Files
lanework/KanbanTests/AppModelTests.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

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(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.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)
}
}