Remove the App Group wholesale — one sandbox, one bookmark, one flag

Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.

BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.

The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.

1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 17:46:32 -04:00
parent 092300c7d2
commit 2c6b8fe63a
27 changed files with 385 additions and 1461 deletions
-499
View File
@@ -1,499 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// ** Retired by the one-app collapse one-app collapse phase 2.** 12-editions.md App-side
/// state (re-ruled 2026-07-30) removes the App Group wholesale: one app, one sandbox, one grant, one
/// open-now flag. This whole file is the two-app arrangement's proof, and it retires with the code
/// it pins it is kept green in the meantime rather than deleted ahead of the machinery, because
/// the machinery is what still ships.
///
/// **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")
}
}
+5 -6
View File
@@ -33,10 +33,9 @@ private func makeMixedBoard() throws -> WriterFixture {
return fixture
}
/// An `AppModel` whose app-side state lives in temp rather than in the shared App Group container
/// both halves of it: the registry file, and the clipboard's staging store, whose launch sweep would
/// otherwise collect the developer's own staged copy (and the sibling edition's, since there is one
/// store now).
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home both halves of it: the registry file, and the clipboard's staging store, whose
/// launch sweep would otherwise collect the developer's own staged copy.
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
@@ -219,13 +218,13 @@ struct AppModelTests {
let record = try #require(model.boardRegistry.record(id: recordID))
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
#expect(record.cardCount == 2)
#expect(!record.isOpen(inEdition: model.boardRegistry.editionID))
#expect(!record.isOpenNow)
#expect(model.boardRegistry.restorables().isEmpty)
// Twice is a no-op, which is what lets the window's close interception and its disappear both
// call this without the sequence running twice.
await model.closeBoard(ref: ref, cause: .userClose)
#expect(model.boardRegistry.record(id: recordID)?.isOpen(inEdition: model.boardRegistry.editionID) == false)
#expect(model.boardRegistry.record(id: recordID)?.isOpenNow == false)
}
@Test("Quit closes every board and leaves them all flagged for the next launch")
+54
View File
@@ -0,0 +1,54 @@
import Foundation
import Testing
@testable import Kanban
/// **Where app-side state lives** (`AppStateHome`; 02-architecture.md § Per-board app state,
/// "App-wide state has the same home"; 12-editions.md App-side state, re-ruled 2026-07-30 one
/// app, one sandbox, one home).
///
/// Two claims, and they are the only two this type makes: the three file stores are neighbours under
/// one directory the app can actually create, and a **unit-test host never resolves to the real
/// one**. The second is not a nicety the test host *is* the app, so `KanbanApp.init()` runs for
/// real on every test launch, and a home that pointed at the developer's own state would have the
/// host's launch sweep collecting real staged clipboard trees and its recents refresh rewriting real
/// records. There is no injection point in `App.init` to fix that from outside.
@MainActor
@Suite("App state home")
struct AppStateHomeTests {
@Test("Every app-side store resolves under one state directory, and it is creatable")
func stateDirectoryIsOneHomeAndAlwaysUsable() throws {
let home = AppStateHome.directory
// The registry, the clipboard's staging store and the template store are one another's
// neighbours by design, and each names the home rather than spelling a path so this is the
// assertion that keeps them moving together the day the home moves.
#expect(BoardRegistry.defaultStorageURL.deletingLastPathComponent() == home)
#expect(ClipboardStore.defaultStagingRoot.deletingLastPathComponent() == home)
#expect(TemplateEngine.userStore.deletingLastPathComponent() == home)
// And it is a directory the app can actually create, which is the one property that has to
// hold whichever side of the test-host redirect this is running on.
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
#expect(FileManager.default.fileExists(atPath: home.path))
}
@Test("A unit-test host never resolves to the real Application Support home")
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
// the home the developer's own running copy uses.
#expect(AppStateHome.isUnitTestHost)
#expect(AppStateHome.directory == AppStateHome.unitTestDirectory)
#expect(AppStateHome.directory != AppStateHome.productionDirectory)
}
@Test("The production home is Application Support itself, with no bundle-id subfolder appended")
func productionHomeHasNoBundleIDSubfolder() {
// The sandbox already scopes `Application Support` to this app that container path is the
// one place the bundle id belongs so a subfolder appended *inside* it would name the app
// twice. The last component is therefore the check: what this type adds is nothing.
#expect(AppStateHome.productionDirectory.lastPathComponent == "Application Support")
}
}
+45 -9
View File
@@ -91,7 +91,7 @@ struct BoardRegistryTests {
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.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")
@@ -135,7 +135,7 @@ struct BoardRegistryTests {
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.bookmark.isEmpty, "the bookmark still mints on the before-load call")
#expect(record.icon == nil)
#expect(record.iconColor == nil)
}
@@ -163,7 +163,7 @@ struct BoardRegistryTests {
#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)
#expect(!record.bookmark.isEmpty)
}
// MARK: Counts
@@ -314,6 +314,42 @@ struct BoardRegistryTests {
#expect(updated?.iconColor == "aluminum")
}
@Test("A record with no bookmark key at all is a born orphan, not a quarantine")
func aRecordWithNoBookmarkKeyDecodesAsAnOrphan() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
// `bookmark` carries a decoding default like every key past the four founding ones, and this
// is why: a record the system refused to mint a key for is a recents row with Forget the
// born-orphaned case `recordOpen` already writes and quarantining the user's whole list
// over one keyless entry would be the cure being worse than the disease.
let id = UUID()
let json = """
[
{
"displayName" : "Ghost",
"id" : "\(id.uuidString)",
"lastKnownPath" : "/Volumes/Gone/Ghost.kanban",
"lastOpened" : "2026-01-01T09:00:00.000Z"
}
]
"""
try Data(json.utf8).write(to: storage.url)
let registry = BoardRegistry(storageURL: storage.url)
let rows = registry.recents()
#expect(rows.count == 1, "the file decoded; nothing was quarantined")
guard case .unavailable = rows[0] else {
Issue.record("expected unavailable, got \(rows[0])")
return
}
#expect(registry.record(id: id)?.bookmark.isEmpty == true)
// The other defaulted keys came through as their defaults too, rather than as a throw.
#expect(registry.record(id: id)?.isOpenNow == false)
#expect(registry.record(id: id)?.pushOnCommit == false)
#expect(registry.record(id: id)?.remoteLocationWarned == false)
}
@Test("A registry file with icon and iconColor present decodes them")
func registryFileWithIconKeysDecodes() async throws {
let storage = try RegistryStorage()
@@ -620,16 +656,16 @@ struct BoardRegistryTests {
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.record(id: id)?.isOpenNow == 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(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)?.isOpen(inEdition: registry.editionID) == false)
#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
@@ -639,7 +675,7 @@ struct BoardRegistryTests {
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(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)
}
@@ -712,13 +748,13 @@ struct BoardRegistryTests {
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.record(id: id)?.isOpenNow == 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)
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true)
}
// MARK: Bookmarks in a sandboxed host
+2 -2
View File
@@ -229,8 +229,8 @@ struct TemplateChooserRowTests {
// MARK: - Fixture
/// A temp store holding hand-written template board folders the user store's shape, minus
/// the shared App Group container (which no test may touch).
/// A temp store holding hand-written template board folders the user store's shape, somewhere no
/// test can disturb the real one.
struct TemplateFixture {
let store: URL
+1 -1
View File
@@ -43,7 +43,7 @@ private func subtitle(forCard cardID: String, boardNamed board: String, in model
return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value)
}
/// A registry whose file lives in temp rather than in the shared App Group container.
/// A registry whose file lives in temp rather than in the app's real Application Support home.
@MainActor
private struct RegistryStorage {
let folder: URL
+17 -19
View File
@@ -8,9 +8,8 @@ import Testing
///
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
/// run) and a temp staging directory (so nothing goes near the shared App Group container, which is now
/// the sibling edition's staging store too). Both seams exist exactly because those two claims are the
/// ones worth pinning.
/// run) and a temp staging directory (so nothing goes near the app's real Application Support home).
/// Both seams exist exactly because those two claims are the ones worth pinning.
// MARK: - Test doubles
@@ -116,8 +115,8 @@ struct ClipboardHarness {
/// The staged copy directories, sorted "at most the current copy" is a claim about this list.
///
/// Hidden entries are excluded because the sweep keeps its own bookkeeping folder among them
/// (`ClipboardStore.prune`'s claim-then-delete, which is what makes a concurrent sweep by the
/// sibling edition safe). A staged copy is never hidden its name is a lowercased UUID.
/// (`ClipboardStore.prune`'s claim-then-delete). A staged copy is never hidden its name is a
/// lowercased UUID.
func stagedCopyIDs() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: staging.path)
.filter { !$0.hasPrefix(".") }
@@ -416,17 +415,16 @@ struct ClipboardSweepTests {
#expect(try harness.stagedCopyIDs().isEmpty)
}
// MARK: The sibling edition's sweep
// MARK: Two sweeps over one store
//
// The staging store now lives in the shared App Group container (12-editions.md Both editions
// installed), so base and Pro sweep the same directory on their own launches, activations, copies
// and pastes. Both compute the *same* answer the keep set is the one `copyID` the machine-wide
// pasteboard names so they never disagree about what should go; what they can do is arrive at the
// same doomed tree together. These two tests are the ruling's two clauses: atomic removals, and
// One app, so the ordinary case is one sweeper but the sweep is written claim-then-delete
// anyway (`ClipboardStore.prune`), which is what makes a second sweeper a non-event: a second
// copy of the app launched with `open -n` shares this container, and so does the next sweep after
// a crash mid-delete. These two tests are that property's two halves: atomic removals, and
// missing-entry = already swept.
@Test("Two editions sweeping the same store at once agree, and neither errors")
func concurrentSweepsFromBothEditionsAgree() async throws {
@Test("Two stores sweeping the same staging root at once agree, and neither errors")
func concurrentSweepsAgree() async throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
@@ -443,9 +441,9 @@ struct ClipboardSweepTests {
try Data("bytes".utf8).write(to: tree.appendingPathComponent("nested/file.txt", isDirectory: false))
}
// Both editions read the *same* pasteboard, which is why both keep sets are `keep`. Modelled as
// two stores over one staging root with pasteboards holding the same manifest, since two
// processes are not something a unit test can have.
// Both sweepers read the *same* machine-wide pasteboard, which is why both keep sets are
// `keep`. Modelled as two stores over one staging root with pasteboards holding the same
// manifest, since two processes are not something a unit test can have.
let manifest = ClipboardManifest(
copyID: "keep",
boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true),
@@ -500,7 +498,7 @@ struct ClipboardSweepTests {
stagingRoot: staging,
observesActivation: false
)
// The sibling got there first which from this store's side is indistinguishable from the
// Something got there first which from this store's side is indistinguishable from the
// directory listing simply being stale by the time it is walked.
try FileManager.default.removeItem(at: doomed)
clipboard.sweep()
@@ -508,8 +506,8 @@ struct ClipboardSweepTests {
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
// And a staging root that has gone altogether the sibling swept, then something removed the
// shared folder is nothing to do either, rather than a throw on the way to a no-op.
// And a staging root that has gone altogether is nothing to do either, rather than a throw on
// the way to a no-op.
try FileManager.default.removeItem(at: staging)
clipboard.sweep()
await clipboard.stagingSettled()
+10 -11
View File
@@ -19,9 +19,8 @@ import Testing
///
/// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind.
///
/// Every test drives an explicit store URL. **No test may touch the real store** which since the
/// 2026-07-29 re-homing is in the shared App Group container, so it would be the sibling edition's
/// template store too. `userStore` is named here only to prove the engine never creates it on its own.
/// Every test drives an explicit store URL. **No test may touch the real store** it is the
/// developer's own. `userStore` is named here only to prove the engine never creates it on its own.
// MARK: - Helpers
@@ -439,18 +438,18 @@ struct SaveAsTemplateAtomicityTests {
@Suite("Save as Template — the user store")
struct UserTemplateStoreTests {
@Test("The user store is named beside the registry in the shared home, and is never created by naming it")
@Test("The user store is named beside the registry in the app's state home, and is never created by naming it")
func theStoreIsNamedNotCreated() {
let store = TemplateEngine.userStore
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
// Beside the registry and the clipboard's staging store, in the App Group container
// (09-templates.md Storage, re-homed 2026-07-29 templates cross editions). Compared against
// the one shared home rather than spelled out, so the assertion follows it wherever it goes
// including the scratch redirect a test host gets (`AppGroup.isUnitTestHost`). `AppGroup` rather
// than the two stores' own defaults because those are `@MainActor` and this suite is not; that
// the three agree is `AppGroupContainerTests`' assertion.
#expect(store.deletingLastPathComponent() == AppGroup.stateDirectory)
// Beside the registry and the clipboard's staging store (09-templates.md Storage;
// 02-architecture.md § Per-board app state, "App-wide state has the same home"). Compared
// against the one home rather than spelled out, so the assertion follows it wherever it goes
// including the scratch redirect a test host gets (`AppStateHome.isUnitTestHost`).
// `AppStateHome` rather than the two stores' own defaults because those are `@MainActor` and
// this suite is not; that the three agree is `AppStateHomeTests`' assertion.
#expect(store.deletingLastPathComponent() == AppStateHome.directory)
// Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and
// Reveal in Finder's, and both are driven with an explicit store in this suite.
}
+4 -3
View File
@@ -15,9 +15,9 @@ import Testing
// MARK: - Fixtures
/// A record with nothing in it that matters except what a given test is about. It holds **no grant
/// slot** because this function never resolves one `RecentBoard` is constructed directly here, so the
/// availability classification is an input rather than a filesystem outcome.
/// A record with nothing in it that matters except what a given test is about. Its bookmark is
/// **empty** because this function never resolves one `RecentBoard` is constructed directly here,
/// so the availability classification is an input rather than a filesystem outcome.
private func record(
name: String,
at path: String,
@@ -28,6 +28,7 @@ private func record(
iconColor: String? = nil
) -> BoardRecord {
BoardRecord(
bookmark: Data(),
displayName: name,
lastKnownPath: path,
lastOpened: opened,