Files
lanework/KanbanTests/AppGroupStateTests.swift
T
rzen 566deab506 Home app-side state in the shared App Group container
Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):

- AppGroup namespace: container resolution with per-edition fallback
  when unprovisioned, shared UserDefaults suite, edition identity, and
  a unit-test-host redirect (the test host IS the app — its launch
  sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
  openNow keyed by bundle id; hand-written Codable keeps legacy keys
  decoding (adopted in memory as the running edition's slots, upgraded
  on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
  first click runs an open panel pre-anchored at the recorded path,
  prompt "Grant"; recordOpen mints this edition's slot onto the
  matched shared record (path fallback only after identity fails and
  only against records holding no grant of ours, so re-granting never
  forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
  registry when the sibling edition wrote it, so one edition's save
  never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
  popover gains BoardEditionPresence ("Also open in Lanework Pro"),
  pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
  claims doomed trees by atomic rename into .sweeping/ then deletes,
  so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
  re-ruling; scalars (quick-style recents, window size) move to the
  shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
  family group). No pathfinder 1.x migrator: 1.x predates the
  registry; state starts fresh in the group container.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 20:18:15 -04:00

494 lines
23 KiB
Swift

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