Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
437 lines
21 KiB
Swift
437 lines
21 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 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
|
|
.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
|
|
}
|
|
|
|
/// The same board with a **real repository** at its root, root commit and all — what a Pro session
|
|
/// detects as mode `git`, and therefore the only shape that composes a branch switcher to test the
|
|
/// settle step's seams through.
|
|
@MainActor
|
|
private func makeProGitBoard() throws -> WriterFixture {
|
|
let fixture = try makeMixedBoard()
|
|
guard case .success = GitRepository.create(at: fixture.root) else {
|
|
fixture.tearDown()
|
|
Issue.record("could not initialize a repository for the fixture board")
|
|
throw CocoaError(.fileWriteUnknown)
|
|
}
|
|
return fixture
|
|
}
|
|
|
|
/// Opens a card window the way its host does — registered with the board's session — and leaves it
|
|
/// holding one fine step and an open Edit session, so it both *has* a stack to lose and answers the
|
|
/// save-or-discard step's `needsSettling` with `true`.
|
|
///
|
|
/// The step is registered through `BoardStore.registerStep` rather than pushed onto the provider, so
|
|
/// it is routed by the same line production routes a card-window gesture with (`on:` → the window's
|
|
/// stack) and carries the raw write the close fold would look for.
|
|
@MainActor
|
|
@discardableResult
|
|
private func openCardWindow(
|
|
_ model: AppModel,
|
|
board: BoardWindowRef,
|
|
card id: String,
|
|
store: BoardStore
|
|
) -> CardWindowSession {
|
|
let window = CardWindowSession()
|
|
model.registerCardWindow(CardWindowRef(board: board, cardID: ItemID(rawValue: id)), session: window)
|
|
window.body.beginEditSession()
|
|
store.registerStep(
|
|
"Edit Card",
|
|
on: window.undo,
|
|
undoExpects: [.present(.card(ItemID(rawValue: id)), .body("after\n"))],
|
|
redoExpects: [.present(.card(ItemID(rawValue: id)), .body("before\n"))],
|
|
undo: { _ in },
|
|
redo: { _ in }
|
|
)
|
|
return window
|
|
}
|
|
|
|
// 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(BoardLoadFailure) {
|
|
_ = 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(BoardLoadFailure) {
|
|
_ = 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.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)?.isOpenNow == 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: The branch switch's settle
|
|
|
|
/// **"The settle also clears each open card window's fine undo stack"** (06-history-undo.md
|
|
/// ▸ Branch switching, ruled 2026-07-31): "pre-switch steps describe the branch being left — Save
|
|
/// All and Discard alike end with every window's stack empty … the windows stay open, following
|
|
/// their cards onto the new branch with fresh stacks."
|
|
///
|
|
/// This is an `AppModel` test rather than a `GitBranchSwitcher` one because the clear is a fact
|
|
/// about the **composition**: the switcher's settle seam, the card-window registry and the stacks
|
|
/// themselves only meet in `wireBranchSwitching`, and a switcher wired by hand would be a test
|
|
/// asserting its own wiring (the `a381fac` lesson, applied one card later).
|
|
@Test(
|
|
"The branch switch's settle empties every open card window's fine stack",
|
|
arguments: [SessionSettleChoice.saveAll, .discard]
|
|
)
|
|
func theSettleClearsEveryFineStack(answering choice: SessionSettleChoice) async throws {
|
|
let board = try makeProGitBoard()
|
|
defer { board.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
model.currentTier = { .pro }
|
|
|
|
let ref = try openBoard(model, at: board.root)
|
|
let session = try #require(model.session(for: ref))
|
|
let switcher = try #require(session.git?.switcher)
|
|
|
|
let windows = [Ident.card1, Ident.card2].map { id in
|
|
openCardWindow(model, board: ref, card: id, store: session.store)
|
|
}
|
|
#expect(windows.allSatisfy { $0.undo.stack.canUndo })
|
|
#expect(windows.allSatisfy { $0.undo.netEffect() != nil }, "a session with a net effect to fold")
|
|
|
|
model.settleAsk = { _ in choice }
|
|
#expect(await switcher.settleSessions?() == .proceed)
|
|
|
|
for window in windows {
|
|
#expect(!window.undo.stack.canUndo, "the stack describes the branch being left")
|
|
#expect(!window.undo.manager.canUndo, "and ⌘Z in that window answers with it")
|
|
}
|
|
// "The windows stay open, following their cards onto the new branch with fresh stacks."
|
|
#expect(model.session(for: ref)?.cardRefs.count == 2)
|
|
}
|
|
|
|
/// **Closing the stack is not closing the window.** The coarse step a card window owes its board is
|
|
/// registered at *close*, folded from this stack (13-native-undo.md ▸ Rules ▸ "Window close
|
|
/// coarsens"); a settle clear registers nothing at all, which is exactly what
|
|
/// `registerCardSession` answering `false` — and the deferred purge staying the caller's — says.
|
|
@Test("A settle clear registers no coarse step — the fold that would have run finds nothing")
|
|
func theClearRegistersNoCoarseStep() async throws {
|
|
let board = try makeProGitBoard()
|
|
defer { board.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
model.currentTier = { .pro }
|
|
|
|
let ref = try openBoard(model, at: board.root)
|
|
let session = try #require(model.session(for: ref))
|
|
let switcher = try #require(session.git?.switcher)
|
|
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
|
|
|
|
model.settleAsk = { _ in .saveAll }
|
|
#expect(await switcher.settleSessions?() == .proceed)
|
|
|
|
#expect(window.undo.netEffect() == nil, "nothing left to fold")
|
|
var purged = false
|
|
let registered = session.store.registerCardSession(
|
|
window.undo,
|
|
inCard: ItemID(rawValue: Ident.card1),
|
|
retiring: { purged = true }
|
|
)
|
|
#expect(!registered, "a close arriving right after the switch registers nothing")
|
|
#expect(!purged, "and the deferred purge is still the caller's, not a step's")
|
|
}
|
|
|
|
/// "**Cancel** keeps the current branch and the sessions" — and now their stacks with them. The
|
|
/// same `if` that withholds the staging release withholds this.
|
|
@Test("Cancel clears nothing")
|
|
func cancelKeepsTheFineStacks() async throws {
|
|
let board = try makeProGitBoard()
|
|
defer { board.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
model.currentTier = { .pro }
|
|
|
|
let ref = try openBoard(model, at: board.root)
|
|
let session = try #require(model.session(for: ref))
|
|
let switcher = try #require(session.git?.switcher)
|
|
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
|
|
|
|
model.settleAsk = { _ in .cancel }
|
|
#expect(await switcher.settleSessions?() == .cancelled)
|
|
#expect(window.undo.stack.canUndo)
|
|
#expect(window.undo.netEffect() != nil)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|