Files
lanework/KanbanTests/BoardRegistryTests.swift
T
rzen fccdf56cf4 Stand up the window architecture — welcome, board, card
Four scenes (welcome, restore bootstrap, board group, card group) with
system restoration disabled in favor of the registry's open-now flags:
set when a window actually opens, cleared only on user close, so quit —
and crash — leave exactly the restoration set behind. AppModel joins
windows to sessions (shared store, registry record, card refs, held
security scope); CloseFlushCoordinator pins 02's strict close order as
a seam-injected machine (card sessions end, windows drain, store
flushes, record stamps, teardown) with named slots where m6/m7 flushes
land. HostedWindowController proxies — never replaces — SwiftUI's
window delegate to intercept windowShouldClose for the flush, report
frames, and place saved frames onto live screens. Card windows are
(board path, case-folded card id) values: reopen focuses, and a
snapshot-pure fate function dismisses on delete, tombstone, tombstoned
lane, or cross-board move.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 07:47:11 -04:00

488 lines
22 KiB
Swift

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: The open-now marker
@Test("Opening flags a board, a user close unflags it, and quit deliberately leaves it standing")
func openNowTracksWindowsAndSurvivesQuit() 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: "Work")
#expect(registry.record(id: id)?.isOpenNow == nil, "recording an open is not opening a window")
#expect(registry.restorables().isEmpty)
registry.setOpenNow(id: id)
#expect(registry.record(id: id)?.isOpenNow == true)
#expect(ids(registry.restorables()) == [id])
// A user close. The flag goes, and with it the board's place in the next launch.
registry.clearOpenNow(id: id)
#expect(registry.record(id: id)?.isOpenNow == false)
#expect(registry.restorables().isEmpty)
// A quit. The teardown stamps counts and does *not* clear the flag — that omission is the
// whole restoration mechanism, so it is asserted rather than assumed, and asserted across a
// reload of the file because a relaunch is what consumes it.
registry.setOpenNow(id: id)
registry.recordClose(id: id, laneCount: 2, cardCount: 5)
let afterRelaunch = BoardRegistry(storageURL: storage.url)
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
#expect(ids(afterRelaunch.restorables()) == [id])
#expect(afterRelaunch.record(id: id)?.laneCount == 2)
}
@Test("Restorables are the flagged records only, oldest first")
func restorablesReopenInLastOpenedOrder() 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")
registry.setOpenNow(id: firstID)
registry.setOpenNow(id: thirdID)
// Ascending, the exact inverse of `recents()` — these are reopened in order, so the board
// opened last at quit opens last again and ends up frontmost.
#expect(ids(registry.restorables()) == [firstID, thirdID])
#expect(ids(registry.recents()) == [thirdID, secondID, firstID], "recents is unchanged, and still newest first")
#expect(!ids(registry.restorables()).contains(secondID), "a board that was not open does not restore")
// A flagged board whose folder has gone still comes back — classified, not dropped. The
// launch flow renders it as a failed restoration rather than pretending it was never open.
third.tearDown()
let rows = registry.restorables()
#expect(ids(rows) == [firstID, thirdID])
guard case .unavailable = rows[1] else {
Issue.record("expected the deleted board to classify unavailable, got \(rows[1])")
return
}
}
@Test("A registry file written before the open-now key existed still decodes")
func oldRegistryFilesDecodeWithoutTheOpenNowKey() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
// Byte-for-byte the shape this file had one milestone ago: no `isOpenNow` anywhere. The
// struct's evolution rule says a new key must be optional precisely so this file survives —
// a required key would have failed to decode, quarantined the file, and emptied the user's
// recents on upgrade.
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)
#expect(registry.recents().count == 1, "the file decoded; nothing was quarantined")
#expect(registry.record(id: id)?.isOpenNow == nil, "a missing key reads as 'not open'")
#expect(registry.restorables().isEmpty)
#expect(registry.record(id: id)?.laneCount == 4, "and every other field survived")
// And the key writes through from here on.
registry.setOpenNow(id: id)
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true)
}
// 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)
}
}