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