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
300 lines
14 KiB
Swift
300 lines
14 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `AppModel` is mostly window bookkeeping that only means anything with a window on screen, but two
|
|
/// of its members are pure facts about a snapshot and both are load-bearing: the counts a welcome row
|
|
/// advertises, and the name a window title shows. Neither is observable from a unit test any other
|
|
/// way once it is wrong — a stale count looks like staleness, which the design accepts, and a wrong
|
|
/// count looks exactly the same.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// Live and tombstoned at both levels, plus the case the ancestor walk exists for: live cards
|
|
/// underneath a tombstoned lane.
|
|
///
|
|
/// - lane 1 (live): two live cards, one tombstoned card
|
|
/// - lane 2 (**tombstoned**): two live cards, which render nowhere and must not count
|
|
/// - lane 3 (live): empty
|
|
@MainActor
|
|
private func makeMixedBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
|
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Archive"))
|
|
|
|
// The trash: cards in a sibling container, never lanes (03-board-ui.md § Trash).
|
|
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Gone"))
|
|
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "2048", title: "Also gone"))
|
|
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).
|
|
@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"),
|
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
|
)
|
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
|
}
|
|
|
|
/// Opens a board the way `BoardWindowHost` does — record, acquire, name it for real, flag, begin —
|
|
/// so the close tests are closing something the app would recognise.
|
|
@MainActor
|
|
@discardableResult
|
|
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
|
let ref = BoardWindowRef(url: url)
|
|
let recordID = model.boardRegistry.recordOpen(of: url)
|
|
let store = try model.storeRegistry.acquire(url)
|
|
model.boardRegistry.syncDisplayState(id: recordID, title: AppModel.displayName(of: store), icon: nil, iconColor: nil)
|
|
model.boardRegistry.setOpenNow(id: recordID)
|
|
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
|
return ref
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("AppModel")
|
|
struct AppModelTests {
|
|
|
|
// MARK: Live-only counts
|
|
|
|
@Test("The recents counts are working items only — the trash is an errand, not inventory")
|
|
func liveCountsExcludeTheTrash() throws {
|
|
let fixture = try makeMixedBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// The snapshot itself keeps everything — the trash is a sibling container — so this is a
|
|
// genuine exclusion, not a property of the load.
|
|
#expect(snapshot.lanes.count == 2)
|
|
#expect(snapshot.trash.count == 2)
|
|
|
|
let counts = AppModel.liveCounts(of: snapshot)
|
|
#expect(counts.lanes == 2)
|
|
// 02 § Per-board app state, re-grounded 2026-07-28: "cards in `.trash/` don't count; the row
|
|
// advertises the board's working size". The walk reads `snapshot.lanes` and the trash is
|
|
// `snapshot.trash`, so the exclusion is by construction and none could be forgotten.
|
|
#expect(counts.cards == 2)
|
|
}
|
|
|
|
@Test("A board with nothing on it counts zero rather than declining to answer")
|
|
func liveCountsOfAnEmptyBoard() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Gone"))
|
|
|
|
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
|
|
#expect(counts.lanes == 0)
|
|
#expect(counts.cards == 0, "a board whose only content is trash advertises no working size")
|
|
}
|
|
|
|
// MARK: Display name
|
|
|
|
@Test("A board's display name is its title, falling back to the folder name sans extension")
|
|
func displayNameFallsBackToTheFolderName() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
try fixture.item("Weekly.kanban", "---\nschema: 1\ntitle: Weekly Review\n---\nbody\n")
|
|
let titled = try BoardStore(rootURL: fixture.url("Weekly.kanban"))
|
|
#expect(AppModel.displayName(of: titled) == "Weekly Review")
|
|
|
|
try fixture.item("Untitled Board.kanban", "---\nschema: 1\n---\nbody\n")
|
|
let untitled = try BoardStore(rootURL: fixture.url("Untitled Board.kanban"))
|
|
#expect(AppModel.displayName(of: untitled) == "Untitled Board", "sans extension, per 01 § Board naming")
|
|
}
|
|
|
|
// MARK: Record before load
|
|
|
|
/// `BoardWindowHost.start()`'s own sequence for a board whose load fails fail-fast: record
|
|
/// first, then attempt the load, then (on failure) file the launch failure and refresh the
|
|
/// cached recents this test reads back through `WelcomeRow.derive`, exactly as welcome would.
|
|
@Test("A first open that fails fail-fast still records — a folder-name row carrying the failure")
|
|
func failFastOpenStillRecordsAndSurfacesRowLevel() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
// The board root's own `index.md` fails to parse — fail-fast's own case, thrown the moment
|
|
// `BoardStore.init` (behind `storeRegistry.acquire`) walks it.
|
|
try fixture.item("", "---\nschema: 1\nlabels: [a, b\n---\nbody\n")
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
let ref = BoardWindowRef(url: fixture.root)
|
|
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
|
|
|
var failureMessage: String?
|
|
do throws(BoardLoadError) {
|
|
_ = try model.storeRegistry.acquire(fixture.root)
|
|
Issue.record("expected the load to fail fail-fast")
|
|
} catch {
|
|
failureMessage = error.description
|
|
model.recordLaunchFailure(path: ref.path, message: error.description)
|
|
}
|
|
model.refreshRecents()
|
|
|
|
// The record exists, provisionally named after the folder — nothing loaded, so nothing else
|
|
// was there to trust (02 § Per-board app state).
|
|
let record = try #require(model.boardRegistry.record(id: recordID))
|
|
#expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent)
|
|
#expect(record.laneCount == nil, "never opened successfully, so nothing was ever counted")
|
|
|
|
// And the failure lands on that very row — uniform with a failed restoration's row, never
|
|
// the separate list of failures naming no record.
|
|
let derived = WelcomeRow.derive(recents: model.recents, failures: model.launchFailures)
|
|
let row = try #require(derived.rows.first { $0.id == recordID })
|
|
guard case let .failed(message) = row.caption else {
|
|
Issue.record("expected the row to carry the failure, got \(row.caption)")
|
|
return
|
|
}
|
|
#expect(message == failureMessage)
|
|
#expect(derived.unmatched.isEmpty, "the failure landed on a row it already had, not the fallback list")
|
|
}
|
|
|
|
@Test("Retrying a fail-fast open reuses the same record, and a later success replaces its name")
|
|
func retryingAFailFastOpenReusesTheRecord() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let brokenIndex = "---\nschema: 1\nlabels: [a, b\n---\nbody\n"
|
|
try fixture.item("", brokenIndex)
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
let ref = BoardWindowRef(url: fixture.root)
|
|
let firstAttempt = model.boardRegistry.recordOpen(of: fixture.root)
|
|
do throws(BoardLoadError) {
|
|
_ = try model.storeRegistry.acquire(fixture.root)
|
|
Issue.record("expected the load to fail fail-fast")
|
|
} catch {
|
|
model.recordLaunchFailure(path: ref.path, message: error.description)
|
|
}
|
|
model.refreshRecents()
|
|
#expect(model.boardRegistry.recents().count == 1, "a retry updates the one record, it does not add one")
|
|
|
|
// The user fixes the file and retries — `BoardWindowHost.start()`'s exact sequence again.
|
|
try fixture.item("", "---\nschema: 1\ntitle: Fixed Board\n---\nbody\n")
|
|
let secondAttempt = model.boardRegistry.recordOpen(of: fixture.root)
|
|
#expect(secondAttempt == firstAttempt, "the same folder is the same board, failed or not")
|
|
|
|
let store = try model.storeRegistry.acquire(fixture.root)
|
|
model.boardRegistry.syncDisplayState(id: secondAttempt, title: AppModel.displayName(of: store), icon: nil, iconColor: nil)
|
|
|
|
let record = try #require(model.boardRegistry.record(id: secondAttempt))
|
|
#expect(record.displayName == "Fixed Board", "the first success replaces the provisional folder name")
|
|
}
|
|
|
|
// MARK: Sessions
|
|
|
|
@Test("A user close stamps live counts, unflags the board, and lets the store go")
|
|
func closingABoardRunsTheRealFlush() async throws {
|
|
let fixture = try makeMixedBoard()
|
|
defer { fixture.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
let ref = try openBoard(model, at: fixture.root)
|
|
let recordID = try #require(model.session(for: ref)?.recordID)
|
|
#expect(model.hasOpenBoards)
|
|
#expect(model.storeRegistry.liveStore(for: fixture.root) != nil)
|
|
|
|
await model.closeBoard(ref: ref, cause: .userClose)
|
|
|
|
#expect(model.session(for: ref) == nil)
|
|
#expect(!model.hasOpenBoards)
|
|
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
|
|
|
|
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(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)
|
|
}
|
|
|
|
@Test("Quit closes every board and leaves them all flagged for the next launch")
|
|
func quitFlushesEveryBoardAndPreservesTheRestorationSet() async throws {
|
|
let first = try makeMixedBoard()
|
|
defer { first.tearDown() }
|
|
let second = try makeMixedBoard()
|
|
defer { second.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
try openBoard(model, at: first.root)
|
|
try openBoard(model, at: second.root)
|
|
#expect(model.storeRegistry.openBoardCount == 2)
|
|
|
|
await model.flushAllBoardsForQuit()
|
|
|
|
#expect(!model.hasOpenBoards)
|
|
#expect(model.storeRegistry.openBoardCount == 0, "every board's store was released, not just the first")
|
|
#expect(model.boardRegistry.restorables().count == 2, "the flags describe what was open at quit")
|
|
for row in model.boardRegistry.restorables() {
|
|
#expect(row.record.laneCount == 2, "and every board was stamped on the way out")
|
|
}
|
|
}
|
|
|
|
@Test("Card windows join and leave their board's session")
|
|
func cardWindowMembershipIsTracked() throws {
|
|
let fixture = try makeMixedBoard()
|
|
defer { fixture.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
let ref = try openBoard(model, at: fixture.root)
|
|
let card = CardWindowRef(board: ref, cardID: ItemID(rawValue: Ident.card1))
|
|
let session = CardWindowSession()
|
|
|
|
model.registerCardWindow(card, session: session)
|
|
#expect(model.session(for: ref)?.cardRefs == [card])
|
|
|
|
// A card window against a board with no session is the one thing the ownership rule forbids;
|
|
// registering it would leave an entry the close flush never drains.
|
|
let orphan = CardWindowRef(boardPath: "/nowhere", cardID: Ident.card2)
|
|
model.registerCardWindow(orphan, session: CardWindowSession())
|
|
#expect(model.session(for: orphan.board) == nil)
|
|
|
|
model.unregisterCardWindow(card)
|
|
#expect(model.session(for: ref)?.cardRefs.isEmpty == true)
|
|
|
|
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
|
|
}
|
|
|
|
// MARK: Launch restoration
|
|
|
|
/// App ▸ Settings…'s "Restore open boards at launch" (11-command-nexus.md) gates the flagged set
|
|
/// by AND, not by either half alone: the preference off never restores even with boards flagged
|
|
/// (a user who turned it off gets welcome, full stop), and the preference on restores nothing when
|
|
/// there is nothing flagged (an ordinary first launch, which shows welcome exactly as it always
|
|
/// has, not an empty restoration pass).
|
|
@Test(
|
|
"The launch-restoration gate is the preference AND something to restore",
|
|
arguments: [
|
|
(preference: true, hasRestorables: true, expected: true),
|
|
(preference: true, hasRestorables: false, expected: false),
|
|
(preference: false, hasRestorables: true, expected: false),
|
|
(preference: false, hasRestorables: false, expected: false),
|
|
]
|
|
)
|
|
func launchRestorationGate(preference: Bool, hasRestorables: Bool, expected: Bool) {
|
|
#expect(AppModel.shouldRestoreAtLaunch(preference: preference, hasRestorables: hasRestorables) == expected)
|
|
}
|
|
}
|