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
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,19 @@ private func makeMixedBoard() throws -> WriterFixture {
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// An `AppModel` whose registry file lives in temp rather than in the test host's real Application
|
||||
/// Support directory.
|
||||
/// 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).
|
||||
@MainActor
|
||||
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
||||
let folder = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("AppModelTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json"))
|
||||
let model = AppModel(
|
||||
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||
)
|
||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||
}
|
||||
|
||||
@@ -214,13 +219,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.isOpenNow == false)
|
||||
#expect(!record.isOpen(inEdition: model.boardRegistry.editionID))
|
||||
#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)?.isOpenNow == false)
|
||||
#expect(model.boardRegistry.record(id: recordID)?.isOpen(inEdition: model.boardRegistry.editionID) == false)
|
||||
}
|
||||
|
||||
@Test("Quit closes every board and leaves them all flagged for the next launch")
|
||||
|
||||
@@ -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.bookmark.isEmpty)
|
||||
#expect(created.grant(forEdition: registry.editionID) != nil)
|
||||
#expect(created.displayName == "Todo Board")
|
||||
#expect(created.lastKnownPath == fixture.root.path)
|
||||
#expect(created.laneCount == nil, "counts are stamped at close, never guessed at open")
|
||||
@@ -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.bookmark.isEmpty, "the bookmark still mints on the before-load call")
|
||||
#expect(record.grant(forEdition: registry.editionID) != nil, "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.bookmark.isEmpty)
|
||||
#expect(record.grant(forEdition: registry.editionID) != nil)
|
||||
}
|
||||
|
||||
// MARK: Counts
|
||||
@@ -620,16 +620,16 @@ struct BoardRegistryTests {
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
|
||||
let id = registry.recordOpen(of: fixture.root, displayName: "Work")
|
||||
#expect(registry.record(id: id)?.isOpenNow == nil, "recording an open is not opening a window")
|
||||
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false, "recording an open is not opening a window")
|
||||
#expect(registry.restorables().isEmpty)
|
||||
|
||||
registry.setOpenNow(id: id)
|
||||
#expect(registry.record(id: id)?.isOpenNow == true)
|
||||
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == true)
|
||||
#expect(ids(registry.restorables()) == [id])
|
||||
|
||||
// A user close. The flag goes, and with it the board's place in the next launch.
|
||||
registry.clearOpenNow(id: id)
|
||||
#expect(registry.record(id: id)?.isOpenNow == false)
|
||||
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false)
|
||||
#expect(registry.restorables().isEmpty)
|
||||
|
||||
// A quit. The teardown stamps counts and does *not* clear the flag — that omission is the
|
||||
@@ -639,7 +639,7 @@ struct BoardRegistryTests {
|
||||
registry.recordClose(id: id, displayName: "Work", laneCount: 2, cardCount: 5)
|
||||
|
||||
let afterRelaunch = BoardRegistry(storageURL: storage.url)
|
||||
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
|
||||
#expect(afterRelaunch.record(id: id)?.isOpen(inEdition: registry.editionID) == true, "the flags describe what was open at quit")
|
||||
#expect(ids(afterRelaunch.restorables()) == [id])
|
||||
#expect(afterRelaunch.record(id: id)?.laneCount == 2)
|
||||
}
|
||||
@@ -712,13 +712,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)?.isOpenNow == nil, "a missing key reads as 'not open'")
|
||||
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false, "a missing key reads as 'not open'")
|
||||
#expect(registry.restorables().isEmpty)
|
||||
#expect(registry.record(id: id)?.laneCount == 4, "and every other field survived")
|
||||
|
||||
// And the key writes through from here on.
|
||||
registry.setOpenNow(id: id)
|
||||
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true)
|
||||
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpen(inEdition: registry.editionID) == true)
|
||||
}
|
||||
|
||||
// MARK: Bookmarks in a sandboxed host
|
||||
|
||||
@@ -230,7 +230,7 @@ struct TemplateChooserRowTests {
|
||||
// MARK: - Fixture
|
||||
|
||||
/// A temp store holding hand-written template board folders — the user store's shape, minus
|
||||
/// Application Support (which no test may touch).
|
||||
/// the shared App Group container (which no test may touch).
|
||||
struct TemplateFixture {
|
||||
|
||||
let store: URL
|
||||
|
||||
@@ -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 test host's Application Support.
|
||||
/// A registry whose file lives in temp rather than in the shared App Group container.
|
||||
@MainActor
|
||||
private struct RegistryStorage {
|
||||
let folder: URL
|
||||
@@ -264,7 +264,10 @@ struct CardWindowIdentityTests {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
|
||||
let model = AppModel(registryStorageURL: storage.url)
|
||||
let model = AppModel(
|
||||
registryStorageURL: storage.url,
|
||||
clipboardStagingRoot: storage.folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||
)
|
||||
let board = BoardWindowRef(url: fixture.root)
|
||||
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
||||
let store = try model.storeRegistry.acquire(fixture.root)
|
||||
|
||||
@@ -8,8 +8,9 @@ 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 Application Support). Both seams exist
|
||||
/// exactly because those two claims are the ones worth pinning.
|
||||
/// 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.
|
||||
|
||||
// MARK: - Test doubles
|
||||
|
||||
@@ -109,8 +110,14 @@ 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.
|
||||
func stagedCopyIDs() throws -> [String] {
|
||||
try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted()
|
||||
try FileManager.default.contentsOfDirectory(atPath: staging.path)
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +396,106 @@ struct ClipboardSweepTests {
|
||||
|
||||
#expect(try harness.stagedCopyIDs().isEmpty)
|
||||
}
|
||||
|
||||
// MARK: The sibling edition's sweep
|
||||
//
|
||||
// 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
|
||||
// missing-entry = already swept.
|
||||
|
||||
@Test("Two editions sweeping the same store at once agree, and neither errors")
|
||||
func concurrentSweepsFromBothEditionsAgree() async throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
|
||||
// Six trees, each with a file in it so a removal is a real recursive delete rather than an
|
||||
// empty-directory unlink — the case where two sweepers walking one tree could see it half gone.
|
||||
for name in ["a", "b", "c", "d", "e", "keep"] {
|
||||
let tree = staging.appendingPathComponent(name, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: tree.appendingPathComponent("nested", isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
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.
|
||||
let manifest = ClipboardManifest(
|
||||
copyID: "keep",
|
||||
boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true),
|
||||
kind: .card,
|
||||
container: .board,
|
||||
// One entry, because a manifest with none is refused outright (`init?(data:)`) — and a
|
||||
// refused manifest is a keep set of nothing, which is a different test.
|
||||
entries: [
|
||||
ClipboardManifest.Entry(
|
||||
id: Ident.card1,
|
||||
folder: Ident.card1,
|
||||
title: "First",
|
||||
index: "---\nschema: 1\ntitle: First\norder: 1024\n---\n",
|
||||
attachmentCount: 0
|
||||
)
|
||||
]
|
||||
)
|
||||
let data = try #require(manifest.encoded())
|
||||
|
||||
let onePasteboard = FakePasteboard()
|
||||
onePasteboard.write(manifest: data, text: manifest.plainText)
|
||||
let otherPasteboard = FakePasteboard()
|
||||
otherPasteboard.write(manifest: data, text: manifest.plainText)
|
||||
|
||||
// Each `init` sweeps — the launch sweep — so the two are already racing before either explicit
|
||||
// call below.
|
||||
let one = ClipboardStore(pasteboard: onePasteboard, stagingRoot: staging, observesActivation: false)
|
||||
let other = ClipboardStore(pasteboard: otherPasteboard, stagingRoot: staging, observesActivation: false)
|
||||
one.sweep()
|
||||
other.sweep()
|
||||
one.sweep()
|
||||
await one.stagingSettled()
|
||||
await other.stagingSettled()
|
||||
|
||||
// The answer both computed, arrived at exactly once: the named tree intact, everything else
|
||||
// gone, and no bookkeeping left behind.
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() == ["keep"])
|
||||
#expect(FileManager.default.fileExists(atPath: staging.appendingPathComponent("keep/nested/file.txt").path))
|
||||
}
|
||||
|
||||
@Test("A tree that vanished before the sweep reached it is already swept, not an error")
|
||||
func aVanishedEntryIsANoOp() async throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
let doomed = staging.appendingPathComponent("gone", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: doomed, withIntermediateDirectories: true)
|
||||
|
||||
let clipboard = ClipboardStore(
|
||||
pasteboard: FakePasteboard(),
|
||||
stagingRoot: staging,
|
||||
observesActivation: false
|
||||
)
|
||||
// The sibling 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()
|
||||
await clipboard.stagingSettled()
|
||||
|
||||
#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.
|
||||
try FileManager.default.removeItem(at: staging)
|
||||
clipboard.sweep()
|
||||
await clipboard.stagingSettled()
|
||||
#expect(!FileManager.default.fileExists(atPath: staging.path), "a vanished store is not recreated by a sweep")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Takeover
|
||||
|
||||
@@ -464,7 +464,10 @@ private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
||||
let folder = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("HistoryProviderTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json"))
|
||||
let model = AppModel(
|
||||
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||
)
|
||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ 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 Application Support** — `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** — 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.
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -438,14 +439,18 @@ struct SaveAsTemplateAtomicityTests {
|
||||
@Suite("Save as Template — the user store")
|
||||
struct UserTemplateStoreTests {
|
||||
|
||||
@Test("The user store is named inside the app container and is never created by naming it")
|
||||
@Test("The user store is named beside the registry in the shared home, and is never created by naming it")
|
||||
func theStoreIsNamedNotCreated() {
|
||||
let store = TemplateEngine.userStore
|
||||
|
||||
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
|
||||
#expect(store.deletingLastPathComponent().lastPathComponent
|
||||
== (Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"))
|
||||
#expect(store.path.contains("Application Support"))
|
||||
// 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)
|
||||
// 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.
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import Testing
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A record with nothing in it that matters except what a given test is about. The bookmark is empty
|
||||
/// because this function never resolves one — `RecentBoard` is constructed directly here, so the
|
||||
/// 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.
|
||||
private func record(
|
||||
name: String,
|
||||
@@ -28,7 +28,6 @@ private func record(
|
||||
iconColor: String? = nil
|
||||
) -> BoardRecord {
|
||||
BoardRecord(
|
||||
bookmark: Data(),
|
||||
displayName: name,
|
||||
lastKnownPath: path,
|
||||
lastOpened: opened,
|
||||
|
||||
Reference in New Issue
Block a user