Files
lanework/KanbanTests/TrashModelTests.swift
T
rzen 7be4eec9fd Unify the trashed universe on the trash rows
02 rules the trashed side has exactly one definition: the set with
trash rows — a card carrying its own deleted: under a tombstoned lane
is in neither universe, so an anchor or selection can never survive on
an item that renders nowhere. The membership rule now lives once, in
Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries,
paths, and emptyTrashTargets all derive from it — the old
lane-OR-card logic that admitted subsumed cards to the trashed side is
gone, and the two universes deliberately no longer partition the
board. Put Back, Delete Immediately, and Empty Trash outcomes are
unchanged: a tombstoned lane still moves and purges whole, its nested
tombstones with it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 07:23:18 -04:00

478 lines
24 KiB
Swift

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<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`.
// 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 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)
}
}
// 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))
}
}