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.lane1)/\(Ident.card3)", "---\nschema: 1\norder: 3072\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) try fixture.item( Ident.lane2, "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried")) try fixture.item( "\(Ident.lane2)/\(Ident.indexless)", "---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n" ) try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing")) return fixture } /// An `AppModel` whose registry file lives in temp rather than in the test host's real Application /// Support directory. @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")) 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 live items only, at both levels") func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws { let fixture = try makeMixedBoard() defer { fixture.tearDown() } let snapshot = try BoardLoader.load(boardRoot: fixture.root).model // The snapshot itself keeps everything — tombstones are what the trash renders — so this is a // genuine filter, not a property of the load. #expect(snapshot.lanes.count == 3) #expect(snapshot.lanes.flatMap(\.cards).count == 5) let counts = AppModel.liveCounts(of: snapshot) #expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size") #expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane") } @Test("A board with nothing live 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( Ident.lane1, "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried")) let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) #expect(counts.lanes == 0) #expect(counts.cards == 0) } @Test("A malformed deleted: still counts as deleted") func liveCountsFollowPresenceNotValidity() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item( "\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n" ) // The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an // unparseable timestamp hides the card here exactly as it hides it on the board. let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) #expect(counts.lanes == 1) #expect(counts.cards == 0) } // 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 live ones") #expect(record.cardCount == 2) #expect(record.isOpenNow == false) #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: 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) } }