import Foundation import Testing @testable import Kanban /// `BoardStore`'s correctness is almost entirely about *what survives what*: a failed reload must /// not eat a good snapshot, a selection must not survive a liveness flip, a wholesale operation's /// expectation must not be consumed by a walk that predates it. So these tests drive the store /// through its one inbound door — `handleWatcherEvent(_:)` — against real boards in real temp /// directories, and never through a `FolderWatcher` (which has its own suite, and whose FSEvents /// timing would turn every assertion here into a race). /// /// **No sleeps stand in for ordering.** `awaitQuiescence()` is how a test waits for the pipeline, /// and the one test that needs a load pinned *open* mid-flight uses `BoardStore.loadBarrier` to pin /// it rather than guessing at a duration. The single polling helper (`waitUntil`) waits for an /// actor to report that a load has arrived at that barrier — a fact, not an elapsed time. // MARK: - Fixtures /// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`; a board store needs /// exactly what the writer suites needed — hand-written `index.md` files in a temp tree — so this /// file borrows them rather than growing a third copy. /// Frontmatter that opens and closes correctly but does not parse: an unclosed flow sequence. The /// shape a non-atomic external write leaves behind when a reload catches it mid-flight, which is the /// case 02-architecture.md § Live-reload resilience is written about. private let brokenIndex = "---\nschema: 1\norder: 1024\nlabels: [a, b\n---\nbody\n" /// A card whose `deleted:` key is present — the tombstone an agent or a hand-edit adds to a file the /// user currently has selected. private func tombstoned(order: String, title: String) -> String { """ --- schema: 1 title: \(title) order: \(order) deleted: 2026-03-03T09:00:00Z --- \(title) body. """ } /// The board every test starts from: two lanes, two cards in the first, and one stray folder so /// `loadWarnings` has something real to carry. @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("notes", "not a board item at all\n") return fixture } private func lane(_ id: String, in snapshot: BoardModel) -> Lane? { snapshot.lanes.first { $0.id.rawValue == id } } private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] { (lane(id, in: snapshot)?.cards ?? []).compactMap(\.title.value) } /// Relative paths as `BoardLoadError` reports them — root-relative, `/`-joined. private func indexPath(_ components: String...) -> String { (components + ["index.md"]).joined(separator: "/") } // MARK: - Assertion helpers /// The refusal-side twin of `WriterTestSupport.writeFailure`: runs `operation` expecting the store /// to turn it away, and hands back the refusal for inspection. @MainActor @discardableResult private func refusal(_ operation: () throws -> Void) -> BoardStoreWriteRefusal? { do { try operation() Issue.record("expected the store to refuse the write, but it ran") return nil } catch let error as BoardStoreWriteRefusal { return error } catch { Issue.record("expected a BoardStoreWriteRefusal, got \(error)") return nil } } /// Counts the bracket calls a store makes, standing in for the watcher the registry will wire up. @MainActor private final class BracketLog { private(set) var begins = 0 private(set) var ends = 0 /// Wired into a store the way the registry will wire its watcher. func attach(to store: BoardStore) { store.watcherBrackets = (begin: { self.begins += 1 }, end: { self.ends += 1 }) } } /// Holds a finished tree walk open until a test says otherwise, and reports when one has arrived. /// /// This is what makes the "an in-flight walk never consumes the wholesale expectation" test a proof /// rather than a coin flip: without it, whether the walk in question had already applied would /// depend on how fast the disk was. private actor LoadGate { private(set) var arrivals = 0 private var isOpen = false private var waiters: [CheckedContinuation] = [] func arrive() async { arrivals += 1 guard !isOpen else { return } await withCheckedContinuation { waiters.append($0) } } func open() { isOpen = true let pending = waiters waiters.removeAll() for waiter in pending { waiter.resume() } } } /// Polls until `condition` holds or the deadline passes. Used only to notice that a load has reached /// the gate — the ordering itself is enforced by the gate, never by the interval. private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: @Sendable () async -> Bool) async { let start = ContinuousClock.now while ContinuousClock.now - start < deadline { if await condition() { return } try? await Task.sleep(for: .milliseconds(5)) } } // MARK: - Tests @MainActor @Suite("BoardStore") struct BoardStoreTests { // MARK: Opening @Test("A valid board loads its snapshot and its warnings at init") func initialLoadSucceeds() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.rootURL == fixture.root) #expect(store.snapshot.lanes.count == 2) #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"]) #expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes"))) #expect(store.reloadFailure == nil) #expect(store.readOnlyLock == nil) #expect(!store.isReadOnly) #expect(store.selection == .empty) } @Test("A broken board throws at init rather than constructing a store — fail-fast") func initialLoadFailsFast() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item(Ident.lane1, brokenIndex) // There is nothing to fall back on before the first load, so every later rule — the banner, // the lock, "a failed reload never replaces a good snapshot" — has no meaning here. do throws(BoardLoadError) { _ = try BoardStore(rootURL: fixture.root) Issue.record("expected the initial load to fail") } catch { #expect(error.path == indexPath(Ident.lane1)) if case .unparseableYAML = error.reason {} else { Issue.record("expected unparseable YAML, got \(error.reason)") } } } // MARK: Reloading @Test("A foreign tree change reloads and the snapshot shows the external edit") func foreignChangeReloads() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Exactly what an agent or an editor does: a card folder appears, with no Writer and no // bracket anywhere near it. try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"]) #expect(store.reloadFailure == nil) } @Test("A failed reload keeps the last good snapshot, and the next success heals it") func failedReloadKeepsTheSnapshot() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let lastGood = store.snapshot try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.snapshot == lastGood, "a failed reload never replaces a good snapshot") #expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1)) #expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")), "warnings describe the snapshot on screen, so they stay with it") // Transient breakage self-heals: the watcher kept watching and the fix arrives as an // ordinary reload. try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fixed")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.reloadFailure == nil) #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Fixed", "Second"]) } @Test("An ordinary failed reload does not lock the board — editing continues around the breakage") func ordinaryFailureDoesNotLock() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.reloadFailure != nil) #expect(store.readOnlyLock == nil) #expect(!store.isReadOnly) // Per-file breakage: the snapshot still describes the tree, so a real write through the // Writer is safe and must go through. // The explicit closure signature is `performWrite`'s one ergonomic wart: with a non-`Void` // result, `T` and the closure's thrown type cannot both be inferred, and the thrown type // widens to `any Error`. See `performWrite`'s doc comment. let created = try store.performWrite { () throws(BoardWriteError) -> ItemID in try BoardWriter.createCard(inLane: fixture.url(Ident.lane2), title: "Filed anyway") } #expect(fixture.exists("\(Ident.lane2)/\(created.rawValue)/index.md")) } // MARK: Wholesale operations @Test("A wholesale operation whose reload succeeds leaves no lock and no leaked expectation") func wholesaleSuccessConsumesTheExpectation() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let brackets = BracketLog() brackets.attach(to: store) try store.performWholesale { try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) } #expect(brackets.begins == 1) #expect(brackets.ends == 1) store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() #expect(store.readOnlyLock == nil) #expect(store.reloadFailure == nil) #expect(store.snapshot.lanes.count == 3) // The expectation was consumed by that reload rather than left armed: an ordinary failure // afterwards is per-file breakage again, and must not lock. try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.reloadFailure != nil) #expect(store.readOnlyLock == nil, "the wholesale expectation must not outlive the reload that consumed it") } @Test("A failed reload after a wholesale operation locks the board, refuses writes, and clears on the next success") func wholesaleFailureLocksAndHeals() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let brackets = BracketLog() brackets.attach(to: store) let lastGood = store.snapshot // The shape of a branch switch that lands a tree this app cannot read: the snapshot on // screen now describes something that is not there any more. try store.performWholesale { try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex) } store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() #expect(store.readOnlyLock == .bracketedReloadFailed) #expect(store.isReadOnly) #expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1)) #expect(store.snapshot == lastGood) // Writes are refused, and refused *before* the bracket opens — a refusal must not leave the // watcher suspended. var ran = false let refused = refusal { try store.performWrite { ran = true } } #expect(refused == .readOnlyLocked(.bracketedReloadFailed)) #expect(!ran) #expect(brackets.begins == 1, "the refusal opened no bracket") #expect(brackets.ends == 1) // A locked board refuses to *start* wholesale work too. refusal { try store.performWholesale { ran = true } } #expect(!ran) // Reading stays live throughout — keeping the last-good snapshot is the point of the lock. store.select([ItemID(rawValue: Ident.card2)], liveness: .live) #expect(store.selection.ids.count == 1) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.readOnlyLock == nil, "the next successful reload clears both the banner and the lock") #expect(store.reloadFailure == nil) #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Repaired", "Second"]) try store.performWrite { ran = true } #expect(ran) } @Test("The wholesale expectation binds to the walk started after it, never to one already in flight") func wholesaleExpectationSurvivesAnInFlightLoad() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let gate = LoadGate() store.loadBarrier = { await gate.arrive() } // Walk 1 reads the tree while it is still valid, then parks before applying. store.handleWatcherEvent(.treeChanged(.foreign)) await waitUntil { await gate.arrivals >= 1 } // Broken only now — walk 1 is past its read, walk 2 is not. try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex) // Armed while walk 1 is demonstrably still in flight. A boolean flag would be consumed by // walk 1's *successful* application and would leave walk 2's failure unlocked. try store.performWholesale {} store.handleWatcherEvent(.treeChanged(.appMediated)) await gate.open() await store.awaitQuiescence() #expect(store.reloadGeneration == 2, "two walks: the one in flight, then the one the operation owed") #expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1)) #expect(store.readOnlyLock == .bracketedReloadFailed) } // MARK: Selection across reloads /// The rules themselves belong to `ItemReferenceSet`/`TransientBoardState` and are proved in /// `TransientBoardStateTests` across all three referencing sets. These stay because what they /// pin here is the *store's* half: that `land(_:generation:origin:)` re-resolves on success and /// only on success, read through the `selection`/`select`/`clearSelection` conveniences the /// command sites use. @Test("A selected item that vanished from the tree leaves the selection; the survivor stays") func selectionDropsVanishedMembers() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) #expect(store.selection.liveness == .live) } @Test("A liveness flip is a vanish: a selected card tombstoned externally leaves the selection") func selectionEjectsALivenessFlip() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() // Still in the snapshot — the trash renders it — but no longer on the selection's side of // the boundary, so the homogeneous-by-liveness invariant survives a foreign edit. let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 } #expect(flipped?.isDeleted == true) #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) } @Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective") func selectionEjectsCardsUnderATombstonedLane() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() // The cards' own flags never changed, but their lane's did — and liveness is // ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to // a single trash entry, and nothing invisible may stay selected. let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 } #expect(survivor?.isDeleted == false, "the card's own flag is untouched") #expect(store.selection.ids.isEmpty) } @Test("A selection can resolve to nothing, and nothing is invented to replace it") func selectionCanResolveToNothing() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.selection.ids.isEmpty) #expect(store.selection.liveness == .live, "the side survives even when the membership does not") store.select([ItemID(rawValue: Ident.lane1)], liveness: .live) store.clearSelection() #expect(store.selection == .empty) } // MARK: Coalescing @Test("A burst of signals during one walk coalesces into exactly one follow-up") func signalBurstCoalescesIntoOneFollowUp() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) // No `await` between them, so the main actor never yields and no result can land in the // middle: the first signal starts a walk, the other four fold into one pending reload. for _ in 0..<5 { store.handleWatcherEvent(.treeChanged(.foreign)) } await store.awaitQuiescence() #expect(store.reloadGeneration == 2, "five signals, one walk plus one follow-up") #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"]) #expect(store.reloadFailure == nil) } // MARK: Brackets @Test("performWrite brackets exactly once, whether the operation returns or throws") func performWriteBracketsExactlyOnce() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let brackets = BracketLog() brackets.attach(to: store) var ran = false try store.performWrite { ran = true } #expect(ran) #expect(brackets.begins == 1) #expect(brackets.ends == 1) // A Writer operation that fails partway has still touched disk, so the bracket has to close // on the throwing path too — an unbalanced one would suspend the watcher for the session. let boom = BoardWriteError(operation: .style(title: nil), path: "/nowhere", reason: .io(message: "disk full")) do { try store.performWrite { () throws(BoardWriteError) -> Void in throw boom } Issue.record("expected the operation's own error to propagate") } catch let error as BoardWriteError { #expect(error == boom, "the store rethrows the Writer's error untouched") } catch { Issue.record("expected a BoardWriteError, got \(error)") } #expect(brackets.begins == 2) #expect(brackets.ends == 2) } // MARK: Root changes @Test("A root change with no delegate wired leaves the last good snapshot alone") func rootChangeWithoutADelegateIsANoOp() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let lastGood = store.snapshot // Re-resolve-or-lock needs a bookmark this store does not own, so the response is // delegated (the registry wires it — `RootRecoveryTests` drives the real thing). With no // delegate, the honest answer is the same one every failure path gives: keep the last good // snapshot. Guessing here would lock a board that had merely moved. store.handleWatcherEvent(.rootChanged) await store.awaitQuiescence() #expect(store.snapshot == lastGood) #expect(store.reloadFailure == nil) #expect(store.readOnlyLock == nil) #expect(store.reloadGeneration == 0, "a root change schedules no reload of its own") } @Test("A root change with a delegate hands over and starts no reload of its own") func rootChangeIsDelegated() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) var calls = 0 store.rootChangeDelegate = { calls += 1 } store.handleWatcherEvent(.rootChanged) await store.awaitQuiescence() #expect(calls == 1) // The delegate's two outcomes both end in a reload — at the re-resolved root, or on the // root's return. One fired from here would walk a path that just stopped being the board. #expect(store.reloadGeneration == 0) } }