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
290 lines
13 KiB
Swift
290 lines
13 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: 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")
|
|
}
|
|
}
|