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) } }