import Foundation import Testing @testable import Kanban /// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash, /// resettled 2026-07-28 (the materialized trash). /// /// **The suite is much smaller than the tombstone model's was, and that is the finding.** The rows /// no longer need deriving — the trash's contents *are* `snapshot.trash`, parsed by the same card /// parse the lanes use and already in `order` display order — so the deterministic timestamp sort, /// the absolute ancestor walk, and the returning-card count all went with the entries they described. /// What remains is what the *commands* need: the two purge confirmations' phrasing, and the menu /// validation that stages Delete by place. Plus `ItemPath`, the location vocabulary that replaced /// `TrashModel.paths`. /// /// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every /// other model suite here does it: only the loader produces `.trash` the way production does. /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Fixtures /// A few more literal identities than `Ident` offers — folder name is the display tie-break, so the /// names matter. private enum More { static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff" } private func card(order: String, title: String) -> String { "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" } private func untitled(order: String) -> String { "---\nschema: 1\norder: \(order)\n---\nNo title.\n" } private func load(_ fixture: WriterFixture) throws -> BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } /// One lane with two cards, and three cards in the board's trash. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(More.laneA, card(order: "1024", title: "Todo")) try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First")) try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second")) // Newest-first by ordinary ranks: every arrival mints above the current top. try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest")) try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle")) try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest")) return fixture } private let laneA = ItemID(rawValue: More.laneA) private let card1 = ItemID(rawValue: Ident.card1) private let card2 = ItemID(rawValue: Ident.card2) private let cardD = ItemID(rawValue: More.cardD) private let cardE = ItemID(rawValue: More.cardE) private let cardF = ItemID(rawValue: More.cardF) // MARK: - The contents /// **There is no derivation left to test** — so what this suite pins instead is that the container /// *is* the list, in the order the column shows it, which is the pivot's whole claim. @MainActor @Suite("The trash's contents are the container") struct TrashContentsTests { @Test("The trash is `snapshot.trash`, newest first by ordinary ranks") func theContainerIsTheList() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"], "03 ▸ Trash: the trash sorts by `order` like any lane, and entry is at the top") #expect(snapshot.trash.allSatisfy { $0.deleted.isMissing }, "there is no `deleted:` key and no timestamp sort") } @Test("Lanes are never in it, whatever a hand-editor nests in there") func lanesAreNeverTrashed() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // A lane-shaped nesting inside `.trash/` is a stray by construction: the container holds // card folders directly, and the walk does not descend. try fixture.item(".trash/\(Ident.lane3)/\(Ident.card4)", card(order: "1024", title: "Nested")) let snapshot = try load(fixture) #expect(!snapshot.trash.map(\.id).contains(ItemID(rawValue: Ident.card4))) #expect(snapshot.lanes.map(\.id) == [laneA], "and nothing in there is a lane") } @Test("An absent container is an empty trash") func absentIsEmpty() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(More.laneA, card(order: "1024", title: "Todo")) #expect(try load(fixture).trash.isEmpty) } } // MARK: - ItemPath /// The location vocabulary that replaced `TrashModel.paths` — three cases because the board has /// exactly three places an identity-bearing folder can be. @MainActor @Suite("ItemPath") struct ItemPathTests { @Test("Each case resolves to the folder it names") func foldersResolve() throws { let root = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true) #expect(ItemPath.lane(laneA).folder(under: root).path == "/Boards/Work.kanban/\(More.laneA)") #expect(ItemPath.card(lane: laneA, id: card1).folder(under: root).path == "/Boards/Work.kanban/\(More.laneA)/\(Ident.card1)") #expect(ItemPath.trashCard(cardD).folder(under: root).path == "/Boards/Work.kanban/.trash/\(More.cardD)") } @Test("The container is the case, and only a lane is a lane") func containerAndKind() { #expect(ItemPath.lane(laneA).container == .board) #expect(ItemPath.card(lane: laneA, id: card1).container == .board) #expect(ItemPath.trashCard(cardD).container == .trash) #expect(ItemPath.lane(laneA).isLane) #expect(!ItemPath.card(lane: laneA, id: card1).isLane) #expect(!ItemPath.trashCard(cardD).isLane) } @Test("Resolution is per container, in display order, skipping what is not there") func resolutionIsPerContainer() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let everything: Set = [laneA, card1, card2, cardD, cardE, cardF] #expect(ItemPath.resolve(everything, in: .board, snapshot: snapshot) == [.lane(laneA), .card(lane: laneA, id: card1), .card(lane: laneA, id: card2)], "lanes left to right, each lane then its cards — never the caller's set order") #expect(ItemPath.resolve(everything, in: .trash, snapshot: snapshot) == [.trashCard(cardF), .trashCard(cardE), .trashCard(cardD)], "and the trash top to bottom") #expect(ItemPath.resolve([], in: .board, snapshot: snapshot).isEmpty) #expect(ItemPath.resolve([ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot).isEmpty) } @Test("A lookup that spans containers finds an item wherever it is, and nothing where it is not") func lookupSpansContainers() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(ItemPath.of(laneA, in: snapshot) == .lane(laneA)) #expect(ItemPath.of(card1, in: snapshot) == .card(lane: laneA, id: card1)) #expect(ItemPath.of(cardD, in: snapshot) == .trashCard(cardD)) #expect(ItemPath.of(ItemID(rawValue: Ident.indexless), in: snapshot) == nil) } } // MARK: - The universes @MainActor @Suite("ItemContainer ▸ the universe") struct ItemContainerUniverseTests { @Test("The board's universe is its lanes and their cards; the trash's is its cards") func universesArePresenceOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(ItemContainer.board.ids(in: snapshot) == [laneA, card1, card2]) #expect(ItemContainer.trash.ids(in: snapshot) == [cardD, cardE, cardF]) } @Test("The two universes partition the board — nothing is in both, nothing is in neither") func theyPartition() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let board = ItemContainer.board.ids(in: snapshot) let trash = ItemContainer.trash.ids(in: snapshot) #expect(board.isDisjoint(with: trash)) // The tombstone model's two sides deliberately did *not* partition — a card under a // tombstoned lane was in neither. Presence being the whole test is what closed that gap. var everything: Set = [] for lane in snapshot.lanes { everything.insert(lane.id) for card in lane.cards { everything.insert(card.id) } } for card in snapshot.trash { everything.insert(card.id) } #expect(board.union(trash) == everything) } } // MARK: - Phrasing @Suite("TrashModel ▸ phrasing") struct TrashPhrasingTests { @Test("Plurals fold, and there is only one noun left to fold") func pluralFolding() { #expect(TrashModel.phrase(1) == "1 card") #expect(TrashModel.phrase(41) == "41 cards") #expect(TrashModel.phrase(0) == "0 cards") } @MainActor @Test("A sole card is named; several fold into a count") func purgePromptNamesOrCounts() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let sole = try #require(TrashModel.purgePrompt( for: [cardF], snapshot: snapshot, unrecoverable: true )) #expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?") #expect(sole.message == "This can\u{2019}t be undone.") #expect(sole.confirmTitle == "Delete") let several = try #require(TrashModel.purgePrompt( for: [cardE, cardF], snapshot: snapshot, unrecoverable: true )) #expect(several.title == "Permanently delete 2 cards?") } @MainActor @Test("An untitled card reads as the untitled rendering, never as an empty pair of quotes") func untitledReadsAsARendering() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(More.laneA, card(order: "1024", title: "Todo")) try fixture.item(".trash/\(More.cardD)", untitled(order: "1024")) let snapshot = try load(fixture) let prompt = try #require(TrashModel.purgePrompt( for: [cardD], snapshot: snapshot, unrecoverable: true )) #expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?") } @MainActor @Test("A set naming nothing raises no prompt — the refusal and the action agree") func nothingToPurgeRaisesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(TrashModel.purgePrompt( for: [ItemID(rawValue: Ident.indexless)], snapshot: snapshot, unrecoverable: true ) == nil) #expect(TrashModel.purgePrompt(for: [], snapshot: snapshot, unrecoverable: true) == nil) } @MainActor @Test("Empty Trash names the true count, and the message follows recoverability") func emptyTrashPrompt() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let unrecoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: true)) #expect(unrecoverable.title == "Permanently delete 3 cards?") #expect(unrecoverable.message == "This can\u{2019}t be undone.") let recoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: false)) #expect(recoverable.message == "The board\u{2019}s history still has them.") } @MainActor @Test("An empty trash raises no Empty Trash prompt") func emptyTrashOnAnEmptyTrash() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(More.laneA, card(order: "1024", title: "Todo")) #expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil) } } // MARK: - Menu validation @MainActor @Suite("TrashModel ▸ validation") struct TrashValidationTests { /// **One Delete, one predicate.** The tombstone model needed a mirror-image pair so two ⌘⌫ twins /// could enable exactly one of themselves; Put Back's retirement left one item, so the predicate /// is "does this selection name anything", asked in the selection's own container. @Test("Delete enables for either container, and for nothing that names nothing") func canDeleteIsStagedNotSplit() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot)) #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot)) #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot)) #expect(!TrashModel.canDelete(selection: .empty, in: snapshot)) // A selection the next reload will drop: the id is real, but not in the container it claims. #expect(!TrashModel.canDelete( selection: ItemReferenceSet(ids: [card1], container: .trash), in: snapshot )) #expect(!TrashModel.canDelete( selection: ItemReferenceSet(ids: [cardD], container: .board), in: snapshot )) } }