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