Files
lanework/KanbanTests/TransientBoardStateTests.swift
T
rzen 53bc71f7fb Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

515 lines
24 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 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")
}
}