import Foundation import Testing @testable import Kanban /// `TrashModel` is the trash quasi-lane's whole heart, and it is a pure function — so this suite is /// the executable form of 03-board-ui.md § Trash ▸ Contents: the deterministic sort, the absolute /// ancestor walk, and what a lane entry's card count actually counts. /// /// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every /// other model suite here does it: the interesting inputs are `FieldValue` shapes — a valid /// stamp, an unparseable one, an absent key — and only the loader produces those the way production /// does. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Fixtures /// A few more literal identities than `Ident` offers: the sort tests need enough rows to prove an /// *ordering* rather than a comparison, and folder name is the tie-break, so the names matter. private enum More { static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let laneB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff" } /// An item with a `deleted:` key — `stamp` goes in verbatim, so a test can write an unparseable one. private func tombstoned(order: String, title: String, deleted: String) -> String { """ --- schema: 1 title: \(title) order: \(order) deleted: \(deleted) --- \(title) body. """ } private func live(order: String, title: String) -> String { "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" } private func load(_ fixture: WriterFixture) throws -> BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } private func ids(_ entries: [TrashEntry]) -> [String] { entries.map(\.id.rawValue) } // MARK: - The sort @Suite("TrashModel ▸ sort") struct TrashModelSortTests { @Test("Dated entries sort newest first, and lane entries interleave by their own stamp") func newestFirstWithLanesInterleaved() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "Oldest card", deleted: "2026-03-01T09:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Newest card", deleted: "2026-03-03T09:00:00Z")) // A tombstoned lane whose own stamp falls between the two cards': the sort is one ordering // over both kinds, not cards-then-lanes. try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z")) let entries = TrashModel.entries(of: try load(fixture)) #expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1]) #expect(entries[1].isLaneEntry) } @Test("Ties on the second break by folder name, ascending") func tiesBreakByFolderName() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) // One multi-card ⌫ stamps one second onto N cards. The three are written in an order that // is neither their folder-name order nor their `order` order, so only the rule can produce // the expectation below. let stamp = "2026-03-03T09:00:00Z" try fixture.item("\(Ident.lane1)/\(More.cardF)", tombstoned(order: "1024", title: "F", deleted: stamp)) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "2048", title: "5", deleted: stamp)) try fixture.item("\(Ident.lane1)/\(More.cardD)", tombstoned(order: "3072", title: "D", deleted: stamp)) let entries = TrashModel.entries(of: try load(fixture)) #expect(ids(entries) == [Ident.card1, More.cardD, More.cardF]) } @Test("An unparseable stamp sorts oldest — after every dated entry, however old") func unparseableSortsOldest() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "Corrupt", deleted: "whenever")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Ancient", deleted: "1999-01-01T00:00:00Z")) let model = try load(fixture) // The premise: the key is present but has no date reading — still a tombstone (presence, // not validity), and still with no position in time. let corrupt = try #require(model.lanes.first?.cards.first { $0.id.rawValue == Ident.card1 }) #expect(corrupt.isDeleted) #expect(corrupt.deleted.value == nil) // A corrupt stamp must not outrank a fresh deletion for the trash's most prominent rows — // and here it does not even outrank a 1999 one. #expect(ids(TrashModel.entries(of: model)) == [Ident.card2, Ident.card1]) } @Test("Undated entries order by folder name among themselves, lanes included") func undatedOrderByFolderName() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(More.laneB, tombstoned(order: "3072", title: "B lane", deleted: "not-a-date")) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(More.cardE)", tombstoned(order: "1024", title: "E", deleted: "corrupt")) try fixture.item(More.laneA, tombstoned(order: "2048", title: "A lane", deleted: "later")) // `a… < b… < e…`: one folder-name ordering across both kinds, exactly as the dated half has // one date ordering across both kinds. #expect(ids(TrashModel.entries(of: try load(fixture))) == [More.laneA, More.laneB, More.cardE]) } } // MARK: - The ancestor walk and the returning count @Suite("TrashModel ▸ contents") struct TrashModelContentsTests { /// The board every test below reads: one live lane holding a tombstoned card and a live one, and /// one tombstoned lane holding a live card, a card with its own flag, and another live card. private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "Trashed card", deleted: "2026-03-03T09:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", live(order: "2048", title: "Live card")) try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z")) try fixture.item("\(Ident.lane2)/\(Ident.card3)", live(order: "1024", title: "Rides along")) try fixture.item("\(Ident.lane2)/\(More.cardD)", tombstoned(order: "2048", title: "Own flag", deleted: "2026-03-04T09:00:00Z")) try fixture.item("\(Ident.lane2)/\(More.cardE)", live(order: "3072", title: "Rides along too")) return fixture } @Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute") func ownFlagUnderTombstonedLaneHasNoRow() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let entries = TrashModel.entries(of: try load(fixture)) // `cardD`'s stamp is the newest on the board, so if it had a row at all it would be the // first one. The lane's single entry subsumes it instead. #expect(ids(entries) == [Ident.card1, Ident.lane2]) #expect(!ids(entries).contains(More.cardD)) } @Test("A lane entry counts what Put Back returns — cards without their own flag") func returningCountExcludesOwnFlaggedCards() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let entries = TrashModel.entries(of: try load(fixture)) let lane = try #require(entries.first { $0.id.rawValue == Ident.lane2 }) guard case let .lane(_, returning) = lane else { Issue.record("expected a lane entry") return } // Three cards sit in the folder; two come back with the lane. The third comes back to the // *trash*, which is why it is not in this number. #expect(returning == 2) } @Test("A lane entry with nothing to return counts zero rather than being suppressed") func emptyLaneEntryCountsZero() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Empty", deleted: "2026-03-03T09:00:00Z")) let entries = TrashModel.entries(of: try load(fixture)) #expect(entries.count == 1) guard case let .lane(_, returning) = entries[0] else { Issue.record("expected a lane entry") return } #expect(returning == 0) } @Test("An empty trash is empty, and a board with any tombstone is not") func emptiness() throws { let clean = try WriterFixture() defer { clean.tearDown() } try clean.item("", Item.board) try clean.item(Ident.lane1, live(order: "1024", title: "Todo")) try clean.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Card")) #expect(TrashModel.isEmpty(try load(clean))) #expect(TrashModel.entries(of: try load(clean)).isEmpty) let dirty = try makeBoard() defer { dirty.tearDown() } #expect(!TrashModel.isEmpty(try load(dirty))) } @Test("Paths resolve on the effective liveness side, in display order") func pathsResolveByEffectiveLiveness() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let model = try load(fixture) let everything: Set = [ ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2), ItemID(rawValue: Ident.lane2), ItemID(rawValue: Ident.card3), ItemID(rawValue: More.cardD), ] // Live: the live lane and its live card. `card3` is live by its own flag but its lane is // tombstoned, so the ancestor walk puts it on the other side. let liveSide = TrashModel.paths(of: everything, on: .live, in: model) #expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" } == ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"]) // Trashed: the tombstoned card, the tombstoned lane, and everything the lane hides — in // display order, lanes left to right and each lane before its cards. let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model) #expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" } == ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-", "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(More.cardD)"]) #expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty) #expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty) } @Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder") func emptyTrashTargets() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let targets = TrashModel.emptyTrashTargets(in: try load(fixture)) // The own-flag card inside the tombstoned lane is not listed — removing the lane folder // takes it, and a second purge of a path that is already gone would be noise. #expect(targets.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" } == ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"]) #expect(TrashModel.counts(of: targets) == TrashModel.EntryCounts(lanes: 1, cards: 1)) } @Test("A path resolves under whichever root it is given") func pathsResolveUnderTheGivenRoot() { let root = URL(fileURLWithPath: "/tmp/Board.kanban") let card = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: ItemID(rawValue: Ident.card1)) #expect(card.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)/\(Ident.card1)") #expect(!card.isLane) let lane = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: nil) #expect(lane.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)") #expect(lane.isLane) } } // MARK: - Counts, phrasing, and the confirmations @Suite("TrashModel ▸ phrasing") struct TrashModelPhrasingTests { @Test("Plural folding reads counts, singular and plural, cards and lanes and both") func pluralFolding() { #expect(TrashModel.phrase(.init(lanes: 0, cards: 41)) == "41 cards") #expect(TrashModel.phrase(.init(lanes: 0, cards: 1)) == "1 card") #expect(TrashModel.phrase(.init(lanes: 1, cards: 0)) == "1 lane") #expect(TrashModel.phrase(.init(lanes: 3, cards: 0)) == "3 lanes") #expect(TrashModel.phrase(.init(lanes: 2, cards: 3)) == "2 lanes and 3 cards") #expect(TrashModel.phrase(.init(lanes: 1, cards: 1)) == "1 lane and 1 card") #expect(TrashModel.phrase(.init()) == "nothing") } @Test("Entry counts split lane entries from card entries") func entryCounts() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z")) try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Gone", deleted: "2026-03-03T09:00:00Z")) let counts = TrashModel.counts(of: TrashModel.entries(of: try load(fixture))) #expect(counts == TrashModel.EntryCounts(lanes: 1, cards: 2)) #expect(counts.total == 3) #expect(!counts.isEmpty) } @Test("Delete Immediately names a sole item and folds several into counts") func purgePrompt() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "Fix login", deleted: "2026-03-01T09:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Ship it", deleted: "2026-03-02T09:00:00Z")) try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-03T09:00:00Z")) let model = try load(fixture) let sole = try #require(TrashModel.purgePrompt( for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: true)) #expect(sole.title == "Permanently delete \u{201C}Fix login\u{201D}?") #expect(sole.message == "This can\u{2019}t be undone.") #expect(sole.confirmTitle == "Delete") let several = try #require(TrashModel.purgePrompt( for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: model, unrecoverable: true)) #expect(several.title == "Permanently delete 2 cards?") // A lane in the set earns the sentence that matters most: the row says how many cards come // *back*, while a purge takes every card in the folder. let withLane = try #require(TrashModel.purgePrompt( for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.lane2)], in: model, unrecoverable: true)) #expect(withLane.title == "Permanently delete 1 lane and 1 card?") #expect(withLane.message.hasPrefix("Deleting a lane also deletes every card inside it.")) // m7-git: a board whose history keeps the content says so instead. let recoverable = try #require(TrashModel.purgePrompt( for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: false)) #expect(recoverable.message == "The board\u{2019}s history still has them.") // Nothing tombstoned in the set: no prompt, which is also the command's own refusal. #expect(TrashModel.purgePrompt(for: [ItemID(rawValue: Ident.lane1)], in: model, unrecoverable: true) == nil) #expect(TrashModel.purgePrompt(for: [], in: model, unrecoverable: true) == nil) } @Test("An untitled item is named by its rendering, never by an empty string") func untitledPrompt() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\norder: 1024\ndeleted: 2026-03-01T09:00:00Z\n---\nbody\n") let prompt = try #require(TrashModel.purgePrompt( for: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true)) #expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?") } @Test("Empty Trash names the whole trash's count and refuses on an empty one") func emptyTrashPrompt() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) #expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z")) let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true)) // Counts even for a small trash: the command is about the trash, not about an item. #expect(prompt.title == "Permanently delete 2 cards?") } } // MARK: - Menu validation @Suite("TrashModel ▸ validation") struct TrashModelValidationTests { private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Live")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed", deleted: "2026-03-01T09:00:00Z")) return fixture } @Test("The ⌘⌫ twins enable exactly one of themselves, by the selection's liveness side") func chordTwinsAreBinary() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let model = try load(fixture) let liveSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .live) #expect(TrashModel.canDelete(selection: liveSelection, in: model)) #expect(!TrashModel.canActOnTrash(selection: liveSelection, in: model)) let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed) #expect(!TrashModel.canDelete(selection: trashedSelection, in: model)) #expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model)) } @Test("Neither enables on an empty selection, or on one whose members have gone") func nothingToActOn() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let model = try load(fixture) #expect(!TrashModel.canDelete(selection: .empty, in: model)) #expect(!TrashModel.canActOnTrash(selection: ItemReferenceSet(ids: [], liveness: .trashed), in: model)) // A selection the next reload will drop: the id names nothing, so the item that would write // nothing does not look available. let stale = ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live) #expect(!TrashModel.canDelete(selection: stale, in: model)) } @Test("A selection whose side disagrees with the item's own flag enables neither") func sideMustMatch() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let model = try load(fixture) // The homogeneous-by-liveness invariant makes this unreachable through the UI, and the // reload rule ejects it if a foreign edit ever produces it — but the predicates must not // trust that, since they are what stands between a stale set and a write. let wrongSide = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .trashed) #expect(!TrashModel.canActOnTrash(selection: wrongSide, in: model)) #expect(!TrashModel.canDelete(selection: wrongSide, in: model)) } }