Files
lanework/KanbanTests/BoardStoreRegistryTests.swift
T
rzen f0e1738964 Build the board registry and per-board app state
Two registries (Kanban/LiveStore/): BoardStoreRegistry shares one live
store and one started, fully wired watcher per open board across its
windows — keyed by file identity (fileResourceIdentifier), never path,
refcounted to order teardown; release matches by store identity so a
board renamed while open can't leak its watcher. BoardRegistry persists
app-private per-board state in Application Support as diff-stable JSON:
records anchored by security-scoped bookmarks, recents = the registry
sorted by lastOpened (counts registry-cached, never scanned), graceful
orphaning with Forget, corrupt files quarantined aside, and files-first
verified — the board tree is untouched byte-for-byte. Timestamps use
ISO8601DateFormatter with fractional seconds: the FormatStyle variant
truncates-then-rounds and drifts a millisecond per round trip.

16 registry tests; full suite 297 tests in 56 suites green. Four
findings filed on the Redesign board.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 19:46:18 -04:00

247 lines
10 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(BoardLoadError) {
_ = try registry.acquire(fixture.root)
Issue.record("expected the load to fail fast")
} catch {
if case .unparseableYAML = error.reason {} else {
Issue.record("expected unparseable YAML, got \(error.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)
}
@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(BoardLoadError) {
_ = 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.path == ".")
if case .unreadableRoot = error.reason {} else {
Issue.record("expected an unreadable root, got \(error.reason)")
}
}
#expect(registry.openBoardCount == 0)
}
}