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