Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+260 -372
View File
@@ -2,476 +2,364 @@ 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.
/// 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: the interesting inputs are `FieldValue<Date>` 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`.
/// 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: 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.
/// 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 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 card(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
}
private func live(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
}
private func ids(_ entries: [TrashEntry]) -> [String] {
entries.map(\.id.rawValue)
/// 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
}
// MARK: - The sort
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)
@Suite("TrashModel ▸ sort")
struct TrashModelSortTests {
// MARK: - The contents
@Test("Dated entries sort newest first, and lane entries interleave by their own stamp")
func newestFirstWithLanesInterleaved() throws {
let fixture = try WriterFixture()
/// **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() }
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 snapshot = try load(fixture)
let entries = TrashModel.entries(of: try load(fixture))
#expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1])
#expect(entries[1].isLaneEntry)
#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("Ties on the second break by folder name, ascending")
func tiesBreakByFolderName() throws {
let fixture = try WriterFixture()
@Test("Lanes are never in it, whatever a hand-editor nests in there")
func lanesAreNeverTrashed() throws {
let fixture = try makeBoard()
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))
// 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)
let entries = TrashModel.entries(of: try load(fixture))
#expect(ids(entries) == [Ident.card1, More.cardD, More.cardF])
#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 unparseable stamp sorts oldest — after every dated entry, however old")
func unparseableSortsOldest() throws {
@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(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"))
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
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])
#expect(try load(fixture).trash.isEmpty)
}
}
// MARK: - The ancestor walk and the returning count
// MARK: - ItemPath
@Suite("TrashModel ▸ contents")
struct TrashModelContentsTests {
/// 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 {
/// 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("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("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute")
func ownFlagUnderTombstonedLaneHasNoRow() throws {
@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<ItemID> = [laneA, card1, card2, cardD, cardE, cardF]
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))
#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 lane entry counts what Put Back returns — cards without their own flag")
func returningCountExcludesOwnFlaggedCards() throws {
@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)
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 row set's liveness side, in display order")
func pathsResolveByEffectiveLiveness() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let everything: Set<ItemID> = [
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 takes it off this 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 and the tombstoned lane the trash's two rows, in display
// order, lanes left to right and each lane before its cards. Nothing under `lane2` is here:
// its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them
// with it; Delete Immediately purges them with it).
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)/-"])
#expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty)
#expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty)
}
@Test("The trashed universe is exactly the trash's rows — one function, not two")
func trashedUniverseIsTheRowSet() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// The guarantee 02-architecture.md settles: "universe and rows are one function, never a
// broader set with a pointer-side subset". Anything a set may reference on the trashed side
// is something the quasi-lane draws.
let rows = Set(TrashModel.entries(of: model).map(\.id))
let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed)
#expect(trashed == rows)
// And the two universes therefore do **not** partition the board. `cardD` carries its own
// `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one;
// both render nowhere, so neither is on either side.
let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live)
for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] {
#expect(!rows.contains(hidden))
#expect(!trashed.contains(hidden))
#expect(!liveUniverse.contains(hidden))
#expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty)
#expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty)
}
// Paths are the same walk in folder form, so they name the same things one per row.
#expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count)
#expect(TrashModel.emptyTrashTargets(in: model).count == rows.count)
// And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a
// clean one.
#expect(TrashModel.isEmpty(model) == rows.isEmpty)
let clean = try WriterFixture()
defer { clean.tearDown() }
try clean.item("", Item.board)
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
let cleanModel = try load(clean)
#expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty)
#expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).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)
#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: - Counts, phrasing, and the confirmations
// 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<ItemID> = []
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 TrashModelPhrasingTests {
struct TrashPhrasingTests {
@Test("Plural folding reads counts, singular and plural, cards and lanes and both")
@Test("Plurals fold, and there is only one noun left to fold")
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")
#expect(TrashModel.phrase(1) == "1 card")
#expect(TrashModel.phrase(41) == "41 cards")
#expect(TrashModel.phrase(0) == "0 cards")
}
@Test("Entry counts split lane entries from card entries")
func entryCounts() throws {
let fixture = try WriterFixture()
@MainActor
@Test("A sole card is named; several fold into a count")
func purgePromptNamesOrCounts() throws {
let fixture = try makeBoard()
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 snapshot = 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}?")
for: [cardF], in: .trash, 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: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)],
in: model, unrecoverable: true))
for: [cardE, cardF], in: .trash, snapshot: snapshot, 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 {
@MainActor
@Test("Delete Immediately reads the same either side of the boundary")
func purgePromptSpansContainers() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let fromBoard = try #require(TrashModel.purgePrompt(
for: [card1], in: .board, snapshot: snapshot, unrecoverable: true
))
#expect(fromBoard.title == "Permanently delete \u{201C}First\u{201D}?",
"11 ▸ Delete Immediately skips the trash from anywhere")
}
@MainActor
@Test("A lane in the set contributes nothing — no purge path reaches one")
func lanesAreNeverPurged() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(TrashModel.purgePrompt(for: [laneA], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
}
@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(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")
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: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true))
for: [cardD], in: .trash, snapshot: snapshot, 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")
@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)], in: .trash, snapshot: snapshot, unrecoverable: true
) == nil)
#expect(TrashModel.purgePrompt(for: [], in: .board, 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(Ident.lane1, live(order: "1024", title: "Todo"))
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
try fixture.item(More.laneA, card(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"))
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?")
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
}
}
// MARK: - Menu validation
@MainActor
@Suite("TrashModel ▸ validation")
struct TrashModelValidationTests {
struct TrashValidationTests {
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 {
/// **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 model = try load(fixture)
let snapshot = 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))
#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))
let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed)
#expect(!TrashModel.canDelete(selection: trashedSelection, in: model))
#expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model))
#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
))
}
@Test("Neither enables on an empty selection, or on one whose members have gone")
func nothingToActOn() throws {
@Test("Delete Immediately is cards only, in either container")
func canDeleteImmediatelyIsCardsOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let snapshot = 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))
#expect(TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot
))
#expect(TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot
))
#expect(!TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot
), "a lane's delete is physical already — there is nothing for 'skip the trash' to mean")
#expect(!TrashModel.canDeleteImmediately(selection: .empty, in: snapshot))
}
}