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

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

832 lines
34 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// `BoardStore`'s trash operations — Delete (staged by place), Delete Immediately, 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 {
!$0.hasPrefix("modified") && !$0.hasPrefix("order:")
}
}
/// 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 is physical
@MainActor
@Suite("BoardStore ▸ delete a lane")
struct DeleteLaneTests {
@Test("Deleting a lane removes the folder and its contents — nothing is trashed")
func laneDeleteIsPhysical() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.delete([lane3])
// 03-board-ui.md § Trash: "Cards only. Lanes are never trashed … deleting a lane deletes it,
// folder and contents, physically."
#expect(!fixture.exists(Ident.lane3))
#expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash")
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched")
#expect(store.banners.oneShots.isEmpty)
}
@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.deleteTrashCards([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.deleteTrashCards([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.deleteTrashCards([card1])
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - Delete Immediately and Empty Trash
@MainActor
@Suite("BoardStore ▸ purge")
struct PurgeTests {
@Test("Delete Immediately skips the trash from a lane")
func skipsTheTrashFromTheBoard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
store.deleteImmediately([card1])
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere")
#expect(store.selection.isEmpty)
}
@Test("Delete Immediately purges a card already in the trash")
func purgesFromTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash)
store.deleteImmediately([trashed])
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
#expect(fixture.exists(".trash/\(More.newer)"), "and only what it named")
}
@Test("A lane in the set is never purged — cards only")
func lanesAreNotPurged() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], in: .board)
store.deleteImmediately([lane3])
#expect(fixture.exists(Ident.lane3))
}
@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.deleteTrashCards([trashed])
store.deleteImmediately([newer])
store.emptyTrash()
// 13-native-undo.md ▸ Rules: "Permanently delete (Delete Immediately, 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; lanes return live in place")
func migrationMovesCardsAndResurrectsLanes() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
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:")))
// The lane stayed exactly where it was, key stripped — "resurrection is the safe direction,
// nothing is destroyed by migration".
#expect(fixture.exists(Ident.lane2))
#expect(!(try fixture.indexText(Ident.lane2).contains("deleted:")))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it")
#expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position")
}
@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 folded warning-tone row naming both halves")
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")
#expect(store.banners.losses.first?.message
== "Moved 2 cards to the trash and restored 'Retired' — 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 resurrected lane is an ordinary lane again")
}
@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"], lanes: [])
== "Moved 'Fix login' to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: [])
== "Moved 3 cards to the trash — they carried old deleted markers")
}
@Test("A lane reads as a restoration, which is what it is")
func lanesRead() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["Doing"])
== "Restored 'Doing' — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["A", "B"])
== "Restored 2 lanes — they carried old deleted markers")
}
@Test("Both halves fold into one sentence — one migration, one row")
func bothFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: ["D", "E"])
== "Moved 3 cards to the trash and restored 2 lanes — 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], lanes: [])
== "Moved an untitled item to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: []) == nil)
}
}
// MARK: - The confirmations
@MainActor
@Suite("TrashConfirmations")
struct TrashConfirmationsTests {
@Test("Delete Immediately raises the alert where the loss is real, and purges on confirm")
func purgeConfirmsThenActs() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([trashed], in: .trash)
confirmations.requestPurge(of: [trashed], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .purge([trashed]))
// Nothing has happened yet — the alert is what stands between the keystroke and the loss.
#expect(fixture.exists(".trash/\(Ident.indexless)"))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
#expect(confirmations.pending == nil)
// Idempotent: the binding's own dismissal fires an instant after the button.
confirmations.confirm(in: store)
}
/// 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 == .deleteTrashCards([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.requestPurge(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)
confirmations.requestPurge(of: [lane1], in: store)
#expect(confirmations.pending == nil)
}
}