Give transient UI state an explicit home in the store

TransientBoardState, one per store, holds state by how a reload treats
it: item-referencing sets (selection, drag membership, pending cut)
share one shape and one constraint rule — members must exist in the
current universe — applied in two directions by one primitive, so the
search filter's hidden-cards rule and reload survival are one rule
expressed once; derived state is stored as its inputs only (the query,
never its result set); and the new-card placeholder is a lane-anchored
overlay with no UUID until commit, discarded when its lane vanishes or
tombstones, handed off when the created card's UUID appears. Trash
visibility rides along per-open, never persisted. The decision is
written back into DESIGN/02 § Changes from Kanban — the TBD is closed.

Full suite 352 tests in 64 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 21:06:26 -04:00
parent 8285e19497
commit 23e761120f
6 changed files with 712 additions and 107 deletions
+289
View File
@@ -0,0 +1,289 @@
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], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live)
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], liveness: .live)
// 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("Tombstoning a lane ejects its cards from every referencing set — liveness is effective")
func effectiveLivenessEjectsFromEverySet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
await reload(store)
// The cards' own flags never changed, but their lane's did and liveness is ancestor-walked
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and
// nothing invisible may stay selected or pending-cut.
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
}
@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], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live)
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 fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
// Still in the snapshot the trash renders its single entry but the lane the editor was
// sitting in is not on the board any more, which is the same vanish as far as an overlay
// anchored to it is concerned.
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
#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: 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")
}
}