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
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `BoardRegistry` is app-private state about the user's boards, and its two hardest promises are
|
||||
/// negative ones: it must match a board that moved (so settings survive a rename), and it must
|
||||
/// never write a byte into a board folder (so files-first stays absolute). Both are tested here
|
||||
/// against real directories and a real storage file in temp — a fake filesystem would prove neither,
|
||||
/// since both are claims about file identity and about what is on disk.
|
||||
///
|
||||
/// The third promise is that nothing it does can take the app down: a missing file, a corrupt file,
|
||||
/// and a bookmark that no longer resolves all have expected, boring outcomes, and each has a test.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A temp folder holding the registry's JSON file, kept separate from every board folder so the
|
||||
/// quarantine test can see exactly what the registry put next to it.
|
||||
@MainActor
|
||||
private struct RegistryStorage {
|
||||
let folder: URL
|
||||
|
||||
var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) }
|
||||
|
||||
init() throws {
|
||||
folder = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("BoardRegistryTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
|
||||
func entryNames() throws -> [String] {
|
||||
try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted()
|
||||
}
|
||||
}
|
||||
|
||||
/// A small real board — the registry never reads inside one, but a folder with contents is what
|
||||
/// makes the files-first test able to notice a single stray byte.
|
||||
@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"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// Every path under `root` (the root itself included, hidden entries included) with its
|
||||
/// modification date — the shape "the registry touched nothing" takes as an assertion.
|
||||
private func treeSnapshot(of root: URL) throws -> [String: Date] {
|
||||
var snapshot: [String: Date] = [:]
|
||||
|
||||
func modified(_ url: URL) throws -> Date {
|
||||
try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate ?? .distantPast
|
||||
}
|
||||
|
||||
snapshot["."] = try modified(root)
|
||||
let walker = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: []
|
||||
)
|
||||
while let url = walker?.nextObject() as? URL {
|
||||
let relative = url.path.replacingOccurrences(of: root.path + "/", with: "")
|
||||
snapshot[relative] = try modified(url)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func ids(_ recents: [RecentBoard]) -> [UUID] {
|
||||
recents.map(\.record.id)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardRegistry")
|
||||
struct BoardRegistryTests {
|
||||
|
||||
// MARK: Matching on open
|
||||
|
||||
@Test("A second open of the same board updates its record; a rename does not fool it")
|
||||
func recordOpenMatchesByIdentity() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
|
||||
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||
let created = try #require(registry.record(id: id))
|
||||
#expect(!created.bookmark.isEmpty)
|
||||
#expect(created.displayName == "Todo Board")
|
||||
#expect(created.lastKnownPath == fixture.root.path)
|
||||
#expect(created.laneCount == nil, "counts are stamped at close, never guessed at open")
|
||||
#expect(created.pushOnCommit == false)
|
||||
#expect(created.remoteLocationWarned == false)
|
||||
#expect(registry.recents().count == 1)
|
||||
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
let again = registry.recordOpen(of: fixture.root, displayName: "Renamed In Title")
|
||||
#expect(again == id, "the same folder is the same board")
|
||||
#expect(registry.recents().count == 1, "a second open updates a record, it does not add one")
|
||||
let updated = try #require(registry.record(id: id))
|
||||
#expect(updated.displayName == "Renamed In Title")
|
||||
#expect(updated.lastOpened > created.lastOpened)
|
||||
|
||||
// The done-when criterion: a Finder rename, then an open through the new path. Only file
|
||||
// identity gets this right — every path-keyed answer produces a second record here.
|
||||
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 afterRename = registry.recordOpen(of: renamed, displayName: "Todo Board")
|
||||
#expect(afterRename == id, "a renamed board keeps its record, and so keeps its settings")
|
||||
#expect(registry.recents().count == 1)
|
||||
#expect(registry.record(id: id)?.lastKnownPath == renamed.path)
|
||||
}
|
||||
|
||||
// MARK: Counts
|
||||
|
||||
@Test("Counts are stamped at close and read back without a scan")
|
||||
func closeStampsCountsAndRecentsNeverScans() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
|
||||
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||
registry.recordClose(id: id, laneCount: 2, cardCount: 7)
|
||||
|
||||
// The board grows after the close — an agent filing cards, a colleague's pull. A welcome
|
||||
// window that scanned would notice; this one must not, because scanning is what makes
|
||||
// welcome slow on a big board and hangs it on an unavailable one.
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "2048", title: "Third"))
|
||||
|
||||
let rows = registry.recents()
|
||||
#expect(rows.count == 1)
|
||||
guard case let .available(record, at: url) = rows[0] else {
|
||||
Issue.record("expected the board to be available, got \(rows[0])")
|
||||
return
|
||||
}
|
||||
#expect(record.laneCount == 2, "the stamped count, not the tree's count")
|
||||
#expect(record.cardCount == 7)
|
||||
#expect(FileIdentity(of: url) == FileIdentity(of: fixture.root))
|
||||
}
|
||||
|
||||
// MARK: Orphaning
|
||||
|
||||
@Test("A deleted board is orphaned in recents and can be forgotten")
|
||||
func deletedBoardIsOrphaned() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let fixture = try makeBoard()
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
|
||||
let id = registry.recordOpen(of: fixture.root, displayName: "Doomed")
|
||||
registry.recordClose(id: id, laneCount: 1, cardCount: 1)
|
||||
fixture.tearDown()
|
||||
|
||||
let rows = registry.recents()
|
||||
#expect(rows.count == 1)
|
||||
guard case let .unavailable(record) = rows[0] else {
|
||||
Issue.record("expected an orphan, got \(rows[0])")
|
||||
return
|
||||
}
|
||||
#expect(record.id == id)
|
||||
#expect(record.displayName == "Doomed", "an orphan still renders — with Forget, not nothing")
|
||||
#expect(record.lastKnownPath == fixture.root.path)
|
||||
|
||||
registry.forget(id: id)
|
||||
#expect(registry.recents().isEmpty)
|
||||
#expect(registry.record(id: id) == nil)
|
||||
#expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "forgetting persists")
|
||||
}
|
||||
|
||||
@Test("A bookmark that cannot resolve at all classifies as unavailable")
|
||||
func unresolvableBookmarkIsUnavailable() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
|
||||
// Hand-written rather than produced by the registry: deleting a folder *ought* to make its
|
||||
// bookmark stop resolving, but "ought to" is the filesystem's opinion, and this rule needs a
|
||||
// case that cannot resolve by construction. It doubles as the only place the on-disk shape
|
||||
// is pinned literally — including the fractional-seconds timestamp format.
|
||||
let id = UUID()
|
||||
let garbage = Data("not a bookmark".utf8).base64EncodedString()
|
||||
let json = """
|
||||
[
|
||||
{
|
||||
"bookmark" : "\(garbage)",
|
||||
"cardCount" : 9,
|
||||
"displayName" : "Archive",
|
||||
"id" : "\(id.uuidString)",
|
||||
"laneCount" : 4,
|
||||
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
|
||||
"lastOpened" : "2026-01-01T09:00:00.000Z",
|
||||
"pushOnCommit" : true,
|
||||
"remoteLocationWarned" : true
|
||||
}
|
||||
]
|
||||
"""
|
||||
try Data(json.utf8).write(to: storage.url)
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
let rows = registry.recents()
|
||||
#expect(rows.count == 1, "an unreadable bookmark is an orphaned row, not a decoding failure")
|
||||
guard case let .unavailable(record) = rows[0] else {
|
||||
Issue.record("expected an orphan, got \(rows[0])")
|
||||
return
|
||||
}
|
||||
#expect(record.id == id)
|
||||
#expect(record.laneCount == 4, "an orphan still shows the counts it was closed with")
|
||||
#expect(record.pushOnCommit)
|
||||
#expect(record.remoteLocationWarned)
|
||||
#expect(record.lastKnownPath == "/Volumes/Archive/Boards/Archive")
|
||||
|
||||
registry.forget(id: id)
|
||||
#expect(registry.recents().isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Persistence
|
||||
|
||||
@Test("Every mutation survives a reload of the file, dates included")
|
||||
func mutationsPersistAcrossInstances() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let first = try makeBoard()
|
||||
defer { first.tearDown() }
|
||||
let second = try makeBoard()
|
||||
defer { second.tearDown() }
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
|
||||
registry.recordClose(id: firstID, laneCount: 3, cardCount: 11)
|
||||
registry.updateWindowFrame(id: firstID, frame: WindowFrame(x: 120, y: 60, width: 1440, height: 900))
|
||||
registry.setPushOnCommit(id: firstID, true)
|
||||
registry.setRemoteLocationWarned(id: firstID)
|
||||
|
||||
let reloaded = BoardRegistry(storageURL: storage.url)
|
||||
|
||||
// Equality of the whole record, not a field-by-field approximation: `lastOpened` is stamped
|
||||
// at the resolution the file records, so the reloaded value is the *same* date rather than
|
||||
// one within a second of it.
|
||||
#expect(reloaded.record(id: firstID) == registry.record(id: firstID))
|
||||
#expect(reloaded.record(id: secondID) == registry.record(id: secondID))
|
||||
|
||||
let restored = try #require(reloaded.record(id: firstID))
|
||||
#expect(restored.laneCount == 3)
|
||||
#expect(restored.cardCount == 11)
|
||||
#expect(restored.windowFrame == WindowFrame(x: 120, y: 60, width: 1440, height: 900))
|
||||
#expect(restored.pushOnCommit)
|
||||
#expect(restored.remoteLocationWarned)
|
||||
#expect(restored.lastOpened == registry.record(id: firstID)?.lastOpened)
|
||||
|
||||
#expect(ids(reloaded.recents()) == ids(registry.recents()), "order survives too")
|
||||
}
|
||||
|
||||
// MARK: Corruption
|
||||
|
||||
@Test("A corrupt file is quarantined, not deleted, and the registry carries on empty")
|
||||
func corruptFileIsQuarantined() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let garbage = Data("{ this is not the registry you are looking for".utf8)
|
||||
try garbage.write(to: storage.url)
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
#expect(registry.recents().isEmpty, "app-private convenience state never takes the app down")
|
||||
|
||||
let quarantined = try storage.entryNames().filter { $0 != storage.url.lastPathComponent }
|
||||
#expect(quarantined.count == 1)
|
||||
let quarantinedName = try #require(quarantined.first)
|
||||
#expect(quarantinedName.contains("corrupt"))
|
||||
#expect(quarantinedName.hasSuffix(".json"))
|
||||
let preserved = try Data(contentsOf: storage.folder.appendingPathComponent(quarantinedName))
|
||||
#expect(preserved == garbage, "renamed aside, never deleted — it may be the only trace of the user's boards")
|
||||
|
||||
// And the registry is usable from here: the next save writes a clean file over the hole the
|
||||
// quarantine left.
|
||||
let id = registry.recordOpen(of: fixture.root, displayName: "Fresh Start")
|
||||
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.displayName == "Fresh Start")
|
||||
}
|
||||
|
||||
// MARK: Files-first
|
||||
|
||||
@Test("Nothing the registry does touches the board folder")
|
||||
func theBoardFolderIsNeverTouched() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let before = try treeSnapshot(of: fixture.root)
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
let id = registry.recordOpen(of: fixture.root, displayName: "Untouched")
|
||||
registry.recordClose(id: id, laneCount: 1, cardCount: 1)
|
||||
registry.updateWindowFrame(id: id, frame: WindowFrame(x: 0, y: 0, width: 800, height: 600))
|
||||
registry.setPushOnCommit(id: id, true)
|
||||
registry.setRemoteLocationWarned(id: id)
|
||||
_ = registry.recents()
|
||||
_ = BoardRegistry(storageURL: storage.url).recents()
|
||||
|
||||
let after = try treeSnapshot(of: fixture.root)
|
||||
#expect(after == before, "no sidecar, no frontmatter key, no xattr — nothing app-private goes in the board")
|
||||
#expect(!storage.url.path.hasPrefix(fixture.root.path), "and the file itself lives elsewhere entirely")
|
||||
}
|
||||
|
||||
// MARK: Ordering
|
||||
|
||||
@Test("Recents is this registry sorted by last-opened, and an open bumps a board to the top")
|
||||
func recentsOrderFollowsLastOpened() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let first = try makeBoard()
|
||||
defer { first.tearDown() }
|
||||
let second = try makeBoard()
|
||||
defer { second.tearDown() }
|
||||
let third = try makeBoard()
|
||||
defer { third.tearDown() }
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
|
||||
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
let thirdID = registry.recordOpen(of: third.root, displayName: "Third")
|
||||
|
||||
#expect(ids(registry.recents()) == [thirdID, secondID, firstID])
|
||||
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
_ = registry.recordOpen(of: first.root, displayName: "First")
|
||||
#expect(ids(registry.recents()) == [firstID, thirdID, secondID])
|
||||
}
|
||||
|
||||
// MARK: Bookmarks in a sandboxed host
|
||||
|
||||
@Test("A bookmark is always produced, and resolves back to the same folder")
|
||||
func bookmarkCreationAndResolutionRoundTrip() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
// The contract the fallback exists for: *some* bookmark is always available. Which flavor is
|
||||
// the sandbox's call, and production's answer is the security-scoped one — every board URL
|
||||
// there arrives through NSOpenPanel or a drag and already carries access.
|
||||
let bookmark = try #require(BoardRegistry.makeBookmark(for: fixture.root))
|
||||
print("BoardRegistryTests: bookmark flavor in this test host = \(bookmark.isSecurityScoped ? "security-scoped" : "plain")")
|
||||
|
||||
let resolution = try #require(BoardRegistry.resolve(bookmark.data))
|
||||
let target = try #require(FileIdentity(of: fixture.root))
|
||||
let resolved = BoardRegistry.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) }
|
||||
#expect(resolved == target, "a bookmark of either flavor names the file, not the path")
|
||||
|
||||
// Resolution's own fallback, forced: whichever flavor this host hands out, `resolve` must
|
||||
// also cope with a plain bookmark, because a registry file written by a non-sandboxed debug
|
||||
// build (or on a host where the security-scoped attempt failed) is full of them. Nothing
|
||||
// else in this suite reaches that branch when the sandbox is cooperating.
|
||||
let plain = try #require(try? fixture.root.bookmarkData(options: []))
|
||||
let plainResolution = try #require(BoardRegistry.resolve(plain))
|
||||
#expect(FileIdentity(of: plainResolution.url) == target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user