Build the trash quasi-lane
Deletion becomes a two-stage, Finder-style story. File > Delete and plain Backspace tombstone the live selection; View > Show Trash (no chord — Shift-Cmd-T stays with the system's tab bar) reveals the quasi-lane: trailing, one fixed width unit consumed only while shown, hatched dimmed header, count badge, no new-card button, exempt from resize and reorder alike. Its contents are a pure view over the snapshot — the deterministic sort (deleted newest first, folder-name ties, unparseable stamps oldest) interleaves card rows with a tombstoned lane's single entry, whose count names what Put Back returns; the ancestor walk is absolute, so an own-flag card beneath a tombstoned lane has no row and recovery is deliberately two steps. Put Back twins Delete on Cmd-Backspace with validation enabling exactly one; restore fidelity is byte-perfect because nothing ever moved. Delete Immediately confirms exactly where loss is real (every board is mode-none today; the predicate names the git carve-out for m7), Empty Trash always confirms with the true whole-board count, and dragging a tombstoned card onto a live lane restores it there — positional drops and cross-board locality arrive with m5's machinery. The banner's delete phrasing drops "move to the trash" per the naming constraint: board deletion says Delete, "Move to Trash" stays reserved for the system Trash. 47 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
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 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> = [
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user