Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1020 lines
43 KiB
Swift
1020 lines
43 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `BoardStore`'s trash operations — Delete (staged by place), Empty Trash, and the legacy tombstone
|
|
/// migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md § Deletion).
|
|
///
|
|
/// These drive a **real store over a real temp board** and then read the **raw bytes** back, never
|
|
/// the app's own read path, like every other write suite here: the interesting claims are about the
|
|
/// files — which folder moved, which rank it landed under, which stamps followed it, and what came
|
|
/// through byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
|
///
|
|
/// **Restore has no suite here**, and its absence is a finding: "restoring is an ordinary move out …
|
|
/// there is no restore-specific machinery and no Put Back" (03 § Trash), so it is tested where the
|
|
/// ordinary moves are (`DragWriteTests ▸ restore by move-out`).
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// One more literal identity than `Ident` offers — the trash needs two residents to have an order.
|
|
private enum More {
|
|
static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
}
|
|
|
|
/// A card already sitting in `<root>/.trash/` — an ordinary card in a special place, with an unknown
|
|
/// key so the verbatim-preservation claims have something to preserve.
|
|
private func trashResident(order: String, title: String) -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
project: lanework # agent overlay
|
|
created: 2026-01-01T09:00:00Z
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
|
|
/// A legacy tombstone — the only thing in this file that still writes a `deleted:` key, because the
|
|
/// migration is the one code path that still reads one.
|
|
private func legacyTombstone(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
project: lanework # agent overlay
|
|
created: 2026-01-01T09:00:00Z
|
|
deleted: \(deleted)
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
|
|
/// Three lanes — two cards in the first, one in the second, one in the third — plus two cards
|
|
/// already in the board's trash.
|
|
@MainActor
|
|
private func makeBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
|
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
|
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed"))
|
|
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
|
|
return fixture
|
|
}
|
|
|
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
|
private let card1 = ItemID(rawValue: Ident.card1)
|
|
private let card2 = ItemID(rawValue: Ident.card2)
|
|
private let card3 = ItemID(rawValue: Ident.card3)
|
|
private let card4 = ItemID(rawValue: Ident.card4)
|
|
private let trashed = ItemID(rawValue: Ident.indexless)
|
|
private let newer = ItemID(rawValue: More.newer)
|
|
|
|
/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
|
|
/// through a delete byte-for-byte, in order.
|
|
private func untouchedLines(_ text: String) -> [Substring] {
|
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
|
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
|
|
!$0.hasPrefix("modified") && !$0.hasPrefix("order:") && !$0.hasPrefix("kind:")
|
|
}
|
|
}
|
|
|
|
/// A file's bytes and mtime — "minimal touch" stated the way `WriteFidelityTests` states it.
|
|
private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
|
|
let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
|
|
let data = try Data(contentsOf: indexURL)
|
|
let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
|
|
guard let modified = attributes[.modificationDate] as? Date else {
|
|
Issue.record("no modification date for \(relativePath)")
|
|
return (data, .distantPast)
|
|
}
|
|
return (data, modified)
|
|
}
|
|
|
|
private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
|
|
try BoardLoader.load(boardRoot: fixture.root).model
|
|
}
|
|
|
|
private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument {
|
|
try FrontmatterDocument.parse(fixture.indexText(relativePath))
|
|
}
|
|
|
|
@MainActor
|
|
private func reload(_ store: BoardStore) async {
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
}
|
|
|
|
// MARK: - Delete: a card is a move into .trash/
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ delete a card")
|
|
struct DeleteCardTests {
|
|
|
|
@Test("Deleting a card moves its folder into .trash/ and writes no key at all")
|
|
func deleteIsAMove() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
store.delete([card1])
|
|
|
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
|
let after = try fixture.indexText(".trash/\(Ident.card1)")
|
|
#expect(!after.contains("deleted:"), "the tombstone model is retired — no key is ever written")
|
|
// Everything but the rank and the stamps rides along byte-for-byte, unknown keys and their
|
|
// comments included: a move never reads below the folder it moves.
|
|
#expect(untouchedLines(after) == untouchedLines(before))
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("The move stamps modified — the deliberate exception to moves-don't-stamp")
|
|
func deleteStamps() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.delete([card1])
|
|
|
|
// 01-storage-format.md § Deletion: "The move stamps `modified` (deletion is an edit to the
|
|
// card's story — the deliberate exception to moves-don't-stamp), which is what a future
|
|
// age-based auto-purge will read."
|
|
#expect(try document(fixture, ".trash/\(Ident.card1)").modified.value != nil)
|
|
}
|
|
|
|
@Test("Entry is at the top: the rank is minted above the current topmost")
|
|
func entryIsAtTheTop() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
// The trash's current top is `newer` at 512.
|
|
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed])
|
|
|
|
store.delete([card1])
|
|
|
|
let landed = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
|
|
#expect(landed < 512, "03 ▸ Trash: every arrival mints an `order` rank above the current top")
|
|
#expect(try loaded(fixture).trash.map(\.id) == [card1, newer, trashed],
|
|
"newest-first falls out of ordinary ranks — no timestamp sort")
|
|
}
|
|
|
|
@Test("A multi-card delete is one bracket, each arrival above the one before it")
|
|
func batchLandsNewestOnTop() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.delete([card1, card2])
|
|
|
|
let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
|
|
let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value)
|
|
#expect(second < first)
|
|
#expect(try loaded(fixture).trash.map(\.id) == [card2, card1, newer, trashed])
|
|
}
|
|
|
|
@Test("The selection moves to the successor sibling, immediately")
|
|
func successorSelection() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([card1], in: .board)
|
|
|
|
store.delete([card1])
|
|
|
|
// Computed from the pre-write snapshot and applied at once: a second ⌫ pressed before the
|
|
// watcher rounds the first one back must already have somewhere to land.
|
|
#expect(store.selection.ids == [card2])
|
|
#expect(store.selection.container == .board)
|
|
}
|
|
|
|
@Test("An emptied lane leaves nothing selected")
|
|
func emptiedContainerClears() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([card3], in: .board)
|
|
|
|
store.delete([card3])
|
|
|
|
#expect(store.selection.isEmpty)
|
|
}
|
|
|
|
@Test("Drop-on-trash and the card window's button write exactly what ⌫ writes")
|
|
func everyGestureWritesTheSameThing() throws {
|
|
let byKey = try makeBoard()
|
|
defer { byKey.tearDown() }
|
|
let byDrop = try makeBoard()
|
|
defer { byDrop.tearDown() }
|
|
let byButton = try makeBoard()
|
|
defer { byButton.tearDown() }
|
|
|
|
try BoardStore(rootURL: byKey.root).delete([card1])
|
|
try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1])
|
|
try BoardStore(rootURL: byButton.root).deleteCard(card1)
|
|
|
|
let keyed = try byKey.indexText(".trash/\(Ident.card1)")
|
|
#expect(untouchedLines(try byDrop.indexText(".trash/\(Ident.card1)")) == untouchedLines(keyed))
|
|
#expect(untouchedLines(try byButton.indexText(".trash/\(Ident.card1)")) == untouchedLines(keyed))
|
|
// Same rank, too: all three mint at the head of the same trash.
|
|
let rank = try document(byKey, ".trash/\(Ident.card1)").order.value
|
|
#expect(try document(byDrop, ".trash/\(Ident.card1)").order.value == rank)
|
|
#expect(try document(byButton, ".trash/\(Ident.card1)").order.value == rank)
|
|
}
|
|
|
|
@Test("Only ⌫ picks a successor — a drag and a card window's button leave the selection alone")
|
|
func onlyTheKeystrokePicksASuccessor() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([card3], in: .board)
|
|
|
|
store.deleteByDrag(cardIDs: [card1])
|
|
#expect(store.selection.ids == [card3], "a drag's run need not be the selection at all")
|
|
|
|
store.deleteCard(card2)
|
|
#expect(store.selection.ids == [card3])
|
|
}
|
|
|
|
@Test("An id that names nothing writes nothing and opens no bracket")
|
|
func vanishedTargetsAreSkipped() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
store.delete([ItemID(rawValue: "44444444-4444-4444-4444-444444444444")])
|
|
store.delete([])
|
|
// A card that is in the trash is not on the board side, so a board delete never finds it.
|
|
store.delete([trashed])
|
|
|
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
|
|
#expect(fixture.exists(".trash/\(Ident.indexless)"))
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete: a lane takes the same move
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ delete a lane")
|
|
struct DeleteLaneTests {
|
|
|
|
/// 03-board-ui.md § Trash, re-ruled 2026-07-29: "deleting a lane moves its folder — subtree
|
|
/// intact — into `.trash/`, exactly as a card moves". The freight is inside the folder that
|
|
/// moved, so it is not filed separately and not surfaced.
|
|
@Test("Deleting a lane moves the folder into .trash/, subtree intact and opaque")
|
|
func laneDeleteIsAMoveIntoTheTrash() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.delete([lane3])
|
|
|
|
#expect(!fixture.exists(Ident.lane3))
|
|
#expect(fixture.exists(".trash/\(Ident.lane3)"))
|
|
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card4)"), "its cards ride along inside it")
|
|
#expect(!fixture.exists(".trash/\(Ident.card4)"), "and are never filed as trash cards")
|
|
|
|
let model = try loaded(fixture)
|
|
#expect(model.trash.map(\.id) == [newer, trashed], "the trash's cards are unchanged")
|
|
#expect(model.trashedLanes.map(\.id.rawValue) == [Ident.lane3])
|
|
#expect(model.trashedLanes.first?.heldCards == 1)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash): the
|
|
/// ladder the rank is minted against is the whole container, cards and lane rows alike.
|
|
@Test("The lane lands at the top of the trash, above every existing entry")
|
|
func laneLandsOnTop() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let topBefore = try #require(loaded(fixture).trash.map(\.order).min())
|
|
|
|
store.delete([lane3])
|
|
let landed = try #require(loaded(fixture).trashedLanes.first?.order)
|
|
#expect(landed < topBefore)
|
|
|
|
// And the next card delete mints above *that* — the lane row is in the ladder the store
|
|
// mints against, which is the whole container rather than one array of it. The reload is
|
|
// what puts the new row in the snapshot; without it the store is still holding the
|
|
// pre-delete picture, as it is for two of any deletes in a row.
|
|
await reload(store)
|
|
store.delete([card1])
|
|
let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order)
|
|
#expect(card < landed)
|
|
}
|
|
|
|
/// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash).
|
|
@Test("A lane delete raises no confirmation and needs none")
|
|
func laneDeleteIsNotConfirmed() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
store.select([lane3], in: .board)
|
|
|
|
confirmations.requestDelete(in: store)
|
|
|
|
#expect(confirmations.pending == nil)
|
|
#expect(fixture.exists(".trash/\(Ident.lane3)"), "it went straight through")
|
|
}
|
|
|
|
@Test("The selection moves to the successor lane")
|
|
func laneSuccessor() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([lane2], in: .board)
|
|
|
|
store.delete([lane2])
|
|
|
|
#expect(store.selection.ids == [lane3])
|
|
}
|
|
|
|
@Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes")
|
|
func lanesWinAMixedSet() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.delete([lane3, card1])
|
|
|
|
#expect(!fixture.exists(Ident.lane3))
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"),
|
|
"the card is left alone rather than earning a second undo step for one keystroke")
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete: staged by place
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ Delete is staged by place")
|
|
struct StagedDeleteTests {
|
|
|
|
@Test("A board selection moves to the trash; a trash selection deletes permanently")
|
|
func stagingFollowsTheContainer() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.select([card1], in: .board)
|
|
store.deleteSelection()
|
|
#expect(fixture.exists(".trash/\(Ident.card1)"), "on the board it moves to the trash")
|
|
|
|
store.select([trashed], in: .trash)
|
|
store.deleteSelection()
|
|
#expect(!fixture.exists(".trash/\(Ident.indexless)"), "in the trash it removes the folder")
|
|
}
|
|
|
|
@Test("A permanent delete walks the trash's own successor, so repeated ⌫ walks the column")
|
|
func trashSuccessorWalksTheColumn() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
// The column's order is [newer, trashed].
|
|
store.select([newer], in: .trash)
|
|
|
|
store.deleteTrashEntries([newer])
|
|
|
|
#expect(store.selection.ids == [trashed])
|
|
#expect(store.selection.container == .trash)
|
|
}
|
|
|
|
@Test("A permanent delete of the last card clears the selection")
|
|
func emptiedTrashClears() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([newer, trashed], in: .trash)
|
|
|
|
store.deleteTrashEntries([newer, trashed])
|
|
|
|
#expect(try loaded(fixture).trash.isEmpty)
|
|
#expect(store.selection.isEmpty)
|
|
}
|
|
|
|
@Test("A permanent delete never reaches a board card, whatever the ids say")
|
|
func trashDeleteCannotReachTheBoard() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.deleteTrashEntries([card1])
|
|
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty Trash
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ purge")
|
|
struct PurgeTests {
|
|
|
|
@Test("Empty Trash removes every card in the container, and leaves strays verbatim")
|
|
func emptyTrashIsWholeScope() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.file(".trash/notes.txt", Data("hand-written".utf8))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.emptyTrash()
|
|
|
|
#expect(try loaded(fixture).trash.isEmpty)
|
|
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
|
#expect(!fixture.exists(".trash/\(More.newer)"))
|
|
#expect(FileManager.default.fileExists(atPath: fixture.url(".trash").appendingPathComponent("notes.txt").path),
|
|
"stray tolerance does not stop applying because the folder is the app's")
|
|
}
|
|
|
|
@Test("Empty Trash clears a trash-side selection and leaves a board one alone")
|
|
func emptyTrashAndTheSelection() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.select([card1], in: .board)
|
|
store.emptyTrash()
|
|
#expect(store.selection.ids == [card1], "the board it names is still right there")
|
|
}
|
|
|
|
@Test("Emptying an already-empty trash writes nothing")
|
|
func emptyTrashOnNothing() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.emptyTrash()
|
|
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("No purge registers an undo step: purgeIsUnrecoverable stays true")
|
|
func purgesAreUnrecoverable() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
#expect(store.purgeIsUnrecoverable)
|
|
|
|
store.select([trashed], in: .trash)
|
|
store.deleteTrashEntries([trashed])
|
|
store.deleteTrashEntries([newer])
|
|
store.emptyTrash()
|
|
|
|
// 13-native-undo.md ▸ Rules: "Permanently delete (the trash's Delete, Empty Trash) …
|
|
// the confirm *is* the safety." A stack entry here would be a promise the filesystem
|
|
// cannot keep.
|
|
#expect(!history.canUndo)
|
|
}
|
|
}
|
|
|
|
// MARK: - The legacy tombstone migration
|
|
|
|
/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never
|
|
/// destroy" — the store-side scheduling, which is `relocateLooseCardFiles`' twin in every
|
|
/// mechanical respect.
|
|
@MainActor
|
|
@Suite("BoardStore ▸ the legacy tombstone migration")
|
|
struct StoreTombstoneMigrationTests {
|
|
|
|
/// A board an older version wrote: two tombstoned cards under a live lane, one tombstoned lane
|
|
/// with a live card inside it, and a board-level key that means nothing.
|
|
private func makeLegacyBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
|
|
try fixture.item(
|
|
"\(Ident.lane1)/\(Ident.card2)",
|
|
legacyTombstone(order: "2048", title: "Older", deleted: "2026-03-01T09:00:00Z")
|
|
)
|
|
try fixture.item(
|
|
"\(Ident.lane1)/\(Ident.card3)",
|
|
legacyTombstone(order: "3072", title: "Newer", deleted: "2026-03-05T09:00:00Z")
|
|
)
|
|
try fixture.item(Ident.lane2, legacyTombstone(order: "2048", title: "Retired"))
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along"))
|
|
return fixture
|
|
}
|
|
|
|
@Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to")
|
|
func migrationMovesCardsAndLeavesLanesAlone() throws {
|
|
let fixture = try makeLegacyBoard()
|
|
defer { fixture.tearDown() }
|
|
let laneBefore = try fixture.indexText(Ident.lane2)
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
// The cards moved, and their keys went with the move.
|
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
|
|
#expect(fixture.exists(".trash/\(Ident.card2)"))
|
|
#expect(fixture.exists(".trash/\(Ident.card3)"))
|
|
#expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
|
|
#expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:")))
|
|
|
|
// "A lane carrying `deleted:` simply loads live with the key ignored — no migration
|
|
// machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are
|
|
// untouched, `modified` included, and it renders as an ordinary lane.
|
|
#expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written")
|
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
|
|
}
|
|
|
|
@Test("Cards migrate oldest-first, so the newest deletion ends up on top")
|
|
func migrationOrderIsOldestFirst() throws {
|
|
let fixture = try makeLegacyBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
// Every arrival mints above the current top, so migrating oldest-first reproduces the
|
|
// newest-first column the tombstone model's timestamp sort used to render.
|
|
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Newer", "Older"])
|
|
}
|
|
|
|
@Test("The order is deterministic when the stamps are missing or unparseable")
|
|
func undatedSortsOldest() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item(
|
|
"\(Ident.lane1)/\(Ident.card1)",
|
|
legacyTombstone(order: "1024", title: "Corrupt", deleted: "not-a-date")
|
|
)
|
|
try fixture.item(
|
|
"\(Ident.lane1)/\(Ident.card2)",
|
|
legacyTombstone(order: "2048", title: "Dated", deleted: "2026-03-01T09:00:00Z")
|
|
)
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
// "A corrupt stamp must not outrank fresh deletions for the trash's most prominent rows":
|
|
// undated sorts oldest, so it migrates first and ends up *below* the dated one.
|
|
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Dated", "Corrupt"])
|
|
}
|
|
|
|
@Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged")
|
|
func boardLevelKeyIsLeftAlone() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", """
|
|
---
|
|
schema: 1
|
|
title: Board
|
|
deleted: 2026-03-03T09:00:00Z
|
|
---
|
|
Board body.
|
|
|
|
""")
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
#expect(try fixture.indexText("").contains("deleted:"), "preserved verbatim")
|
|
#expect(store.banners.losses.isEmpty, "and nothing to announce")
|
|
}
|
|
|
|
@Test("The notice is one warning-tone row, and names only what was moved")
|
|
func theNotice() throws {
|
|
let fixture = try makeLegacyBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
#expect(store.banners.losses.count == 1, "one migration, one row")
|
|
// No lane clause: nothing happened to the lane, so a row claiming otherwise would announce
|
|
// an act the app did not perform.
|
|
#expect(store.banners.losses.first?.message
|
|
== "Moved 2 cards to the trash — they carried old deleted markers")
|
|
}
|
|
|
|
@Test("A board with nothing legacy migrates nothing and says nothing")
|
|
func nothingToMigrate() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
#expect(store.banners.losses.isEmpty)
|
|
#expect(try loaded(fixture).trash.count == 2, "the existing trash is not disturbed")
|
|
}
|
|
|
|
@Test("The read-only lock defers it, and remembers nothing — the next attempt is a fresh one")
|
|
func theLockDefers() throws {
|
|
let fixture = try makeLegacyBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.enterVanishedRootLock()
|
|
|
|
store.migrateLegacyTombstones()
|
|
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "01: deferred under any read-only lock")
|
|
#expect(store.banners.losses.isEmpty)
|
|
}
|
|
|
|
/// The loop guard `relocateLooseCardFiles` documents, read for this migration: after a success
|
|
/// the walk finds nothing and the memo clears; a second call against the *same* unchanged picture
|
|
/// never re-attempts.
|
|
@Test("It cannot hot-loop: a second call against the same picture writes nothing")
|
|
func theLoopGuard() throws {
|
|
let fixture = try makeLegacyBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.migrateLegacyTombstones()
|
|
let after = try stat(fixture, ".trash/\(Ident.card2)")
|
|
store.banners.dismissAllDismissableRows()
|
|
|
|
// The store's snapshot still reports the same legacy tombstones (the reload has not landed),
|
|
// so an unguarded second call would migrate a card that has already moved — and fail.
|
|
store.migrateLegacyTombstones()
|
|
|
|
#expect(try stat(fixture, ".trash/\(Ident.card2)") == after)
|
|
#expect(store.banners.losses.isEmpty)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("A successful reload clears the memo and the pending work together")
|
|
func theReloadClosesTheWindow() async throws {
|
|
let fixture = try makeLegacyBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
#expect(!store.legacyTombstones.isEmpty)
|
|
|
|
store.migrateLegacyTombstones()
|
|
await reload(store)
|
|
|
|
// The window closes per board on the first successful migration write: no key is left to
|
|
// read, so the channel empties and stays empty.
|
|
#expect(store.legacyTombstones.isEmpty)
|
|
#expect(store.snapshot.trash.count == 2)
|
|
#expect(store.snapshot.lanes.count == 2, "the key-carrying lane was always an ordinary lane")
|
|
}
|
|
|
|
@Test("It is armed by the reload seam, exactly like the loose-file relocation")
|
|
func theReloadArmsIt() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// The tombstone arrives after the store opened — an older version's board pulled in by a
|
|
// sync, or a hand edit.
|
|
try fixture.item(
|
|
"\(Ident.lane1)/\(Ident.card1)",
|
|
legacyTombstone(order: "1024", title: "Legacy")
|
|
)
|
|
await reload(store)
|
|
|
|
#expect(fixture.exists(".trash/\(Ident.card1)"), "the reload that saw it is the reload that fixed it")
|
|
#expect(store.banners.losses.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - The banner's phrasing
|
|
|
|
@Suite("BannerCenter ▸ the migration notice")
|
|
struct MigrationNoticeTests {
|
|
|
|
@Test("One card names it; several fold to a count")
|
|
func cardsFold() {
|
|
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"])
|
|
== "Moved 'Fix login' to the trash — it carried an old deleted marker")
|
|
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"])
|
|
== "Moved 3 cards to the trash — they carried old deleted markers")
|
|
}
|
|
|
|
@Test("An untitled item reads as a rendering, and nothing migrated is not news")
|
|
func edges() {
|
|
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil])
|
|
== "Moved an untitled item to the trash — it carried an old deleted marker")
|
|
#expect(BannerCenter.migratedTombstonesMessage(cards: []) == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The confirmations
|
|
|
|
@MainActor
|
|
@Suite("TrashConfirmations")
|
|
struct TrashConfirmationsTests {
|
|
|
|
/// 03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent … Both confirm exactly
|
|
/// where the loss is real."
|
|
@Test("The trash's own Delete confirms; the board's goes straight through")
|
|
func deleteIsConfirmedOnlyInTheTrash() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
|
|
// A board selection is recoverable — the trash itself, and undo — so no alert.
|
|
store.select([card1], in: .board)
|
|
confirmations.requestDelete(in: store)
|
|
#expect(confirmations.pending == nil)
|
|
#expect(fixture.exists(".trash/\(Ident.card1)"), "it went straight through")
|
|
|
|
// A trash selection is the permanent one.
|
|
store.select([trashed], in: .trash)
|
|
confirmations.requestDelete(in: store)
|
|
let pending = try #require(confirmations.pending)
|
|
#expect(pending.action == .deleteTrashEntries([trashed]))
|
|
#expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet")
|
|
|
|
confirmations.confirm(in: store)
|
|
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
|
}
|
|
|
|
@Test("Cancelling dismisses and writes nothing")
|
|
func cancelWritesNothing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
store.select([trashed], in: .trash)
|
|
|
|
confirmations.requestTrashDelete(of: [trashed], in: store)
|
|
confirmations.cancel()
|
|
|
|
#expect(confirmations.pending == nil)
|
|
#expect(fixture.exists(".trash/\(Ident.indexless)"))
|
|
}
|
|
|
|
@Test("Empty Trash always confirms, and its scope is the whole trash")
|
|
func emptyTrashConfirms() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
|
|
confirmations.requestEmptyTrash(in: store)
|
|
|
|
let pending = try #require(confirmations.pending)
|
|
#expect(pending.action == .emptyTrash)
|
|
#expect(pending.prompt.title == "Permanently delete 2 cards?")
|
|
|
|
confirmations.confirm(in: store)
|
|
#expect(try loaded(fixture).trash.isEmpty)
|
|
}
|
|
|
|
@Test("No command raises an alert with nothing to act on")
|
|
func nothingToConfirm() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
|
|
confirmations.requestEmptyTrash(in: store)
|
|
#expect(confirmations.pending == nil)
|
|
|
|
// A board lane names nothing in the trash — the trash-side Delete's own refusal.
|
|
confirmations.requestTrashDelete(of: [lane1], in: store)
|
|
#expect(confirmations.pending == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The menu-validation seams
|
|
|
|
/// The trash's two File-menu rows, validated as predicates rather than as menu items — 11-command
|
|
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
|
|
///
|
|
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled(…)` per row over these
|
|
/// answers; what is worth pinning is the answers. `TrashModel.canDelete` is pinned as a pure function
|
|
/// in `TrashModelTests`; this suite covers the two seams that need a live store — Empty Trash's scope,
|
|
/// and the staging a ⌘⌫ actually performs.
|
|
@MainActor
|
|
@Suite("The trash's menu validation")
|
|
struct TrashMenuValidationTests {
|
|
|
|
/// 11-command-nexus.md: "Board window, trash shown and non-empty (whole-trash scope,
|
|
/// search-independent)"; 03 § Trash: "menu validation's 'non-empty' reads `.trash/`, not the
|
|
/// filtered view".
|
|
@Test("Empty Trash needs the column shown and the container non-empty — never the filtered view")
|
|
func emptyTrashValidation() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Hidden, the trash "is invisible to every gesture" — the command included.
|
|
#expect(!store.canEmptyTrash)
|
|
|
|
store.transient.isTrashVisible = true
|
|
#expect(store.canEmptyTrash)
|
|
|
|
// A query that hides every trash card leaves it enabled: the scope is the container, not
|
|
// what is on screen.
|
|
store.searchQuery = "zzzz-nothing-matches"
|
|
#expect(store.searchFilter.visibleIDs(in: store.snapshot, container: .trash).isEmpty)
|
|
#expect(store.canEmptyTrash)
|
|
|
|
// And the confirmation still names the true count, for the same reason.
|
|
let prompt = try #require(TrashModel.emptyTrashPrompt(in: store.snapshot, unrecoverable: true))
|
|
#expect(prompt.title == "Permanently delete 2 cards?")
|
|
}
|
|
|
|
@Test("An empty container disables it however visible the column is")
|
|
func emptyTrashNeedsCards() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.transient.isTrashVisible = true
|
|
|
|
#expect(!store.canEmptyTrash)
|
|
}
|
|
|
|
/// **One Delete, staged by place** — 04-interactions.md ▸ The map: "File ▸ Delete is the chord's
|
|
/// only owner — no twin menu items, no shared-equivalent routing". Put Back's ⌘⌫ twin is retired,
|
|
/// so exactly one predicate enables the row and the selection's *container* decides which write
|
|
/// it performs.
|
|
@Test("Delete is one enabled row on both sides, and the container picks the write")
|
|
func deleteIsOneRowStagedByPlace() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
|
|
store.select([card1], in: .board)
|
|
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
|
confirmations.requestDelete(in: store)
|
|
// The board staging: a move, no alert, the card now in the trash.
|
|
#expect(confirmations.pending == nil)
|
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
|
|
|
store.select([trashed], in: .trash)
|
|
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
|
confirmations.requestDelete(in: store)
|
|
// The trash staging: permanent, and behind the alert.
|
|
let pending = try #require(confirmations.pending)
|
|
#expect(pending.action == .deleteTrashEntries([trashed]))
|
|
}
|
|
|
|
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
|
|
/// the clicked card even while a *board* selection stands — the case a selection-reading path
|
|
/// would silently no-op on (`TrashConfirmations.requestTrashDelete`).
|
|
@Test("The trash card's context-menu Delete acts on its own target, not on the selection")
|
|
func contextMenuDeleteIgnoresTheSelection() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let confirmations = TrashConfirmations()
|
|
store.select([card1], in: .board)
|
|
|
|
confirmations.requestTrashDelete(of: [trashed], in: store)
|
|
|
|
let pending = try #require(confirmations.pending)
|
|
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
|
|
#expect(pending.action == .deleteTrashEntries([trashed]))
|
|
|
|
confirmations.confirm(in: store)
|
|
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
|
// The board selection was never the subject and is untouched on disk.
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Everything edit-shaped refuses a trash selection
|
|
|
|
/// 04-interactions.md ▸ The trash: "Everything edit-shaped is disabled on trash selections — Open
|
|
/// Card, Rename, Style…". Each of the three answers with one expression used for both its `disabled`
|
|
/// state and its action, which is what this suite drives (`BoardStore.openCardTarget`,
|
|
/// `.renameTarget`, `.boardStyleTarget`).
|
|
@MainActor
|
|
@Suite("Edit-shaped commands on a trash selection")
|
|
struct TrashGrammarExclusionTests {
|
|
|
|
@Test("Open Card takes a sole board card and nothing else")
|
|
func openRefusesTheTrash() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.select([card1], in: .board)
|
|
#expect(store.openCardTarget == card1)
|
|
|
|
// "Trash cards don't open — double-click stops at selection; move it out first" (03 § Trash).
|
|
store.select([trashed], in: .trash)
|
|
#expect(store.openCardTarget == nil)
|
|
store.select([trashed, newer], in: .trash)
|
|
#expect(store.openCardTarget == nil)
|
|
|
|
// A lane and a multi-selection refuse too — a card window is tied to one card.
|
|
store.select([lane1], in: .board)
|
|
#expect(store.openCardTarget == nil)
|
|
store.select([card1, card2], in: .board)
|
|
#expect(store.openCardTarget == nil)
|
|
}
|
|
|
|
@Test("Rename takes a sole board item, card or lane, and never a trash card")
|
|
func renameRefusesTheTrash() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.select([card1], in: .board)
|
|
#expect(store.renameTarget?.id == card1)
|
|
#expect(store.renameTarget?.title == "First")
|
|
store.select([lane1], in: .board)
|
|
#expect(store.renameTarget?.id == lane1)
|
|
|
|
store.select([trashed], in: .trash)
|
|
#expect(store.renameTarget == nil)
|
|
}
|
|
|
|
/// The one with a fall-through worth guarding: an empty selection styles *the board*, so a trash
|
|
/// selection has to disable rather than land there — "quietly restyling the board because the
|
|
/// user had a trashed card selected would be the silent retarget 03 forbids".
|
|
@Test("Style… falls through to the board on an empty selection, but a trash selection disables it")
|
|
func styleRefusesTheTrashWithoutFallingThrough() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.clearSelection()
|
|
#expect(store.boardStyleTarget == .board)
|
|
|
|
store.select([card1], in: .board)
|
|
#expect(store.boardStyleTarget == .items([card1]))
|
|
|
|
store.select([trashed], in: .trash)
|
|
#expect(store.boardStyleTarget == nil)
|
|
}
|
|
|
|
/// The trash's Delete is not edit-shaped and neither is Reveal, so both stay available — the two
|
|
/// rows 11-command-nexus.md gives a trash card, and no others.
|
|
@Test("Delete and Reveal are what a trash selection keeps")
|
|
func whatTheTrashKeeps() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([trashed], in: .trash)
|
|
|
|
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
|
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
|
|
.map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
|
|
}
|
|
}
|
|
|
|
// MARK: - Put Back is gone
|
|
|
|
/// 03-board-ui.md § Trash: "**No Put Back** (settled) … Restoring is an ordinary move out". The
|
|
/// retirement is mostly a compile-time fact — there is no `putBack` on the store, no restore write on
|
|
/// the Writer, and no second ⌫-titled menu row — so what is left to state at runtime is that a
|
|
/// restore registers, phrases and writes as the ordinary move it now is.
|
|
@MainActor
|
|
@Suite("Put Back is retired")
|
|
struct PutBackRetirementTests {
|
|
|
|
@Test("The undo vocabulary has no restore verb — a restore is a Move")
|
|
func noRestoreVerb() {
|
|
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Restore"))
|
|
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Put Back"))
|
|
// What a drag-out or a ⌘X/⌘V restore actually reads as in the Edit menu.
|
|
#expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
|
|
}
|
|
|
|
/// One predicate for both stagings, because there is only one item: the mirror-image pair that
|
|
/// existed to make two ⌘⌫ twins enable exactly one of themselves retired with Put Back.
|
|
@Test("One Delete predicate covers both containers")
|
|
func oneDeletePredicate() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try loaded(fixture)
|
|
|
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
|
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [trashed], container: .trash), in: snapshot))
|
|
}
|
|
}
|