import Foundation import Testing @testable import Kanban /// `BoardStoreRegistry`'s whole job is *sharing*: one store and one watcher per board, handed to /// every window that asks, torn down once the last one lets go. So these tests are almost entirely /// about object identity (`===`/`!==`) and about the count that orders teardown — the two things a /// second store or a leaked watcher would break silently. /// /// One test deliberately runs the **real** FSEvents path rather than poking /// `handleWatcherEvent(_:)` by hand: the registry's reason to exist is the wiring, and wiring /// asserted against a hand-delivered event is wiring that was never tested. It borrows /// `FolderWatcherTests`' idiom for that — generous polling when waiting *for* something, never a /// fixed sleep standing in for an ordering claim. // MARK: - Fixtures /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`, like every other suite /// here that needs a real board in a real temp directory. /// Frontmatter that opens and closes but does not parse — the fail-fast case, borrowed in shape /// from `BoardStoreTests`. private let brokenIndex = "---\nschema: 1\norder: 1024\nlabels: [a, b\n---\nbody\n" /// Two lanes, two cards in the first. Enough tree that a reload has something to notice. @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")) return fixture } private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] { (snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value) } /// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is /// not a bounded-latency promise and a slow machine must not fail a correctness test. @MainActor private func waitUntil(_ deadline: Duration = .seconds(10), _ condition: () -> Bool) async { let start = ContinuousClock.now while ContinuousClock.now - start < deadline { if condition() { return } try? await Task.sleep(for: .milliseconds(25)) } } /// Gives a freshly started stream a beat to register with `fseventsd`. Without it the first write /// of a test can land in the window between `FSEventStreamStart` and the stream actually being /// live — see `FolderWatcherTests` for the same note. @MainActor private func settle() async { try? await Task.sleep(for: .milliseconds(300)) } // MARK: - Tests @MainActor @Suite("BoardStoreRegistry") struct BoardStoreRegistryTests { // MARK: Sharing @Test("Two acquires of one board share a store; two boards get two") func acquireSharesOneStorePerBoard() async throws { let first = try makeBoard() defer { first.tearDown() } let second = try makeBoard() defer { second.tearDown() } let registry = BoardStoreRegistry() // The board window, then one of its card windows. let boardWindowStore = try registry.acquire(first.root) let cardWindowStore = try registry.acquire(first.root) #expect(boardWindowStore === cardWindowStore, "a card window must share its board's store, not load a second one") #expect(registry.openBoardCount == 1, "two references, one open board") let other = try registry.acquire(second.root) #expect(other !== boardWindowStore) #expect(registry.openBoardCount == 2) registry.release(boardWindowStore) registry.release(cardWindowStore) registry.release(other) } // MARK: Refcounted teardown @Test("The last release tears the board down; a later acquire opens it fresh") func refcountOrdersTeardown() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) _ = try registry.acquire(fixture.root) // The card window closes first. The board is still on screen, so nothing may be torn down — // this is the ordering the refcount exists for. registry.release(store) #expect(registry.liveStore(for: fixture.root) === store) #expect(registry.openBoardCount == 1) registry.release(store) #expect(registry.liveStore(for: fixture.root) == nil) #expect(registry.openBoardCount == 0) // Reopening is a genuine open — a fresh load, a fresh watcher — not a resurrection of the // store that was let go. let reopened = try registry.acquire(fixture.root) #expect(reopened !== store) #expect(registry.openBoardCount == 1) registry.release(reopened) } // MARK: Identity, not paths @Test("A board acquired through a renamed path lands on the store it already has") func acquireFollowsFileIdentityAcrossARename() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) // A Finder rename, which 01-storage-format.md calls ordinary: same volume, same folder, new // name. The board is the file, not the string that names it. let renamed = fixture.root .deletingLastPathComponent() .appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true) try FileManager.default.moveItem(at: fixture.root, to: renamed) defer { try? FileManager.default.removeItem(at: renamed) } let again = try registry.acquire(renamed) #expect(again === store, "path-keyed registries open a second store here; identity-keyed ones do not") #expect(registry.openBoardCount == 1) #expect(registry.liveStore(for: renamed) === store) registry.release(store) registry.release(store) #expect(registry.openBoardCount == 0) } // MARK: The wiring @Test("The watcher the registry attaches really drives the store") func watcherWiringIsReal() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } // The other half of the wiring: the store can suspend the watcher for its own writes. #expect(store.watcherBrackets != nil) await settle() // A card folder appearing with no Writer and no bracket anywhere near it — an agent, or an // editor. Nothing in this test hands the store an event; FSEvents does. try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") } #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"]) #expect(store.reloadFailure == nil) } // MARK: Teardown races @Test("Releasing a store the registry never handed out is a no-op") func releasingAnUnknownStoreIsHarmless() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let stranger = try makeBoard() defer { stranger.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) // A store nobody registered — the shape a double-dismiss or a directly built test store // takes. Trapping here would turn an ordinary window-close race into a crash. registry.release(try BoardStore(rootURL: stranger.root)) #expect(registry.openBoardCount == 1) #expect(registry.liveStore(for: fixture.root) === store) // And releasing one twice past zero. registry.release(store) registry.release(store) #expect(registry.openBoardCount == 0) } // MARK: Fail-fast @Test("A board that fails to open leaves no entry behind") func failedAcquireRegistersNothing() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item(Ident.lane1, brokenIndex) let registry = BoardStoreRegistry() do throws(BoardLoadFailure) { _ = try registry.acquire(fixture.root) Issue.record("expected the load to fail fast") } catch { if case .unparseableYAML = error.primary.reason {} else { Issue.record("expected unparseable YAML, got \(error.primary.reason)") } } #expect(registry.openBoardCount == 0, "a board that failed to open is not open") #expect(registry.liveStore(for: fixture.root) == nil) // And the registry is not poisoned by the failure: the repaired board opens normally. try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) let store = try registry.acquire(fixture.root) #expect(registry.openBoardCount == 1) #expect(store.snapshot.lanes.count == 2) registry.release(store) } /// **Every scheduled heal fires at open** (02-architecture.md ▸ Components ▸ HealScheduler, /// settled 2026-07-29: "fires uniformly at the reload tail and at registry acquire, closing /// today's asymmetry where tombstone migration never fires at open"). /// /// Before the engine this seam named two of the three healers by hand, which is how the /// legacy-tombstone migration came to be the one heal that never fired at open: a board opened, /// migrated nothing, and waited for an unrelated filesystem event to do what opening should have /// done. The three defects below are healed by `acquire` alone — no watcher event, no reload /// beyond the one each heal's own write produces. @Test("Opening a board runs every scheduled heal, migration included") func acquireRunsEveryScheduledHeal() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } // One of each: a legacy tombstone, a loose card file, and a squatter on a claimed name. try fixture.item( "\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) try fixture.file("\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("notes".utf8)) try fixture.file(".trash", Data("squatter".utf8)) let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } // The migration — the one that used to wait for an unrelated event. await waitUntil { fixture.exists(".trash/\(Ident.card3)") } #expect(fixture.exists(".trash/\(Ident.card3)")) #expect(try !fixture.indexText(".trash/\(Ident.card3)").contains("deleted:")) // The relocation, the displacement, and the guide — the three that already did. #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data("notes".utf8)) #expect(try fixture.data(".trash 2") == Data("squatter".utf8)) #expect(fixture.exists(AgentGuide.filename)) } @Test("Acquiring a root that does not exist throws the loader's own error") func acquireOfAMissingRootThrows() async throws { let registry = BoardStoreRegistry() let missing = FileManager.default.temporaryDirectory .appendingPathComponent("no-such-board-\(UUID().uuidString)", isDirectory: true) do throws(BoardLoadFailure) { _ = try registry.acquire(missing) Issue.record("expected a missing root to fail") } catch { // The identity read fails first, and the registry deliberately says nothing about that — // it lets `BoardStore`'s load produce the honest reason. #expect(error.primary.path == ".") if case .unreadableRoot = error.primary.reason {} else { Issue.record("expected an unreadable root, got \(error.primary.reason)") } } #expect(registry.openBoardCount == 0) } // MARK: The off-main acquire // // The board window's open (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29): // the walk runs off the main actor so the window can be on screen while it does, concurrent asks // for one board share it, and a cancelled open leaves nothing behind. These are claims about // *what is registered*, so they are asserted the way the rest of this file asserts — object // identity and the entry count, never a duration. @Test("An already-open board answers the async acquire without a walk") func offMainAcquireHitsTheOpenBoard() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let first = try registry.acquire(fixture.root) let second = try await registry.acquireOffMain(fixture.root) #expect(second === first, "an open board is the same store however it is asked for") #expect(registry.openBoardCount == 1) // Two references, so the first release must not tear anything down. registry.release(first) #expect(registry.liveStore(for: fixture.root) === first) registry.release(first) #expect(registry.openBoardCount == 0) } @Test("Concurrent async acquires of one board single-flight into one store") func offMainAcquiresSingleFlight() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() // Both calls are in flight before either can finish: each hops to the main actor, and the // first one to get there suspends on its walk with the second right behind it. async let first = registry.acquireOffMain(fixture.root) async let second = registry.acquireOffMain(fixture.root) let stores = try await (first, second) #expect(stores.0 != nil) #expect(stores.0 === stores.1, "a joined walk must produce one store, not two") #expect(registry.openBoardCount == 1, "two windows racing one board is still one open board") // And both callers really are holding it: the refcount took both, so the first release // leaves the board standing. guard let store = stores.0 else { return } registry.release(store) #expect(registry.liveStore(for: fixture.root) === store) registry.release(store) #expect(registry.openBoardCount == 0) } @Test("Two boards opening at once are two independent walks") func offMainAcquiresOfDistinctBoardsDoNotShare() async throws { let first = try makeBoard() defer { first.tearDown() } let second = try makeBoard() defer { second.tearDown() } let registry = BoardStoreRegistry() // Restoration's shape: several windows opening together. The single flight is keyed by file // identity, so nothing here can queue behind anything else — a slow board holds its own key // and no other. async let one = registry.acquireOffMain(first.root) async let two = registry.acquireOffMain(second.root) let stores = try await (one, two) #expect(stores.0 != nil) #expect(stores.1 != nil) #expect(stores.0 !== stores.1) #expect(registry.openBoardCount == 2) if let store = stores.0 { registry.release(store) } if let store = stores.1 { registry.release(store) } #expect(registry.openBoardCount == 0) } @Test("A cancelled async acquire builds no store and registers nothing") func cancelledOffMainAcquireLeavesNothingBehind() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() // ⌘W during the walk. The walk itself is not cooperatively cancellable — it runs to // completion and its result is discarded — so what is asserted here is the discard: no // store, no entry, no watcher, no reference. let open = Task { try? await registry.acquireOffMain(fixture.root) } open.cancel() let store = await open.value #expect(store == nil, "a cancelled open answers with nothing rather than a board") #expect(registry.openBoardCount == 0) #expect(registry.liveStore(for: fixture.root) == nil) // And the board is still openable afterwards: a discarded walk must leave no half-state a // later open could trip over. let reopened = try await registry.acquireOffMain(fixture.root) #expect(reopened != nil) #expect(registry.openBoardCount == 1) if let reopened { registry.release(reopened) } } @Test("The async acquire fails fail-fast like the synchronous one") func offMainAcquireOfABrokenBoardThrows() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item(Ident.lane1, brokenIndex) let registry = BoardStoreRegistry() do throws(BoardLoadFailure) { _ = try await registry.acquireOffMain(fixture.root) Issue.record("expected a broken board to fail") } catch { #expect(error.primary.path == "\(Ident.lane1)/index.md") } // A failed load leaves nothing behind, whichever actor walked it. #expect(registry.openBoardCount == 0) } }