Files
lanework/KanbanTests/TransientBoardStateTests.swift
T
rzen b35566e0fe Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:52:24 -04:00

471 lines
21 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], 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: 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 is tombstoned is discarded — a liveness flip is a vanish")
func renameDiscardedWhenItsTargetIsTombstoned() 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.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
await reload(store)
#expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true)
#expect(store.transient.renameEditor == nil)
}
@Test("A rename under a tombstoned lane is discarded too — liveness is effective")
func renameDiscardedWhenItsLaneIsTombstoned() 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.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
await reload(store)
// The card's own flag never changed; its lane's did. The ancestor walk is absolute — the
// card renders nowhere, so the editor sitting on it has no target
// (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor).
let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(card?.isDeleted == false)
#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], liveness: .live)
#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], liveness: .live)
#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], liveness: .live)
#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 fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
// A lane that renders nowhere 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")
}
}