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 — acquire, record, 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 store = try model.storeRegistry.acquire(url) let recordID = model.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store)) 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: 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)) } }