Phase 2 of the decision surface: the pre-snapshot loading state ruled 2026-07-29 (02 § Launch and window lifecycle), built. The board window appears immediately at its saved frame, titled with the registry record's cached name, its content a centered spinner behind an injectable ~200ms grace — no skeletons, and the first snapshot snaps in place. The tree walk runs off-main via BoardStoreRegistry.acquireOffMain (per-board single-flight keyed by file identity — concurrent opens of one root share a walk, restoration of many boards is genuinely parallel), landing in BoardStore's new designated init(rootURL:loaded:); the self-walking init survives as a convenience for its ~470 callers. ⌘W during the walk is real: configureWindow split into a loading half (frame restore, frame tracking, close interception — installed before the walk) and a store half (toolbar, widget, hideTitle, undo — installed at the snap), and the walk lives in an explicitly held BoardOpenWalk so the user's close and SwiftUI's teardown end in one cancel(). Cancellation is discard-on-completion: nil from acquireOffMain means nothing was built, nothing retained, and no open-now flag was ever set. Failure keeps today's sequence exactly: record the launch failure, welcome's row carries it, the window retires. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
401 lines
18 KiB
Swift
401 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|