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 = [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") } }