diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index dcead9b..9f48f5c 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -44,6 +44,24 @@ final class LaneDropRegistry { var columns: Int /// Spacing between columns and between stacked cards. var spacing: CGFloat + + /// The masonry arithmetic this grid is drawn with — `MasonryLayout`'s own expression, so + /// the zones and the drawn grid cannot disagree (`MasonryPlacement`). + /// + /// **Derived at event time and deliberately never cached**, unlike the resting layout it is + /// applied to (`RestingLayoutCache`): the grid is registered from inside the lane's scroll + /// view, so its origin moves whenever the lane scrolls — which the autoscroll driver does on + /// purpose, mid-drag, with the snapshot standing perfectly still. It is also free: four + /// stores and a divide, no allocation. + var placement: MasonryPlacement { + MasonryPlacement( + columnCount: columns, + columnWidth: MasonryPlacement.columnWidth( + totalWidth: frame.width, columnCount: columns, spacing: spacing), + spacing: spacing, + origin: frame.origin + ) + } } /// The height a card with no registered measurement is assumed to have — a lane whose faces have @@ -68,6 +86,18 @@ final class LaneDropRegistry { private(set) var grids: [ItemID: Grid] = [:] private(set) var heights: [ItemID: CGFloat] = [:] + /// How many times a registered card height has actually **changed**, ever — the freshness signal + /// `RestingLayoutCache` keys its heights on. + /// + /// A height is not snapshot content, and that is the whole reason this counter exists: a face + /// measuring itself for the first time replaces the `nominalCardHeight` stand-in its lane was + /// tiled with, and no store's `snapshotGeneration` moves for it. A layout cached across that + /// would keep proposing against the guess. + /// + /// Bumped only on a real change, so the steady state — a drag over a board that has finished + /// laying out, which is every drag after its first frames — leaves it still and the cache holds. + private(set) var heightsGeneration = 0 + /// Each lane's title bar, in the same global space `Grid.frame` is written in — the topmost-rule /// stripe (`FileDropZones.landing`). Absent for a lane that has not laid its header out yet, and /// the file zones then let the masonry answer alone. @@ -79,8 +109,164 @@ final class LaneDropRegistry { func update(header frame: CGRect, for laneID: ItemID) { headers[laneID] = frame } func removeHeader(_ laneID: ItemID) { headers.removeValue(forKey: laneID) } - func update(height: CGFloat, for cardID: ItemID) { heights[cardID] = height } - func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) } + func update(height: CGFloat, for cardID: ItemID) { + guard heights[cardID] != height else { return } + heights[cardID] = height + heightsGeneration += 1 + } + + func removeHeight(_ cardID: ItemID) { + guard heights.removeValue(forKey: cardID) != nil else { return } + heightsGeneration += 1 + } +} + +// MARK: - The resting layout a card drag proposes against + +/// One lane's **resting layout**, as a card drag's zones read it: the cards standing in it with the +/// dragged run already lifted out, and how tall each one is. +/// +/// **Ids and heights, never `Card` values.** This is what the masonry zones are built over +/// (`DropSlotMath.cardSlot`), and all they want from a card is a number; a `[Card]` of it would be +/// nine `FieldValue`s and a whole `FrontmatterDocument` of ARC traffic per card, rebuilt on every +/// mouse sample of the drag. +struct LaneRestingLayout: Equatable, Sendable { + + /// The lane's cards in logical order with the dragged run lifted out — **the index space every + /// proposal in this lane is counted in**, which is the space `BoardStore.moveCards` / + /// `copyCards` resolve theirs in too (each writer counted in the layout its own gesture showed). + var cardIDs: [ItemID] + + /// Their heights, aligned 1:1 with `cardIDs`, each one the registered measurement or + /// `LaneDropRegistry.nominalCardHeight` for a face that has not laid out yet. + var heights: [CGFloat] +} + +/// The per-lane resting layouts a drag is proposing against, built **once per snapshot** rather than +/// once per mouse sample. +/// +/// ### Why this is not a hole in the re-grounding rule +/// +/// "Geometry re-derives … the resting zones are recomputed against each new snapshot. Nothing is +/// cached across a reload because nothing needs to be" (DRAG-REORDER.md § The mid-drag re-grounding +/// trio, rule 1). Nothing is cached across a reload here either: an entry stands only for the +/// `snapshotGeneration` it was built from, so a foreign reload landing mid-drag retires every layout +/// derived from the snapshot before it and the next sample re-derives against the board as it now +/// is. What this removes is the *repetition* — the same lane rebuilt from the same snapshot dozens +/// of times a second, for samples whose answer `DragSession.propose` then discards as unchanged. +/// +/// ### Why the applied counter is the right one +/// +/// **`BoardStore.snapshotGeneration`, not `landedReloads`.** The layout is a function of the +/// snapshot's *content*, and the applied counter is exactly "the content changed": a value-equal +/// walk lands the same cards in the same order and cannot move a zone, so keying on the walk counter +/// would throw the layout away for reloads that provably changed nothing. The committed-overlay hold +/// does draw an arrangement the snapshot does not describe while it stands — but it also freezes +/// `propose` and `resolveOperation`, so nothing this cache feeds reaches a proposal for as long as +/// it does. +/// +/// ### What the key has to carry, and what it provably need not +/// +/// Everything the layout derives from that can move mid-drag is in the key: the snapshot +/// (`generation`, plus `boardRoot` because a cross-board drag retargets against the *hovered* +/// board's store), the lane, the registered heights (`LaneDropRegistry.heightsGeneration`), and the +/// dragged run's own hidden set — which follows the **operation**, since ⌥ pressed mid-drag leaves a +/// copy's originals standing in the layout (`DragSession.hiddenMembers`). +/// +/// Two inputs are deliberately absent. The **grid** is not layout content — it is read live at every +/// sample (`LaneDropRegistry.Grid.placement`), because a lane that scrolls moves its origin without +/// touching any snapshot. And the **dragged run's frozen heights** are frozen at pickup by +/// construction (`DragSession.cardHeights`), so they cannot move for the life of an entry — which is +/// one drag, since the session clears this at `begin` and at `end`. +@MainActor +final class RestingLayoutCache { + + /// Which lane's layout, in which board window. + /// + /// The **registry** rather than the store: `heights` and `grids` belong to one board window, so + /// two windows open on one board are two layouts even though they share a store and a lane id. + private struct Key: Hashable { + var registry: ObjectIdentifier + var laneID: ItemID + } + + /// A layout plus everything it was derived from — the whole of what invalidates it. + private struct Entry { + var boardRoot: BoardRootKey + var generation: Int + var heightsGeneration: Int + var hidden: Set + var layout: LaneRestingLayout + } + + private var entries: [Key: Entry] = [:] + + /// How many layouts this drag has built, and how many samples were answered from one already + /// standing — the observation handle that makes "the steady state is a containment scan" a test + /// rather than a hope (`RestingLayoutCacheTests`). + /// + /// Instance counters rather than `BoardLoader.ParseCounter`'s injected object, because the + /// subject is different: a walk is a stateless static called from several tasks at once and needs + /// somewhere per-walk to tally into, while this is one object per drag whose whole lifetime is + /// the question being asked. Nothing in the app reads them. + private(set) var builds = 0 + private(set) var reuses = 0 + + init() {} + + /// `laneID`'s resting layout on `store`'s current snapshot, built if this is the first sample to + /// ask for it since anything it derives from moved. + /// + /// - Returns: `nil` when `laneID` is not in the snapshot at all — the vanished-lane case, which + /// is rule 2's and the caller's to answer (`BoardDropContext.revalidateProposal`). + func layout( + inLane laneID: ItemID, + of store: BoardStore, + registry: LaneDropRegistry, + hidden: Set + ) -> LaneRestingLayout? { + let key = Key(registry: ObjectIdentifier(registry), laneID: laneID) + if let entry = entries[key], + entry.generation == store.snapshotGeneration, + entry.heightsGeneration == registry.heightsGeneration, + entry.boardRoot == store.rootKey, + entry.hidden == hidden { + reuses += 1 + return entry.layout + } + + guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else { + entries.removeValue(forKey: key) + return nil + } + + var cardIDs: [ItemID] = [] + var heights: [CGFloat] = [] + cardIDs.reserveCapacity(lane.cards.count) + heights.reserveCapacity(lane.cards.count) + for card in lane.cards where !hidden.contains(card.id) { + cardIDs.append(card.id) + heights.append(registry.heights[card.id] ?? LaneDropRegistry.nominalCardHeight) + } + + let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights) + entries[key] = Entry( + boardRoot: store.rootKey, + generation: store.snapshotGeneration, + heightsGeneration: registry.heightsGeneration, + hidden: hidden, + layout: layout + ) + builds += 1 + return layout + } + + /// Drops every layout — the drag that filled them is over, or a new one is starting. + func clear() { + entries.removeAll(keepingCapacity: true) + builds = 0 + reuses = 0 + } } // MARK: - The board window's half of a drop @@ -224,28 +410,28 @@ struct BoardDropContext { /// originals, because that is the layout the user is looking at. `DropSlotMath` has no opinion /// on the matter — the caller decides what the resting layout contains — and the commit's two /// writers are counted in exactly these two spaces (`BoardStore.moveCards` / `copyCards`). + /// + /// **The layout is rebuilt per snapshot, the zones are resolved per sample** (`RestingLayoutCache` + /// — including why that keeps rule 1 of the re-grounding trio exactly). What is left here at + /// event time is the geometry that genuinely moves under a stationary cursor: the grid, which the + /// autoscroll driver scrolls on purpose, and `DropSlotMath`'s own containment arithmetic. func retargetCards(inLane laneID: ItemID) { guard session.isDraggingCards, let cursor = globalCursor() else { return } - guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else { + guard let resting = session.restingLayouts.layout( + inLane: laneID, + of: store, + registry: registry, + hidden: session.hiddenMembers(onBoardRooted: store.rootKey) + ) else { revalidateProposal() return } guard let grid = registry.grids[laneID] else { return } - let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) - let rendered = lane.cards.filter { !hidden.contains($0.id) } - let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } - let placement = MasonryPlacement( - columnCount: grid.columns, - columnWidth: MasonryPlacement.columnWidth( - totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing), - spacing: grid.spacing, - origin: grid.frame.origin - ) let slot = DropSlotMath.cardSlot( cursor: cursor, - placement: placement, - heights: heights, + placement: grid.placement, + heights: resting.heights, // The run's footprint at the landing spot: the first dragged card's frozen height, which // is the trigger rect the cursor is over (the rest stack below it). draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight, @@ -392,19 +578,12 @@ struct BoardDropContext { let rendered = lane.cards let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } - let placement = MasonryPlacement( - columnCount: grid.columns, - columnWidth: MasonryPlacement.columnWidth( - totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing), - spacing: grid.spacing, - origin: grid.frame.origin - ) let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL])) let landing = FileDropZones.landing( cursor: cursor, headerBottom: registry.headers[laneID]?.maxY, - placement: placement, + placement: grid.placement, heights: heights, nominalHeight: LaneDropRegistry.nominalCardHeight, current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 757f1a6..8659aff 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -323,6 +323,20 @@ final class DragSession { /// whichever board's standard width it is being proposed into. @ObservationIgnored private(set) var laneUnits: [Int] = [] + /// The per-lane resting layouts this drag's retargets propose against, built once per snapshot + /// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1 + /// of the re-grounding trio exactly as it was). + /// + /// `@ObservationIgnored` for the reason `LaneDropRegistry` is not `@Observable` at all: it is + /// written from *event* handlers, and a cache fill that invalidated the strip would be the + /// animation feedback loop this whole model exists to avoid (03-board-ui.md § Motion). + /// + /// **Session-scoped**, so it cannot outlive the drag it was filled for — and so a layout can + /// assume the one input that is neither in its key nor read live, `cardHeights`, is the frozen + /// set this session picked up with. `begin` and `end` bracket every session this object has, and + /// both clear it. + @ObservationIgnored let restingLayouts = RestingLayoutCache() + // MARK: Where it would land /// The current proposal, or `nil` when the drag has none — a fresh session before the first @@ -564,6 +578,7 @@ final class DragSession { mixesKinds: Bool ) { endHold() + restingLayouts.clear() self.kind = kind self.members = members self.memberSet = Set(members) @@ -627,6 +642,7 @@ final class DragSession { folders = [] cardHeights = [] laneUnits = [] + restingLayouts.clear() proposal = nil operation = .move sourceStore = nil diff --git a/KanbanTests/RestingLayoutCacheTests.swift b/KanbanTests/RestingLayoutCacheTests.swift new file mode 100644 index 0000000..24b5476 --- /dev/null +++ b/KanbanTests/RestingLayoutCacheTests.swift @@ -0,0 +1,384 @@ +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() + } +}