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 `/.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 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], in: .board) #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], in: .trash) #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], in: .board) #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], in: .trash) 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], .trash)) // 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) } /// **The card and lane context menus' ⌥-alternate** — Delete Immediately, routed through /// `requestBoardPurge` rather than through `requestPurge` (11-command-nexus.md ▸ Context menus' /// Card and Lane rows: "Delete — with Delete Immediately as its ⌥-alternate"). /// `purgeConfirmsThenActs`'s twin for the board side: same alert, same rule, a board card as the /// target instead of a trash one. @Test("The board-side ⌥-alternate raises the same alert, and purges the board card on confirm") func boardPurgeConfirmsThenActs() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let confirmations = TrashConfirmations() confirmations.requestBoardPurge(of: [card1], in: store) let pending = try #require(confirmations.pending) #expect(pending.prompt.title == "Permanently delete \u{201C}First\u{201D}?") #expect(pending.action == .purge([card1], .board)) // Nothing has happened yet — same alert, same rule. #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) confirmations.confirm(in: store) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(!fixture.exists(".trash/\(Ident.card1)"), "skips the trash — purged, not moved") #expect(confirmations.pending == nil) } /// A context menu names its target by where it was invoked, so a card row's Delete Immediately /// must purge the *clicked* card even while a different card is selected — `TrashMenuValidation /// Tests.contextMenuDeleteIgnoresTheSelection`'s claim, mirrored onto the board side. @Test("The board-side ⌥-alternate acts on its own target, not the standing selection") func boardPurgeIgnoresTheSelection() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let confirmations = TrashConfirmations() // A right-click on `card1` without first selecting it must still purge `card1`, never the // card the standing selection happens to hold (`CardFaceView.targetIDs`'s targeting rule). store.select([card2], in: .board) confirmations.requestBoardPurge(of: [card1], in: store) let pending = try #require(confirmations.pending) #expect(pending.action == .purge([card1], .board)) confirmations.confirm(in: store) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"), "the clicked card is gone") #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the selected card was never the subject") } /// `TrashModel.canDeleteImmediately` is cards only (`TrashValidationTests /// .canDeleteImmediatelyIsCardsOnly`: "a lane's delete is physical already … there is nothing for /// 'skip the trash' to mean on one"), and the lane row's alternate inherits that unchanged: it is /// wired per 11-command-nexus.md's Lane row, but presently inert on a lane-only target — the same /// posture File ▸ Delete Immediately already takes on a lane-only selection. @Test("A lane-only target raises no prompt — the alternate is still cards only") func boardPurgeIsStillCardsOnlyForALaneTarget() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let confirmations = TrashConfirmations() confirmations.requestBoardPurge(of: [lane1], in: store) #expect(confirmations.pending == nil) #expect(fixture.exists(Ident.lane1)) } /// 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) } } // MARK: - The menu-validation seams /// The trash's three 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`/`canDeleteImmediately` are /// pinned as pure functions 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 == .deleteTrashCards([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 == .deleteTrashCards([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(TrashModel.canDeleteImmediately(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)) } }