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