import Foundation import Testing @testable import Kanban /// **App-side state in the shared App Group container** (12-editions.md ▸ Distribution and ▸ Both /// editions installed, ruled 2026-07-29; 02-architecture.md § Per-board app state). /// /// One container, one registry, one clipboard staging store, one defaults suite — and exactly two /// fields that cannot be shared, both keyed by bundle id: the security-scoped **grant slot** (a /// bookmark never crosses a sandbox, group or not) and the **open-now flag** (an edition restores only /// the boards it had open). Everything here is about those two seams and what they buy. /// /// **Nothing in this file touches the real group container.** Every registry gets a temp storage file /// and every edition is a *string*, injected — which is the only way "base's record, read by Pro" is /// expressible inside one process at all. // MARK: - Fixtures /// A temp registry file, `BoardRegistryTests`' own shape — restated rather than shared because that /// file's copy is `private` to it and a test fixture is not worth a seam. @MainActor private struct GroupStorage { let folder: URL var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) } init() throws { folder = FileManager.default.temporaryDirectory .appendingPathComponent("AppGroupStateTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) } func tearDown() { try? FileManager.default.removeItem(at: folder) } } @MainActor private func makeGroupBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) return fixture } private let base = AppGroup.baseEditionID private let pro = AppGroup.proEditionID // MARK: - The container @MainActor @Suite("App Group container") struct AppGroupContainerTests { @Test("Every app-side store resolves under one state directory, provisioned or not") func stateDirectoryIsOneHomeAndAlwaysUsable() async throws { // Diagnostic, not an assertion: whether this test host has the capability is a fact about // provisioning, and a suite that *required* it could not run on a machine where the group is // not yet registered on the team. Printed for the same reason `BoardRegistryTests` prints its // bookmark flavor — the answer matters and cannot be asserted. let container = AppGroup.containerURL print("AppGroupStateTests: group container in this host = \(container?.path ?? "nil (unprovisioned — per-edition fallback)")") let production = AppGroup.productionStateDirectory if let container { #expect(production.path.hasPrefix(container.path), "the shared home is inside the group container") // No bundle-id **subfolder** — that subfolder is what kept the editions apart. Compared by // path component, not by substring: the group id itself contains base's bundle id, which is // the family name showing through and not a per-edition directory. #expect( !production.pathComponents.contains(AppGroup.editionID), "the shared home must not be nested under a per-edition folder" ) } else { #expect( production == AppGroup.perEditionSupportDirectory, "the fallback is the pre-2.0 per-edition home, unshared but working" ) } // The registry and the clipboard's staging store share one home, which is the whole point: // "the clipboard staging store homes in the group container **beside the registry**". let state = AppGroup.stateDirectory #expect(BoardRegistry.defaultStorageURL.deletingLastPathComponent() == state) #expect(ClipboardStore.defaultStagingRoot.deletingLastPathComponent() == state) // And the template store, re-homed here on the same day (09-templates.md ▸ Storage): "templates // cross editions". #expect(TemplateEngine.userStore.deletingLastPathComponent() == state) // And it is a directory the app can actually create, which is the only property that has to // hold on both sides of the provisioning question. try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) #expect(FileManager.default.fileExists(atPath: state.path)) } @Test("A unit-test host never resolves to the real shared container") func aTestHostIsRedirected() { // This suite is running, so this *is* a test host — and the point of the check is that the two // defaults every launch reaches for (the registry file, the staging root) cannot land in a // container shared with the sibling edition and with the developer's own running copy. There is // no injection point in `KanbanApp.init()` to fix that from the outside. #expect(AppGroup.isUnitTestHost) #expect(AppGroup.stateDirectory == AppGroup.unitTestStateDirectory) #expect(AppGroup.stateDirectory != AppGroup.productionStateDirectory) #expect(AppGroup.defaults != UserDefaults(suiteName: AppGroup.identifier)) } @Test("The group id is the family's, and each edition names itself") func editionIdentityIsReadFromTheBundle() { #expect(AppGroup.identifier == "group.dev.rzen.indie.Kanban") #expect(AppGroup.perEditionSupportDirectory.lastPathComponent == AppGroup.editionID) #expect(AppGroup.editionDisplayName(base) == "Lanework") #expect(AppGroup.editionDisplayName(pro) == "Lanework Pro") // A bundle id this build cannot name gets no name invented for it — the awareness line's whole // posture is that it never says anything it does not know. #expect(AppGroup.editionDisplayName("dev.rzen.indie.KanbanTeams") == nil) } } // MARK: - Grant slots @MainActor @Suite("Per-edition grant slots") struct GrantSlotTests { @Test("A grant is minted into this edition's slot and round-trips through the file") func grantSlotsRoundTrip() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let fixture = try makeGroupBoard() defer { fixture.tearDown() } let asBase = BoardRegistry(storageURL: storage.url, editionID: base) let id = asBase.recordOpen(of: fixture.root, displayName: "Work") let reloaded = try #require(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id)) #expect(reloaded.grant(forEdition: base) != nil) #expect(reloaded.grant(forEdition: pro) == nil, "an edition mints its own slot and nobody else's") } @Test("A record the other edition minted resolves unavailable-until-reopened, anchored at its path") func otherEditionsGrantNeedsReopening() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let fixture = try makeGroupBoard() defer { fixture.tearDown() } BoardRegistry(storageURL: storage.url, editionID: base) .recordOpen(of: fixture.root, displayName: "Work") // Pro, over the very same shared file. The board is *there* — nothing was deleted — but the // only bookmark on the record was minted in another sandbox, so this edition cannot resolve it. let asPro = BoardRegistry(storageURL: storage.url, editionID: pro) let rows = asPro.recents() #expect(rows.count == 1, "the record is shared, not duplicated") guard case let .needsReopen(record, anchor) = rows[0] else { Issue.record("expected needsReopen, got \(rows[0])") return } #expect(record.displayName == "Work", "every other field is common — the list transfers, only access re-grants") #expect(anchor.standardizedFileURL.path == URL(fileURLWithPath: fixture.root.path).standardizedFileURL.path) #expect(rows[0].url == nil, "there is nothing to open until the grant exists") #expect(rows[0].regrantAnchor != nil) } @Test("A genuine orphan is not a re-grant candidate") func aRecordNobodyCanReachStaysUnavailable() async throws { let storage = try GroupStorage() defer { storage.tearDown() } // No grants at all — the born-orphaned record (`recordOpen`'s degenerate case). The two states // are told apart by whether *somebody* holds a grant, so this one must stay `unavailable`: an // open panel cannot help a board nothing knows the whereabouts of. let id = UUID() let json = """ [ { "displayName" : "Ghost", "grants" : {}, "id" : "\(id.uuidString)", "lastKnownPath" : "/Volumes/Gone/Ghost.kanban", "lastOpened" : "2026-01-01T09:00:00.000Z", "openNow" : {}, "pushOnCommit" : false, "remoteLocationWarned" : false } ] """ try Data(json.utf8).write(to: storage.url) let rows = BoardRegistry(storageURL: storage.url, editionID: pro).recents() #expect(rows.count == 1) guard case .unavailable = rows[0] else { Issue.record("expected unavailable, got \(rows[0])") return } } @Test("Re-granting mints this edition's slot onto the shared record and leaves the other's alone") func regrantingDoesNotForkTheRecord() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let fixture = try makeGroupBoard() defer { fixture.tearDown() } let asBase = BoardRegistry(storageURL: storage.url, editionID: base) let id = asBase.recordOpen(of: fixture.root, displayName: "Work") asBase.updateWindowFrame(id: id, frame: WindowFrame(x: 10, y: 20, width: 300, height: 400)) let baseGrant = try #require(asBase.record(id: id)?.grant(forEdition: base)) // The re-grant: the panel handed Pro the same folder, and the open goes through the ordinary // `recordOpen` door. Nothing about it is a special API — that is the design. let asPro = BoardRegistry(storageURL: storage.url, editionID: pro) let proID = asPro.recordOpen(of: fixture.root) #expect(proID == id, "the shared record is matched, never forked") let shared = try #require(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id)) #expect(shared.grant(forEdition: pro) != nil, "Pro now holds its own grant") #expect(shared.grant(forEdition: base) == baseGrant, "base's grant is untouched — it is still that app's key") #expect(shared.windowFrame?.width == 300, "and every common field survived the second edition's open") #expect(BoardRegistry(storageURL: storage.url, editionID: pro).recents().count == 1) // Both editions can now reach it. guard case .available = BoardRegistry(storageURL: storage.url, editionID: pro).recents()[0] else { Issue.record("expected Pro to see the board as available after re-granting") return } } @Test("A registry file written before the grant slots existed adopts its one bookmark as this edition's") func legacySingleGrantRecordsAreReadable() async throws { let storage = try GroupStorage() defer { storage.tearDown() } // Byte-for-byte the pre-App-Group shape: one `bookmark`, one `isOpenNow`, no keyed slots at // all. Only base existed then, so adopting the bookmark as the *running* edition's slot is the // coherent reading — and the reachable case is the unprovisioned fallback, where // `AppGroup.stateDirectory` still points at the old per-edition home. let id = UUID() let garbage = Data("not a bookmark".utf8).base64EncodedString() let json = """ [ { "bookmark" : "\(garbage)", "cardCount" : 9, "displayName" : "Archive", "id" : "\(id.uuidString)", "isOpenNow" : true, "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, editionID: base) let record = try #require(registry.record(id: id)) #expect(record.grant(forEdition: base) != nil, "the one bookmark became this edition's grant") #expect(record.isOpen(inEdition: base), "and the one flag became this edition's flag") #expect(record.laneCount == 4, "every other field survived — nothing was quarantined") // Its bookmark is unresolvable garbage, so it classifies as the orphan it is — never as a // re-grant candidate, which would be this edition offering to grant a board it already holds // the (dead) key to. guard case .unavailable = registry.recents()[0] else { Issue.record("expected unavailable, got \(registry.recents()[0])") return } // Tolerate-and-upgrade **on first write**: reading changed nothing on disk, and the next // ordinary save emits the keyed shape and drops the legacy keys for good. #expect(try String(data: Data(contentsOf: storage.url), encoding: .utf8)?.contains("\"bookmark\"") == true) registry.setRemoteLocationWarned(id: id) let upgraded = try #require(String(data: Data(contentsOf: storage.url), encoding: .utf8)) #expect(!upgraded.contains("\"bookmark\"")) #expect(!upgraded.contains("\"isOpenNow\"")) #expect(upgraded.contains("\"grants\"")) #expect(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id)?.grant(forEdition: base) != nil) } } // MARK: - Open-now flags @MainActor @Suite("Per-edition open-now flags") struct OpenNowPerEditionTests { @Test("An edition restores only the boards it had open") func restorationIsFilteredByEdition() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let mine = try makeGroupBoard() defer { mine.tearDown() } let theirs = try makeGroupBoard() defer { theirs.tearDown() } let asBase = BoardRegistry(storageURL: storage.url, editionID: base) let mineID = asBase.recordOpen(of: mine.root, displayName: "Mine") let theirsID = asBase.recordOpen(of: theirs.root, displayName: "Theirs") asBase.setOpenNow(id: mineID) // Pro flags the other board — same shared records, its own slot. let asPro = BoardRegistry(storageURL: storage.url, editionID: pro) asPro.recordOpen(of: theirs.root) asPro.setOpenNow(id: theirsID) let baseRestores = BoardRegistry(storageURL: storage.url, editionID: base).restorables() let proRestores = BoardRegistry(storageURL: storage.url, editionID: pro).restorables() #expect(baseRestores.map(\.record.id) == [mineID]) #expect(proRestores.map(\.record.id) == [theirsID]) // And a user close in one edition leaves the other's flag standing. BoardRegistry(storageURL: storage.url, editionID: base).clearOpenNow(id: mineID) let after = try #require(BoardRegistry(storageURL: storage.url, editionID: pro).record(id: theirsID)) #expect(after.isOpen(inEdition: pro)) #expect(!after.isOpen(inEdition: base)) } @Test("The other edition's flags are reported raw, never this edition's own") func flaggedEditionsExcludeSelf() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let fixture = try makeGroupBoard() defer { fixture.tearDown() } let asBase = BoardRegistry(storageURL: storage.url, editionID: base) let id = asBase.recordOpen(of: fixture.root, displayName: "Shared") asBase.setOpenNow(id: id) let asPro = BoardRegistry(storageURL: storage.url, editionID: pro) asPro.setOpenNow(id: id) #expect(asPro.otherEditionsFlaggedOpen(id: id) == [base]) #expect(BoardRegistry(storageURL: storage.url, editionID: base).otherEditionsFlaggedOpen(id: id) == [pro]) #expect(asPro.otherEditionsFlaggedOpen(id: UUID()).isEmpty, "an unknown id says nothing") } } // MARK: - Two editions, one file @MainActor @Suite("One registry file, two live editions") struct SharedRegistryFileTests { @Test("Neither edition's write erases the other's") func writesFromBothEditionsSurvive() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let mine = try makeGroupBoard() defer { mine.tearDown() } let theirs = try makeGroupBoard() defer { theirs.tearDown() } // Both **live at once**, which is the steady state 12-editions.md blesses ("a supported steady // state, not a transition to hurry past") — and the case a whole-file writer over a cached array // gets wrong by default: base's next window-frame save would rewrite the file from an array that // never heard of Pro's board. let asBase = BoardRegistry(storageURL: storage.url, editionID: base) let asPro = BoardRegistry(storageURL: storage.url, editionID: pro) let mineID = asBase.recordOpen(of: mine.root, displayName: "Base's") let theirsID = asPro.recordOpen(of: theirs.root, displayName: "Pro's") // An ordinary convenience write from the edition that has not looked at the file since. asBase.updateWindowFrame(id: mineID, frame: WindowFrame(x: 1, y: 2, width: 3, height: 4)) let onDisk = BoardRegistry(storageURL: storage.url, editionID: base) #expect(Set(onDisk.recents().map(\.record.id)) == [mineID, theirsID], "both records are in the file") #expect(onDisk.record(id: mineID)?.windowFrame?.width == 3) #expect(onDisk.record(id: theirsID)?.grant(forEdition: pro) != nil, "and Pro's grant was not rewritten away") } @Test("A flag the other edition sets is visible without relaunching") func theOtherEditionsFlagIsPickedUpLive() async throws { let storage = try GroupStorage() defer { storage.tearDown() } let fixture = try makeGroupBoard() defer { fixture.tearDown() } let asBase = BoardRegistry(storageURL: storage.url, editionID: base) let id = asBase.recordOpen(of: fixture.root, displayName: "Shared") // Pro opens the same board afterwards. Base is still running and has not re-read anything — and // the popover's awareness line is worthless if it can only see flags that predate this launch. let asPro = BoardRegistry(storageURL: storage.url, editionID: pro) asPro.recordOpen(of: fixture.root) asPro.setOpenNow(id: id) #expect(asBase.otherEditionsFlaggedOpen(id: id) == [pro]) #expect( BoardEditionPresence.note( otherEditions: asBase.otherEditionsFlaggedOpen(id: id), isRunning: { $0 == pro } ) == "Also open in Lanework Pro" ) // And it goes away again when Pro closes the board, still without a relaunch. asPro.clearOpenNow(id: id) #expect(asBase.otherEditionsFlaggedOpen(id: id).isEmpty) } } // MARK: - The awareness line @Suite("The board popover's awareness line") struct BoardEditionPresenceTests { @Test("A live other edition earns the line") func aLiveEditionIsNamed() { #expect( BoardEditionPresence.note(otherEditions: [pro], isRunning: { $0 == pro }) == "Also open in Lanework Pro" ) #expect( BoardEditionPresence.note(otherEditions: [base], isRunning: { _ in true }) == "Also open in Lanework" ) } @Test("A flag with no live process shows nothing — crash residue never lies") func staleFlagsSayNothing() { // The flag is deliberately left standing by a crash (02-architecture.md § Launch and window // lifecycle — that residue is what makes crash recovery free), so the flag alone would claim // an app that died last week is looking at this board right now. #expect(BoardEditionPresence.note(otherEditions: [pro], isRunning: { _ in false }) == nil) #expect(BoardEditionPresence.note(otherEditions: [], isRunning: { _ in true }) == nil) } @Test("An edition this build cannot name shows nothing") func unknownEditionsSayNothing() { #expect( BoardEditionPresence.note(otherEditions: ["dev.rzen.indie.KanbanTeams"], isRunning: { _ in true }) == nil ) // …and a nameable live one behind it still wins, so one unknown neighbour does not silence the // line altogether. #expect( BoardEditionPresence.note( otherEditions: ["dev.rzen.indie.KanbanTeams", pro], isRunning: { _ in true } ) == "Also open in Lanework Pro" ) } } // MARK: - The welcome row @MainActor @Suite("The cross-edition welcome row") struct CrossEditionWelcomeRowTests { @Test("A re-grant row opens on one click, reveals on none, and says what the click will do") func theRowIsOpenableButNotRevealable() throws { let record = BoardRecord( grants: [pro: Data("pro's key".utf8)], displayName: "Roadmap", lastKnownPath: "/Boards/Roadmap.kanban", lastOpened: Date() ) let recent = RecentBoard.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath)) let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first) #expect(row.needsReopen) #expect(row.canOpen, "the board is there — one click plus Grant is the whole remedy") #expect(!row.canReveal, "revealing a folder is a read this app has not been granted either") #expect(row.caption == .needsReopen) #expect(row.regrantAnchor?.path == "/Boards/Roadmap.kanban") #expect(row.location == "/Boards") } @Test("Fail-fast's specifics still outrank the re-grant caption") func aFailureStillWinsTheCaption() throws { let record = BoardRecord( grants: [pro: Data("pro's key".utf8)], displayName: "Roadmap", lastKnownPath: "/Boards/Roadmap.kanban", lastOpened: Date() ) let recent = RecentBoard.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath)) let failure = LaunchFailure(path: "/Boards/Roadmap.kanban", message: "index.md is unparseable.") let row = try #require(WelcomeRow.derive(recents: [recent], failures: [failure]).rows.first) // The precedence 02 § Launch and window lifecycle fixes: a failure is what the row is *for* at // that moment, and it is still true that this board needs granting — but the message the user // has to read first is the one about the file. #expect(row.caption == .failed("index.md is unparseable.")) #expect(row.canOpen, "and the retry is still one click") } }