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.grant(forEdition: registry.editionID) != nil) #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) } @Test("Recording an open with no displayName gives a brand-new record the folder name") func recordOpenWithNoDisplayNameUsesTheFolderName() async throws { let storage = try RegistryStorage() defer { storage.tearDown() } let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardRegistry(storageURL: storage.url) // The "record before load" call (02-architecture.md § Per-board app state): nothing // authoritative is known yet, so the provisional name is the folder's own — "the folder // name is the Finder document name the user just picked." let id = registry.recordOpen(of: fixture.root) let record = try #require(registry.record(id: id)) #expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent) #expect(record.grant(forEdition: registry.editionID) != nil, "the bookmark still mints on the before-load call") #expect(record.icon == nil) #expect(record.iconColor == nil) } @Test("Recording an open with no displayName never regresses an existing record's cached name") func recordOpenWithNoDisplayNamePreservesAnExistingRecord() async throws { let storage = try RegistryStorage() defer { storage.tearDown() } let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardRegistry(storageURL: storage.url) // A first, successful-looking open: real values, as a caller with a loaded snapshot passes. let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board", icon: "star", iconColor: "fern") let firstOpened = try #require(registry.record(id: id)).lastOpened try await Task.sleep(for: .milliseconds(5)) // A later open attempt whose load has not run yet — or never gets the chance to, because it // fails fail-fast. Either way, this call alone must not know that, so the cached title and // style survive untouched; only the bits every open refreshes regardless move. let again = registry.recordOpen(of: fixture.root) #expect(again == id) let record = try #require(registry.record(id: id)) #expect(record.displayName == "Todo Board", "the last successful open's title is still the best information this row has") #expect(record.icon == "star") #expect(record.iconColor == "fern") #expect(record.lastOpened > firstOpened, "the bookmark and lastOpened still refresh on every open attempt") #expect(record.grant(forEdition: registry.editionID) != nil) } // 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, displayName: "Todo Board", 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: Title, icon, and iconColor @Test("Icon and iconColor are stamped at open and re-stamped at close, alongside the title") func openAndCloseStampIconAndIconColor() 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", icon: "star", iconColor: "fern") let opened = try #require(registry.record(id: id)) #expect(opened.icon == "star") #expect(opened.iconColor == "fern") // The board is restyled while open — an in-app change or a foreign edit, it makes no // difference here since `recordClose` re-stamps from whatever the store last held. registry.recordClose( id: id, displayName: "Todo Board", laneCount: 2, cardCount: 7, icon: "heart", iconColor: "carnation" ) let closed = try #require(registry.record(id: id)) #expect(closed.icon == "heart") #expect(closed.iconColor == "carnation") // A board can also lose its override entirely — `nil` closes back over a previously // stamped value rather than being mistaken for "leave it alone". registry.recordClose(id: id, displayName: "Todo Board", laneCount: 2, cardCount: 7) #expect(registry.record(id: id)?.icon == nil) #expect(registry.record(id: id)?.iconColor == nil) } @Test("The live write-through updates title, icon, and iconColor when they differ from the cached record") func syncDisplayStateWritesThroughOnlyWhatChanged() 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") #expect(registry.record(id: id)?.icon == nil, "no override yet") // An in-app rename/restyle's reload lands new values — the seam `BoardStore`'s // `displayStateDelegate` calls into. registry.syncDisplayState(id: id, title: "Renamed", icon: "star.fill", iconColor: "deep-sky-blue") let afterFirstSync = try #require(registry.record(id: id)) #expect(afterFirstSync.displayName == "Renamed") #expect(afterFirstSync.icon == "star.fill") #expect(afterFirstSync.iconColor == "deep-sky-blue") // A later reload whose display state is unchanged from the cached record leaves it exactly // as it was — the no-op-skip rule every other setter in this file already keeps. registry.syncDisplayState(id: id, title: "Renamed", icon: "star.fill", iconColor: "deep-sky-blue") #expect(registry.record(id: id) == afterFirstSync) // The values persist like any other mutation here. let reloaded = BoardRegistry(storageURL: storage.url) #expect(reloaded.record(id: id)?.displayName == "Renamed") #expect(reloaded.record(id: id)?.icon == "star.fill") #expect(reloaded.record(id: id)?.iconColor == "deep-sky-blue") } @Test("The live write-through on an id with no record is a no-op, not a crash") func syncDisplayStateOnUnknownIDIsANoOp() async throws { let storage = try RegistryStorage() defer { storage.tearDown() } let registry = BoardRegistry(storageURL: storage.url) registry.syncDisplayState(id: UUID(), title: "Ghost", icon: "star", iconColor: "fern") #expect(registry.recents().isEmpty, "a window that outlived its record must not resurrect one") } @Test("A registry file written before the icon/iconColor keys existed still decodes, with both nil") func oldRegistryFilesDecodeWithoutIconKeys() async throws { let storage = try RegistryStorage() defer { storage.tearDown() } // Byte-for-byte the shape this file had before icon/iconColor existed — the same evolution // rule `oldRegistryFilesDecodeWithoutTheOpenNowKey` pins for `isOpenNow`: a key added here // must be optional, or every existing user's recents empties 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)?.icon == nil, "a missing key reads as 'no override'") #expect(registry.record(id: id)?.iconColor == nil) // And both keys write through from here on. registry.syncDisplayState(id: id, title: "Archive", icon: "archivebox", iconColor: "aluminum") let updated = BoardRegistry(storageURL: storage.url).record(id: id) #expect(updated?.icon == "archivebox") #expect(updated?.iconColor == "aluminum") } @Test("A registry file with icon and iconColor present decodes them") func registryFileWithIconKeysDecodes() async throws { let storage = try RegistryStorage() defer { storage.tearDown() } let id = UUID() let garbage = Data("not a bookmark".utf8).base64EncodedString() let json = """ [ { "bookmark" : "\(garbage)", "cardCount" : 9, "displayName" : "Archive", "icon" : "archivebox", "iconColor" : "aluminum", "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.record(id: id)?.icon == "archivebox") #expect(registry.record(id: id)?.iconColor == "aluminum") } // 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, displayName: "Doomed", 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: Clear Menu @Test("Clear Menu empties the registry, and persists") func forgetAllEmptiesTheRegistry() 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) registry.recordOpen(of: first.root, displayName: "First") registry.recordOpen(of: second.root, displayName: "Second") #expect(registry.recents().count == 2) registry.forgetAll() #expect(registry.recents().isEmpty) #expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "Clear Menu persists") } /// The equivalence 11-command-nexus.md's Clear Menu rests on: Finder clears a *menu*, and here /// the registry **is** the menu — so clearing it can only mean forgetting every record, and must /// leave the file in precisely the state that forgetting them one at a time would. @Test("Clear Menu is Forget applied to every row — same result, same file") func forgetAllMatchesForgettingEachRow() async throws { let wholesale = try RegistryStorage() defer { wholesale.tearDown() } let piecemeal = try RegistryStorage() defer { piecemeal.tearDown() } let first = try makeBoard() defer { first.tearDown() } let second = try makeBoard() defer { second.tearDown() } func populate(_ storage: RegistryStorage) -> BoardRegistry { let registry = BoardRegistry(storageURL: storage.url) registry.recordOpen(of: first.root, displayName: "First") registry.recordOpen(of: second.root, displayName: "Second") return registry } let bulk = populate(wholesale) bulk.forgetAll() let oneByOne = populate(piecemeal) for row in oneByOne.recents() { oneByOne.forget(id: row.record.id) } #expect(bulk.recents().isEmpty) #expect(oneByOne.recents().isEmpty) #expect( try Data(contentsOf: wholesale.url) == Data(contentsOf: piecemeal.url), "the two paths leave byte-identical files — there is no state Clear Menu skips" ) } @Test("Clear Menu on an empty registry writes nothing") func forgetAllOnEmptyRegistryIsANoOp() async throws { let storage = try RegistryStorage() defer { storage.tearDown() } let registry = BoardRegistry(storageURL: storage.url) registry.forgetAll() #expect(try storage.entryNames().isEmpty, "an empty registry has nothing to clear and no file to write") } // 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, displayName: "First", 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, displayName: "Untouched", 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)?.isOpen(inEdition: registry.editionID) == false, "recording an open is not opening a window") #expect(registry.restorables().isEmpty) registry.setOpenNow(id: id) #expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == 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)?.isOpen(inEdition: registry.editionID) == 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, displayName: "Work", laneCount: 2, cardCount: 5) let afterRelaunch = BoardRegistry(storageURL: storage.url) #expect(afterRelaunch.record(id: id)?.isOpen(inEdition: registry.editionID) == 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)?.isOpen(inEdition: registry.editionID) == false, "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)?.isOpen(inEdition: registry.editionID) == 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) } }