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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user