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 = LaneDropRegistry.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, LaneDropRegistry.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, LaneDropRegistry.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 = LaneDropRegistry.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() } }