Delete Immediately is removed entirely (rulingae1dd96, Redesign card d40bfac1): the delete vocabulary is purely staged — board → trash, trash → permanent (confirmed on no-git boards), Empty Trash for bulk. Gone: File ▸ Delete Immediately (⌥⌘⌫) and its validation, both ⌥-alternate context rows (card + the permanently-disabled lane row), the VO custom action, BoardStore.deleteImmediately, TrashModel.canDeleteImmediately, TrashConfirmations' .purge action (zero surviving callers — trash-side Delete always used .deleteTrashCards), and the pinning tests. purgePrompt drops its now-single-purpose container parameter (.trash is the only surviving caller). BoardWriter.purgeItem survives — create-undo rollback still needs it — with its comment rewritten. README's trash paragraph drops the ⌥⌘⌫ sentence. The other 2026-07-30 rulings (2ec2c95registry freshness stamp,c741b02unified-log-as-coerce-consumer) required no code changes — already conformant. Both schemes 1844 tests / 318 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
324 lines
14 KiB
Swift
324 lines
14 KiB
Swift
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<ItemID> = [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<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 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
|
|
))
|
|
}
|
|
}
|