The resting layout is built once per snapshot — retargets stop rebuilding it per mouse sample
RestingLayoutCache (session-scoped, @ObservationIgnored on DragSession, cleared at begin and end) holds each lane's resting layout as ids + heights — never [Card] — keyed on the hovered board's applied snapshotGeneration, the registry's new heightsGeneration, the lane, and the operation-following hidden set. The grid stays event-time on purpose (autoscroll moves a lane's origin with the snapshot standing still), so re-grounding rule 1 holds exactly: nothing survives a reload, only the per-sample repetition goes. The steady-state cost of a hover is now the containment scan plus four stores and a divide. Drag-perf suspect #3, card b9f48fd1. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -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<ItemID>
|
||||
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<ItemID>
|
||||
) -> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user