View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
385 lines
17 KiB
Swift
385 lines
17 KiB
Swift
import CoreGraphics
|
|
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// **The card drag's resting layout, built once per snapshot rather than once per mouse sample**
|
|
/// (`RestingLayoutCache`; DRAG-REORDER.md § The mid-drag re-grounding trio, rule 1).
|
|
///
|
|
/// The cache is transparent by construction — `BoardDropContext.retargetCards` hands
|
|
/// `DropSlotMath.cardSlot` the same heights it always did — so what is worth pinning is not the
|
|
/// arithmetic (that is `DropSlotMathTests`') but the **key**: every input the layout derives from is
|
|
/// either in it or provably still for the drag's duration, and a missing one would be stale
|
|
/// proposals rather than a slow drag.
|
|
///
|
|
/// Boards are real loads off real temp trees, `ViewEquatableTests`' reason: a hand-assembled `Lane`
|
|
/// would be caching something `BoardLoader` can never produce, and "a reload mid-drag re-grounds the
|
|
/// zones" is only worth asserting against the snapshots a reload actually lands.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// Two lanes, three cards in the first — enough for a layout with an interior card to lift out of,
|
|
/// and a second lane to prove the entries are per lane.
|
|
@MainActor
|
|
private func makeFixture() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.board()
|
|
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
|
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First")
|
|
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second")
|
|
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
|
|
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
|
|
try fixture.card(Ident.card4, in: Ident.lane2, order: "1024", title: "Fourth")
|
|
return fixture
|
|
}
|
|
|
|
/// An identity neither fixture board has seen — what an agent files into a lane mid-drag.
|
|
private let foreignName = "abcdabcd-abcd-4bcd-8bcd-abcdabcdabcd"
|
|
private let foreignCard = ItemID(rawValue: foreignName)
|
|
|
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
|
private let card1 = ItemID(rawValue: Ident.card1)
|
|
private let card2 = ItemID(rawValue: Ident.card2)
|
|
private let card3 = ItemID(rawValue: Ident.card3)
|
|
private let card4 = ItemID(rawValue: Ident.card4)
|
|
|
|
/// A registry with every card's height registered — a board that has finished laying out, which is
|
|
/// the state a drag spends all but its first frames in.
|
|
@MainActor
|
|
private func makeRegistry(_ heights: [ItemID: CGFloat] = [card1: 40, card2: 60, card3: 80, card4: 50])
|
|
-> LaneDropRegistry {
|
|
let registry = LaneDropRegistry()
|
|
for (id, height) in heights { registry.update(height: height, for: id) }
|
|
return registry
|
|
}
|
|
|
|
/// One reload through the store's one inbound door — a foreign one, the mid-drag case the
|
|
/// re-grounding trio is written for.
|
|
@MainActor
|
|
private func reload(_ store: BoardStore) async {
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
}
|
|
|
|
// MARK: - The key
|
|
|
|
@MainActor
|
|
@Suite("The card drag's resting-layout cache")
|
|
struct RestingLayoutCacheTests {
|
|
|
|
@Test("A second sample against the same snapshot rebuilds nothing")
|
|
func sameKeyReuses() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
let first = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
|
let second = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
|
|
|
#expect(first?.cardIDs == [card1, card2, card3])
|
|
#expect(first?.heights == [40, 60, 80])
|
|
#expect(second == first)
|
|
#expect(cache.builds == 1)
|
|
#expect(cache.reuses == 1)
|
|
}
|
|
|
|
@Test("An unmeasured card tiles at the nominal height, exactly as the uncached path did")
|
|
func unmeasuredCardsFallBack() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry([card1: 40])
|
|
let cache = RestingLayoutCache()
|
|
|
|
let layout = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
|
|
|
let nominal = registry.nominalCardHeight
|
|
#expect(layout?.heights == [40, nominal, nominal])
|
|
}
|
|
|
|
@Test("The dragged run is lifted out of the layout, and the hidden set is part of the key")
|
|
func hiddenMembersAreKeyed() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
// A move: the run is out of the layout the zones are counted in.
|
|
let moving = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [card2])
|
|
#expect(moving?.cardIDs == [card1, card3])
|
|
#expect(moving?.heights == [40, 80])
|
|
|
|
// ⌥ mid-drag — `hiddenMembers` answers the empty set for a copy, because the originals stay
|
|
// (`DragSession.hiddenMembers`). A key that missed the flip would keep proposing in the
|
|
// move's index space while the user is looking at the copy's.
|
|
let copying = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
|
#expect(copying?.cardIDs == [card1, card2, card3])
|
|
#expect(cache.builds == 2)
|
|
#expect(cache.reuses == 0)
|
|
|
|
// And back: the flip is a key change in both directions, not a one-way invalidation.
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [card2]) == moving)
|
|
#expect(cache.builds == 3)
|
|
}
|
|
|
|
@Test("Each lane is its own entry, so crossing lanes and coming back rebuilds neither")
|
|
func lanesAreSeparateEntries() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.cardIDs
|
|
== [card1, card2, card3])
|
|
#expect(cache.layout(inLane: lane2, of: store, registry: registry, hidden: [])?.cardIDs
|
|
== [card4])
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.cardIDs
|
|
== [card1, card2, card3])
|
|
|
|
#expect(cache.builds == 2)
|
|
#expect(cache.reuses == 1)
|
|
}
|
|
|
|
@Test("A lane that is not in the snapshot answers nothing, and leaves no entry behind")
|
|
func aVanishedLaneAnswersNothing() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
// The caller's cue to run rule 2's revalidation (`BoardDropContext.retargetCards`), and it
|
|
// has to arrive on *every* sample rather than once — so nothing may be cached for it.
|
|
#expect(cache.layout(inLane: ItemID(rawValue: Ident.lane3), of: store,
|
|
registry: registry, hidden: []) == nil)
|
|
#expect(cache.layout(inLane: ItemID(rawValue: Ident.lane3), of: store,
|
|
registry: registry, hidden: []) == nil)
|
|
#expect(cache.builds == 0)
|
|
#expect(cache.reuses == 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - The snapshot, and which counter answers for it
|
|
|
|
/// **Rule 1 of the mid-drag re-grounding trio, under a cache** (DRAG-REORDER.md): "the resting zones
|
|
/// are recomputed against each new snapshot … nothing is cached across a reload because nothing
|
|
/// needs to be".
|
|
@MainActor
|
|
@Suite("The resting-layout cache ▸ re-grounding")
|
|
struct RestingLayoutRegroundingTests {
|
|
|
|
@Test("A foreign card arriving mid-drag re-derives the layout against the new snapshot")
|
|
func aReloadRegroundsTheLayout() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.cardIDs
|
|
== [card1, card2, card3])
|
|
|
|
// An agent files a card between First and Second while the drag is in flight.
|
|
try fixture.card(foreignName, in: Ident.lane1, order: "1536", title: "Foreign")
|
|
await reload(store)
|
|
#expect(store.snapshotGeneration == 1)
|
|
|
|
// The next sample proposes against the board as it now is, not as it was at pickup — and
|
|
// the arrival tiles at the nominal height until its face measures itself, exactly as the
|
|
// uncached path left it.
|
|
let reground = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
|
#expect(reground?.cardIDs == [card1, foreignCard, card2, card3])
|
|
#expect(reground?.heights == [40, registry.nominalCardHeight, 60, 80])
|
|
#expect(cache.builds == 2)
|
|
#expect(cache.reuses == 0)
|
|
}
|
|
|
|
@Test("A value-equal reload rebuilds nothing — the applied counter is the key, not the walk")
|
|
func aValueEqualReloadKeepsTheLayout() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: []) != nil)
|
|
|
|
// A walk that landed and found nothing to assign (`BoardStore.landedReloads` vs
|
|
// `snapshotGeneration`, split 2026-07-31). The same cards in the same order cannot move a
|
|
// zone, so keying on the walk counter would throw the layout away for a reload that
|
|
// provably changed nothing — which, under a watcher, is most of them.
|
|
await reload(store)
|
|
#expect(store.landedReloads == 1)
|
|
#expect(store.snapshotGeneration == 0)
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.cardIDs
|
|
== [card1, card2, card3])
|
|
#expect(cache.builds == 1)
|
|
#expect(cache.reuses == 1)
|
|
}
|
|
|
|
@Test("A lane deleted mid-drag stops answering, which is what withdraws the proposal")
|
|
func aDeletedLaneStopsAnswering() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: []) != nil)
|
|
|
|
try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)")
|
|
await reload(store)
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: []) == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The heights, and the two board windows
|
|
|
|
/// The inputs that move **without** a snapshot behind them: a face measuring itself for the first
|
|
/// time, and a second window drawing the same board at a different size.
|
|
@MainActor
|
|
@Suite("The resting-layout cache ▸ measured heights and board identity")
|
|
struct RestingLayoutMeasurementTests {
|
|
|
|
@Test("A height that actually changes re-derives the layout; one that repeats does not")
|
|
func heightsAreKeyed() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
|
== [40, 60, 80])
|
|
|
|
// The re-register a layout pass makes with nothing to say — the common case, and the one
|
|
// that must not cost a rebuild.
|
|
registry.update(height: 60, for: card2)
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
|
== [40, 60, 80])
|
|
#expect(cache.builds == 1)
|
|
#expect(cache.reuses == 1)
|
|
|
|
// A real measurement. No snapshot moved for it, so nothing but the registry's own counter
|
|
// can retire the layout that tiled the lane at the old figure.
|
|
registry.update(height: 100, for: card2)
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
|
== [40, 100, 80])
|
|
#expect(cache.builds == 2)
|
|
}
|
|
|
|
@Test("A card face going away re-derives too")
|
|
func removingAHeightIsAChange() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let cache = RestingLayoutCache()
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
|
== [40, 60, 80])
|
|
|
|
registry.removeHeight(card3)
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
|
== [40, 60, registry.nominalCardHeight])
|
|
#expect(cache.builds == 2)
|
|
|
|
// A second removal of the same card is not a change, and must not cost a rebuild.
|
|
registry.removeHeight(card3)
|
|
_ = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
|
#expect(cache.builds == 2)
|
|
#expect(cache.reuses == 1)
|
|
}
|
|
|
|
@Test("Two board windows are two layouts, even for one board's lane")
|
|
func registriesDoNotCollide() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let cache = RestingLayoutCache()
|
|
|
|
// One store, two `LaneDropRegistry`s — a board open in two windows, whose faces are measured
|
|
// separately and can be mid-layout in one while settled in the other.
|
|
let settled = makeRegistry()
|
|
let opening = makeRegistry([:])
|
|
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: settled, hidden: [])?.heights
|
|
== [40, 60, 80])
|
|
let nominal = opening.nominalCardHeight
|
|
#expect(cache.layout(inLane: lane1, of: store, registry: opening, hidden: [])?.heights
|
|
== [nominal, nominal, nominal])
|
|
#expect(cache.builds == 2)
|
|
}
|
|
|
|
@Test("Two boards holding a lane under one id are two layouts")
|
|
func boardsDoNotCollide() throws {
|
|
let here = try makeFixture()
|
|
defer { here.tearDown() }
|
|
// A board duplicated on disk: same lane id, different content. A cross-board drag retargets
|
|
// against the *hovered* board's store, so a layout that answered for the wrong root would
|
|
// propose into a lane the user is not over.
|
|
let there = try WriterFixture()
|
|
defer { there.tearDown() }
|
|
try there.board()
|
|
try there.lane(Ident.lane1, order: "1024", title: "Todo")
|
|
try there.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Only")
|
|
|
|
let cache = RestingLayoutCache()
|
|
let registry = makeRegistry()
|
|
let hereStore = try BoardStore(rootURL: here.root)
|
|
let thereStore = try BoardStore(rootURL: there.root)
|
|
|
|
// Deliberately the *same* registry for both: the key's registry identity would separate two
|
|
// windows on its own, and this is the entry's `boardRoot` guard being pinned rather than it.
|
|
#expect(cache.layout(inLane: lane1, of: hereStore, registry: registry, hidden: [])?.cardIDs
|
|
== [card1, card2, card3])
|
|
#expect(cache.layout(inLane: lane1, of: thereStore, registry: registry, hidden: [])?.cardIDs
|
|
== [card4])
|
|
#expect(cache.layout(inLane: lane1, of: hereStore, registry: registry, hidden: [])?.cardIDs
|
|
== [card1, card2, card3])
|
|
#expect(cache.builds == 3)
|
|
}
|
|
}
|
|
|
|
// MARK: - The lifecycle
|
|
|
|
/// The cache is the **drag's**, not the board's: it is filled by one session's samples and must not
|
|
/// be readable by the next one's (`DragSession.restingLayouts`).
|
|
@MainActor
|
|
@Suite("The resting-layout cache ▸ session lifecycle")
|
|
struct RestingLayoutLifecycleTests {
|
|
|
|
@Test("Beginning and ending a drag both drop every layout")
|
|
func theCacheIsSessionScoped() throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let registry = makeRegistry()
|
|
let session = DragSession()
|
|
|
|
session.beginCards([card1], folders: [], heights: [40], container: .board, source: store)
|
|
_ = session.restingLayouts.layout(inLane: lane1, of: store, registry: registry, hidden: [card1])
|
|
_ = session.restingLayouts.layout(inLane: lane1, of: store, registry: registry, hidden: [card1])
|
|
#expect(session.restingLayouts.builds == 1)
|
|
#expect(session.restingLayouts.reuses == 1)
|
|
|
|
session.end()
|
|
#expect(session.restingLayouts.builds == 0)
|
|
#expect(session.restingLayouts.reuses == 0)
|
|
|
|
// A second drag starts cold, whatever the first one left standing.
|
|
session.beginCards([card2], folders: [], heights: [60], container: .board, source: store)
|
|
_ = session.restingLayouts.layout(inLane: lane1, of: store, registry: registry, hidden: [card2])
|
|
#expect(session.restingLayouts.builds == 1)
|
|
#expect(session.restingLayouts.reuses == 0)
|
|
session.end()
|
|
}
|
|
}
|