Files
lanework/KanbanTests/TransientBoardStateTests.swift
T
rzen 9c857ae0cc Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board
A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on
the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body
read store.selection in three places — isSelected, the drag replica's count, and
the context menu's styleTarget — and Observation invalidates every reader of the
property, past the equatable gate entirely. The band overlay stayed cheap, which
is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind.

Now LaneView and TrashLaneView hoist one selection read per body and hand each
face isSelected/selectedCount as compared parameters; StyleMenuItems takes its
target as a deferred closure; TrashLaneRowView gains the same treatment plus the
Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A
growing band costs the selection's own running size; the real board's crossing
fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a
separate lane-level finding recorded in RENDER-INSTRUMENTATION.md.

Also: select() gains defaultsSoleMember — the marquee's explicit nils never
avoided the sole-member default, so a one-card band acquired a selectionHead and
could scroll the lane out from under its own drag.

MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a
growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
2026-08-07 15:10:35 -04:00

551 lines
26 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// `TransientBoardState` is one claim stated four ways: **transient state may only reference items
/// the current snapshot has**. So these tests are about what survives a reload and what doesn't —
/// per set, independently, and with the placeholder's lane-anchored variant of the same idea.
///
/// They drive a **real `BoardStore` over a real temp board**, exactly as `BoardStoreTests` does,
/// rather than calling `resolve(against:)` on a hand-built model: the container's contract includes
/// being called by the store's reload path, and a suite that never went through `land(_:...)` could
/// pass with that wire cut. The one exception is the same-rule test, which compares two *pure value*
/// functions and has to call one of them directly to have anything to compare.
// MARK: - Fixtures
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
/// A lane or card whose `deleted:` key is present — the tombstone an agent or a hand-edit adds to a
/// file the user currently has selected, cut, or is creating a card under.
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes and three cards — enough that "overlapping but different" member sets are expressible
/// and that a set can survive the reload that empties another.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
return fixture
}
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)
/// One foreign reload, start to settled — the only way anything in this suite gets re-resolved.
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
// MARK: - Tests
@MainActor
@Suite("TransientBoardState")
struct TransientBoardStateTests {
// MARK: The sets
@Test("A vanished item leaves only the sets that held it — each set resolves independently")
func setsResolveIndependently() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
// always the control.
store.transient.select([card1, card2], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
#expect(store.transient.selection.ids == [card2], "the selection loses the member it held")
#expect(store.transient.pendingCut.ids == [card3], "so does the cut, on its own")
#expect(
store.transient.dragMembers.ids == [card2, card3],
"the drag never held card1 and must come through untouched — a member leaving one set may not disturb another"
)
#expect(store.selection == store.transient.selection, "the store's convenience is the same value")
}
@Test("The filter's universe and a reload's universe are the same rule, expressed once")
func constrainedAndResolvedAgree() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let seeded = ItemReferenceSet(ids: [card1, card2], container: .board)
// Direction one — the live search filter (04-interactions.md § Search): card1's title and
// body both miss the query, so it is not in the visible set the predicate produced, and
// "hidden cards leave the selection" is just this intersection.
let visible: Set<ItemID> = [lane1, lane2, card2, card3]
let filtered = seeded.constrained(to: visible)
// Direction two — reload survival (02-architecture.md § Live-reload resilience): the same
// member, gone from the tree instead of hidden by a predicate.
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
let resolved = seeded.resolved(against: store.snapshot)
#expect(filtered.ids == [card2])
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
}
@Test("A container crossing is a vanish for every item-referencing set")
func aContainerCrossingEjectsFromEverySet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, card2], in: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board)
// A foreign writer moves card1 and card2 into the board's trash — the move a delete is.
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
await reload(store)
// 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
// UUID *and* container side … a foreign move that trashes a selected board card ejects it
// from the selection (and from the pending cut)".
let trashedIDs = Set(store.snapshot.trash.map(\.id))
#expect(trashedIDs == Set([card1, card2]))
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut")
}
@Test("A restore ejects a trash-side set the same way, in the other direction")
func restoringEjectsATrashSideSet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
let store = try BoardStore(rootURL: fixture.root)
// Everything that can point at an item, all aimed at the trashed card.
store.transient.select([card3], in: .trash, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.beginRename(of: card3, currentTitle: "Third")
#expect(store.snapshot.trash.map(\.id) == [card3])
// An agent moves it back out — a container crossing the other way.
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
await reload(store)
#expect(store.snapshot.trash.isEmpty)
#expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
#expect(ItemContainer.board.ids(in: store.snapshot).contains(card3),
"presence is the whole test — the card is simply on the other side now")
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
#expect(store.transient.pendingCut.ids.isEmpty)
// And no cursor survives on it: an anchor that ranges from a container the selection has left
// would be a range the user cannot see the origin of.
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
// The rename editor tracks the *board* container, so a card arriving back on the board keeps
// its editor — "a foreign move mid-rename is invisible" (04 ▸ Grammar). It is the departure
// into the trash that discards it, which the delete tests cover.
#expect(store.transient.renameEditor?.targetID == card3)
// Menu validation agrees with the sets, which is the point of both reading one rule.
let stale = ItemReferenceSet(ids: [card3], container: .trash)
#expect(!TrashModel.canDelete(selection: stale, in: store.snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board),
in: store.snapshot))
}
@Test("Every set resolves to empty against a board whose lanes all vanished")
func everythingResolvesToNothingOnAnEmptyBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, lane1], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
store.transient.beginPlaceholder(inLane: lane2)
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
#expect(store.snapshot.lanes.isEmpty)
#expect(store.transient.selection == .empty)
#expect(store.transient.dragMembers == .empty)
#expect(store.transient.pendingCut == .empty)
#expect(store.transient.newCardPlaceholder == nil)
}
// MARK: The placeholder
@Test("A placeholder whose lane is deleted from the tree is discarded")
func placeholderDiscardedWhenItsLaneIsRemoved() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title")
// Not a tombstone — the folder is gone, the way an agent's `rm -rf` or a Finder delete
// leaves it. Nothing was ever on disk for the placeholder, so there is nothing to clean up.
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
#expect(store.snapshot.lanes.count == 1)
#expect(store.transient.newCardPlaceholder == nil, "the placeholder's lane vanished, so it goes with it")
}
@Test("A placeholder whose lane is tombstoned is discarded — a tombstoned lane renders nowhere")
func placeholderDiscardedWhenItsLaneIsTombstoned() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title")
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
// the reload" is literally the whole test.
#expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
#expect(store.transient.newCardPlaceholder == nil)
}
@Test("The placeholder hands off by discarding itself the moment its real card arrives")
func placeholderHandsOffWhenTheCardArrives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Filed at last")
store.transient.commitPlaceholder(expecting: card4)
#expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(card4))
// What the Writer's create leaves behind, and what the watcher then round-trips: a real
// card folder under the anchor lane, carrying the minted id.
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed at last"))
await reload(store)
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.count == 2)
#expect(store.transient.newCardPlaceholder == nil, "the overlay's job ended when the card it stood in for landed")
}
@Test("The placeholder stands until its card actually arrives — a reload without it changes nothing")
func placeholderStandsUntilItsCardArrives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Filed at last")
store.transient.commitPlaceholder(expecting: card4)
// A reload lands in the gap between the Writer's create and the watcher noticing it — the
// exact window the `.awaitingArrival` phase exists to cover. The lane is intact and the card
// is not there yet, so neither discard rule fires.
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
await reload(store)
#expect(store.snapshot.lanes.count == 3)
#expect(
store.transient.newCardPlaceholder
== NewCardPlaceholder(laneID: lane2, draftTitle: "Filed at last", phase: .awaitingArrival(card4))
)
}
@Test("An unrelated reload swaps the snapshot underneath the placeholder, draft intact")
func placeholderSurvivesAnUnrelatedReload() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane1)
store.transient.updateDraft("Half a ti")
// An agent files a card in the other lane mid-typing. The overlay is not a card and is not
// in the snapshot, so it has nothing to lose here — including the keystrokes, which live
// nowhere else.
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
await reload(store)
#expect(
store.transient.newCardPlaceholder
== NewCardPlaceholder(laneID: lane1, draftTitle: "Half a ti", phase: .editing)
)
}
// MARK: The rename editor
@Test("The rename editor seeds from the current title and records what is typed")
func renameEditorSeedsAndTracks() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
#expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "First"))
#expect(store.transient.isEditingInline)
store.transient.updateRenameDraft("First, renamed")
#expect(store.transient.renameEditor?.draftTitle == "First, renamed")
// An untitled item seeds *empty*, never with the word the face renders: "Untitled" is a
// rendering, not a value (03-board-ui.md § Card face), and typing it into the file would
// turn a missing key into a real title.
store.transient.beginRename(of: lane2, currentTitle: nil)
#expect(store.transient.renameEditor?.draftTitle.isEmpty == true)
store.transient.discardRename()
#expect(store.transient.renameEditor == nil)
#expect(!store.transient.isEditingInline)
}
@Test("One focus, one editor — beginning either kind ends the other")
func theTwoEditorsAreMutuallyExclusive() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane1)
store.transient.updateDraft("half typed")
store.transient.beginRename(of: card1, currentTitle: "First")
// The draft is discarded per the placeholder's own click-away rule — 02-architecture.md's
// "starting a new creation while a placeholder is open is a click-away for the draft",
// read in the other direction.
#expect(store.transient.newCardPlaceholder == nil)
#expect(store.transient.renameEditor?.targetID == card1)
store.transient.beginPlaceholder(inLane: lane2)
#expect(store.transient.renameEditor == nil)
#expect(store.transient.newCardPlaceholder?.laneID == lane2)
}
@Test("A rename whose target is deleted from the tree is discarded")
func renameDiscardedWhenItsTargetIsRemoved() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Never lands")
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
// "A target that is tombstoned, deleted, or gone at commit time discards the editor and its
// keystrokes silently" (04-interactions.md ▸ Grammar).
#expect(store.transient.renameEditor == nil)
}
@Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
func renameDiscardedWhenItsTargetIsTrashed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
// 04 ▸ Grammar: "a target that is trashed, deleted, or gone at commit time discards the
// editor and its keystrokes silently — entering the trash is a vanish from the board".
#expect(store.snapshot.trash.map(\.id) == [card1])
#expect(store.transient.renameEditor == nil)
}
@Test("A rename whose lane is deleted is discarded too — the card went with it")
func renameDiscardedWhenItsLaneIsDeleted() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
await reload(store)
// A lane delete is physical, so the card is simply not in the snapshot — no ancestor walk.
#expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
#expect(store.transient.renameEditor == nil)
}
@Test("A rename survives a foreign move — the editor follows the UUID, not the position")
func renameSurvivesAForeignMove() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Still mine")
// An agent files the card into the other lane mid-typing. "A foreign *move* mid-rename is
// invisible — the editor follows the UUID and the commit writes the title wherever the card
// now lives."
try FileManager.default.moveItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
)
await reload(store)
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.contains { $0.id == card1 } == true)
#expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "Still mine"))
}
@Test("A rename of a lane survives an unrelated reload, draft intact")
func laneRenameSurvivesAnUnrelatedReload() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: lane1, currentTitle: "Todo")
store.transient.updateRenameDraft("To d")
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
await reload(store)
#expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d"))
}
// MARK: The cursors' sole-member default
@Test("The sole-member default is opt-out — a band that sweeps one card acquires no cursors")
func theSoleMemberDefaultIsOptOut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// **The default, unchanged**: a one-item selection made by any ordinary route is a legitimate
// range origin and a legitimate place to arrow from, so `nil` takes the sole member.
store.transient.select([card1], in: .board)
#expect(store.transient.selectionAnchor == card1)
#expect(store.transient.selectionHead == card1)
// **The opt-out**: a rubber band names no click to range from and no item to arrow from
// whatever it happens to enclose, and passing `nil` cannot say so — `nil` is what *asks* for
// the default. `MarqueeControl.gesture` passes the flag, and this is what it buys: a band
// narrowed onto exactly one card leaves both cursors empty, so a ⇧-click after it acts plain
// and `LaneView.cardStack`'s scroll-to has no head to fire on mid-drag.
store.transient.select([card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
#expect(store.transient.selection.ids == [card2], "the selection itself still lands")
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
// The flag defaults nothing away: an explicit cursor is still taken verbatim.
store.transient.select([card2], in: .board, anchor: card2, head: card2, defaultsSoleMember: false)
#expect(store.transient.selectionAnchor == card2)
#expect(store.transient.selectionHead == card2)
// And the store's funnel forwards it — the marquee calls `BoardStore.select`, not the
// transient's directly.
store.select([card1], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
}
// MARK: The last-active lane
@Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it")
func selectionMarksTheActiveLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
store.select([lane2], in: .board)
#expect(store.transient.lastActiveLaneID == lane2)
// A *card* selection is its lane holding selection too — 04's "the lane that most recently
// held selection or a creation".
store.select([card1], in: .board)
#expect(store.transient.lastActiveLaneID == lane1)
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
// guessing at one of the two.
store.select([card1, card3], in: .board)
#expect(store.transient.lastActiveLaneID == lane1)
// Deselecting does not un-happen where the user was working: ⌘N with nothing selected is
// exactly the case the memory exists to answer.
store.clearSelection()
#expect(store.transient.lastActiveLaneID == lane1)
}
@Test("Creating into a lane marks it active, and a vanished lane is forgotten on reload")
func creationMarksTheActiveLaneAndAVanishClearsIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
#expect(store.transient.lastActiveLaneID == lane2)
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
// lane rather than proposing the trash.
#expect(store.transient.lastActiveLaneID == nil)
}
// MARK: Per-open values
@Test("Trash visibility and the search query default per-open and pass through a reload untouched")
func perOpenValuesDefaultAndSurviveResolve() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Fresh container, fresh store — the trash is hidden on every open and there is no query.
// Nothing resets these; being built with the store is the reset.
#expect(store.transient.isTrashVisible == false)
#expect(store.transient.searchQuery.isEmpty)
store.transient.isTrashVisible = true
store.transient.searchQuery = "log"
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fix login"))
await reload(store)
// Neither references an item, so no snapshot can invalidate either — and the query's
// *results* are recomputed rather than stored, which is why there is nothing else to check.
#expect(store.transient.isTrashVisible)
#expect(store.transient.searchQuery == "log")
}
}