import Foundation import Testing @testable import Kanban /// `BoardStore`'s trash operations — Delete, Put Back, Delete Immediately, Empty Trash, and /// drag-to-restore (03-board-ui.md § Trash). /// /// 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 key lands, which stamps follow it, which folders survive, and what comes through /// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Fixtures private func tombstoned(order: String, title: String) -> String { """ --- schema: 1 title: \(title) order: \(order) project: lanework # agent overlay created: 2026-01-01T09:00:00Z deleted: 2026-03-03T09:00:00Z --- \(title) body. """ } /// Two live lanes with two cards each, plus one already-tombstoned card and one already-tombstoned /// lane holding a live card and an own-flagged one — enough for every rule in this file. @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)", tombstoned(order: "2048", title: "Trashed")) 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, tombstoned(order: "3072", title: "Gone")) try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along")) try fixture.item("\(Ident.lane3)/\(Ident.indexless)", tombstoned(order: "2048", title: "Own flag")) 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 ownFlag = ItemID(rawValue: Ident.indexless) /// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come /// through a tombstone or a Put Back byte-for-byte, in order. private func untouchedLines(_ text: String) -> [Substring] { text.split(separator: "\n", omittingEmptySubsequences: false).filter { !$0.hasPrefix("modified") && !$0.hasPrefix("deleted:") } } /// 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) } @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() } // MARK: - Delete @MainActor @Suite("BoardStore ▸ delete") struct TrashDeleteTests { @Test("Delete stamps deleted and modified, clears modified-by, and touches nothing else") func tombstonesAndStamps() 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]) let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") let document = try FrontmatterDocument.parse(after) #expect(document.deleted.value != nil) #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution") // Everything the write does not own survives exactly, in order — the unknown key with its // inline comment, the reserved `labels`, the original `created`, and the body. #expect(untouchedLines(after) == untouchedLines(before)) #expect(store.banners.oneShots.isEmpty) } @Test("A multi-item delete is one bracket, and a lane's tombstone rewrites only the lane") func batchAndLaneMinimalTouch() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let card = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)") store.delete([lane2, card1]) // Both landed. #expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).deleted.value != nil) #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) .deleted.value != nil) // Hiding the subtree is the renderer's ancestor walk, not a stored flag: the lane's card is // untouched, bytes *and* mtime. let cardAfter = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)") #expect(cardAfter.data == card.data) #expect(cardAfter.modified == card.modified) } @Test("Delete clears the selection") func clearsSelection() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card1], liveness: .live) store.deleteSelection() // m5's successor-selection grammar replaces this; until then, what was selected renders // nowhere and the selection says so. #expect(store.selection.isEmpty) } @Test("The card window's Delete is the same tombstone, and says nothing about the selection") func cardWindowDeleteIsTheSameWrite() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") // Something *else* is selected on the board, which is the case the rule is about: the card // window's card need not be the board's selection at all. store.select([card3], liveness: .live, anchor: card3, head: card3) store.deleteCard(card1) // Byte-indistinguishable from the ⌫ tombstone above — same write op, same stamps, same // minimal touch (05-card-window.md ▸ Actions: "Delete — tombstones the card"). let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") #expect(try FrontmatterDocument.parse(after).deleted.value != nil) #expect(!after.contains("modified-by")) #expect(untouchedLines(after) == untouchedLines(before)) // ⌫ moves the selection to the successor sibling so a repeated keystroke walks down a lane. // A button in another window has no such continuation, and re-pointing a selection that never // lost anything would be the drag's mistake (`deleteByDrag`'s rule, shared). #expect(store.selection.ids == [card3]) } @Test("The card window's Delete writes nothing for a card that is already gone") func cardWindowDeleteIsLiveOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)") // Its own tombstone, its lane's tombstone (effective liveness is ancestor-walked), and a card // this board has never heard of. All three are windows already dismissing — nothing is ever // written into a vanished folder. store.deleteCard(card2) store.deleteCard(card4) store.deleteCard(ItemID(rawValue: "00000000-0000-4000-8000-000000000000")) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified) #expect(store.banners.oneShots.isEmpty) } @Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing") func liveOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)") // `card2` carries its own flag; `card4` is live by its own flag but its lane is tombstoned, // so effective liveness puts it on the trashed side too. store.delete([card2, card4]) store.delete([]) store.delete([ItemID(rawValue: "00000000-0000-4000-8000-000000000000")]) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified) #expect(store.banners.oneShots.isEmpty) } @Test("A read-only board refuses the delete without a second banner") func readOnlyRefusesQuietly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") store.delete([card1]) #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) } @Test("A readable-but-uneditable item refuses the write, banners it, and keeps its bytes") func uneditableBanners() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.uneditable) let store = try BoardStore(rootURL: fixture.root) store.delete([card1]) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == Item.uneditable) let posted = try #require(store.banners.oneShots.first) #expect(posted.error.operation == .delete(title: "Odd")) #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't delete 'Odd' — ")) } } // MARK: - Delete by drop /// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "release /// tombstones the dragged card(s), exactly the ⌫ tombstone". /// /// *Exactly* is the claim under test, and it is a claim about the disk — so these run the two /// gestures over two identical fixtures and compare the bytes. Where the drop *may* land and what it /// draws on the way are `TrashDropTests`' and `DropSettleTests`'; here it has already landed. @MainActor @Suite("BoardStore ▸ delete by drop") struct TrashDropWriteTests { @Test("A drop-delete is byte-for-byte the ⌫ tombstone") func indistinguishableFromTheKeystroke() throws { let byKey = try makeBoard() defer { byKey.tearDown() } let byDrop = try makeBoard() defer { byDrop.tearDown() } try BoardStore(rootURL: byKey.root).delete([card1]) try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1]) let keyed = try byKey.indexText("\(Ident.lane1)/\(Ident.card1)") let dropped = try byDrop.indexText("\(Ident.lane1)/\(Ident.card1)") // Everything but the two stamps that are clocks rather than content, which differ between any // two writes at all — including two ⌫ presses. #expect(untouchedLines(dropped) == untouchedLines(keyed)) #expect(try FrontmatterDocument.parse(dropped).deleted.value != nil) #expect(!dropped.contains("modified-by"), "an app-mediated write clears an external writer's attribution") } /// A multi-selection drag carries its whole run across lanes, and the tombstone is the card's own /// `index.md` and nothing else — the parents are not rewritten to record a child's departure, /// because nothing departed. @Test("A cross-lane run lands whole and touches nothing it did not carry") func theWholeRunLands() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let lane = try stat(fixture, Ident.lane1) store.deleteByDrag(cardIDs: [card1, card3]) #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) .deleted.value != nil) #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane2)/\(Ident.card3)")) .deleted.value != nil) let laneAfter = try stat(fixture, Ident.lane1) #expect(laneAfter.data == lane.data) #expect(laneAfter.modified == lane.modified) #expect(store.banners.oneShots.isEmpty) } /// The one thing the drop deliberately does *not* share with ⌫. The keystroke picks a successor /// because the selection lost its cards and "repeated ⌫ walks down a lane"; a drag's run is not /// necessarily the selection at all, so re-pointing one that lost nothing would be a bug. The /// reload's resolve rule ejects tombstoned members from a live-side set on its own. @Test("A drop-delete never touches the selection, where ⌫ moves it to the successor") func theSelectionIsLeftAlone() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Something else entirely is selected — dragging a card outside the selection drags it alone // and leaves the selection standing (`LaneView.startCardDrag`). store.select([card3], liveness: .live, anchor: card3, head: card3) store.deleteByDrag(cardIDs: [card1]) #expect(store.selection.ids == [card3]) #expect(store.selection.liveness == .live) // The keystroke's contrasting half, over an identical board: ⌫ re-points the selection // whatever was in it, because it is the gesture that promises to walk down a lane. let keyed = try makeBoard() defer { keyed.tearDown() } let keyedStore = try BoardStore(rootURL: keyed.root) keyedStore.select([card3], liveness: .live, anchor: card3, head: card3) keyedStore.delete([card1]) #expect(keyedStore.selection.ids != [card3]) } @Test("Already-tombstoned ids are skipped, and an empty run writes nothing") func liveOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") store.deleteByDrag(cardIDs: [card2]) store.deleteByDrag(cardIDs: []) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) #expect(store.banners.oneShots.isEmpty) } @Test("A read-only board refuses the drop without a second banner") func readOnlyRefusesQuietly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") store.deleteByDrag(cardIDs: [card1]) #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") } } // MARK: - Put Back @MainActor @Suite("BoardStore ▸ put back") struct TrashPutBackTests { @Test("A delete→Put Back round trip differs from the original only in the modified timestamp") func roundTripFidelity() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let path = "\(Ident.lane1)/\(Ident.card1)" let original = try fixture.indexText(path) store.delete([card1]) await reload(store) store.putBack([card1]) let after = try fixture.indexText(path) // Restore fidelity is perfect because nothing ever moved: no residue of the key that made // this a tombstone, and every other line — the inline comment included — as written. #expect(!after.contains("deleted")) #expect(untouchedLines(after) == untouchedLines(original)) #expect(try FrontmatterDocument.parse(after).order == .valid(1024)) // Position among siblings survives byte-for-byte, which is what "at its old order" means. let cards = try #require(BoardLoader.load(boardRoot: fixture.root).model.lanes.first?.cards) #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2]) #expect(cards[0].isDeleted == false) } @Test("Putting back a lane splits its contents by flag — own-flag cards stay tombstoned") func laneSplitsByFlag() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let ridesAlong = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)") let ownFlagged = try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)") store.putBack([lane3]) // Only the lane's own file was rewritten. The card that rides along was never flagged, so // it simply reappears; the own-flagged one keeps its key and its row moves back to the // trash — recovering it is deliberately a second Put Back. #expect(!(try fixture.indexText(Ident.lane3).contains("deleted"))) #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == ridesAlong.modified) #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)").modified == ownFlagged.modified) let model = try BoardLoader.load(boardRoot: fixture.root).model let lane = try #require(model.lanes.first { $0.id == lane3 }) #expect(!lane.isDeleted) #expect(lane.cards.first { $0.id == card4 }?.isDeleted == false) #expect(lane.cards.first { $0.id == ownFlag }?.isDeleted == true) // And the own-flagged card now has a row of its own, which it did not while the lane was // tombstoned. #expect(TrashModel.entries(of: model).map(\.id).contains(ownFlag)) } @Test("Put Back on a live item, an empty set, or an unknown id writes nothing") func noOps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let live = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") store.putBack([card1]) store.putBack([]) store.putBack([ItemID(rawValue: Ident.indexless.uppercased())]) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == live.modified) } } // MARK: - Delete Immediately and Empty Trash @MainActor @Suite("BoardStore ▸ purge") struct TrashPurgeTests { @Test("Delete Immediately removes the folder and leaves everything else alone") func purgeRemovesTheFolder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let sibling = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") store.deleteImmediately([card2]) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == sibling.modified) #expect(store.selection.isEmpty) #expect(store.banners.oneShots.isEmpty) } @Test("Purging a lane takes its whole folder, tombstoned cards and all") func purgingALaneTakesItsSubtree() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.deleteImmediately([lane3]) #expect(!fixture.exists(Ident.lane3)) #expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)")) #expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)")) #expect(fixture.exists(Ident.lane1), "the live lanes are untouched") } @Test("Delete Immediately never reaches a live item") func purgeIsTrashedOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.deleteImmediately([card1, lane1]) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(fixture.exists(Ident.lane1)) } @Test("Empty Trash purges every tombstone on the board, own-flag cards under a lane included") func emptyTrashPurgesEverything() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.emptyTrash() // The tombstoned card under a live lane, the tombstoned lane, and — through the lane's own // folder — the card that carried its own flag inside it. #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(!fixture.exists(Ident.lane3)) #expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)")) // Everything live survives, and the board now has an empty trash. #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card3)")) let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(TrashModel.isEmpty(model)) #expect(store.selection.isEmpty) } @Test("Empty Trash on a board with no tombstones writes nothing") func emptyTrashOnACleanBoard() 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)", Item.rich(order: "1024", title: "First")) let store = try BoardStore(rootURL: fixture.root) let card = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") store.emptyTrash() #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == card.modified) #expect(store.banners.oneShots.isEmpty) } @Test("Every board is unrecoverable today, so Delete Immediately always confirms") func purgeIsUnrecoverableEverywhere() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // m7-git: the git milestone is what makes this answer `false` for some boards. #expect(store.purgeIsUnrecoverable) } } // MARK: - Drag to restore @MainActor @Suite("BoardStore ▸ drag to restore") struct TrashDragRestoreTests { @Test("A drop back on the card's own lane removes the key only — the folder never moves") func sameLaneIsAPutBack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") store.restoreByDrag(cardID: card2, intoLane: lane1, at: 1) let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(!after.contains("deleted")) // Dropped at index 1 — past lane one's single live card, which is exactly where the card's // recorded 2048 already puts it. The rank the drop names and the rank on disk agree, so no // `order` is written at all: the `order` line comes through byte-for-byte and the card // returns where it was, the position-perfect restore a pure-view trash makes possible. #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) #expect(untouchedLines(after) == untouchedLines(original)) } @Test("A drop on another lane restores and appends at that lane's bottom, in one bracket") func crossLaneMovesAndAppends() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)")) let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)") #expect(!after.contains("deleted")) // Dropped at index 1 — lane two's one visible card is at 1024, so the drop's own rank is // the append 2048, carried by the move rather than left to the Writer to compute. #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) let model = try BoardLoader.load(boardRoot: fixture.root).model let lane = try #require(model.lanes.first { $0.id == lane2 }) #expect(lane.cards.map(\.id.rawValue) == [Ident.card3, Ident.card2]) #expect(lane.cards.allSatisfy { !$0.isDeleted }) #expect(TrashModel.isEmpty(model) == false, "the tombstoned lane is still in the trash") } @Test("A drop that names nothing droppable writes nothing") func noOps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let trashedCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") let liveCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") // A tombstoned destination lane is never a drop target (04 ▸ Drag and drop: "a card is // never filed under a `deleted:` parent"). store.restoreByDrag(cardID: card2, intoLane: lane3, at: 0) // A lane that is not on the board at all. store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless), at: 0) // A card that is not a trash row: live, and — for `card4` — hidden by its lane rather than // by its own flag, so it has no row to drag in the first place. store.restoreByDrag(cardID: card1, intoLane: lane2, at: 0) store.restoreByDrag(cardID: card4, intoLane: lane1, at: 0) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified) #expect(fixture.exists("\(Ident.lane3)/\(Ident.card4)")) #expect(store.banners.oneShots.isEmpty) } @Test("A read-only board refuses the drop") func readOnlyRefuses() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:")) } } // 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() confirmations.requestPurge(of: [card2], in: store) let pending = try #require(confirmations.pending) #expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?") #expect(pending.action == .purge([card2])) // Nothing has happened yet — the alert is what stands between the keystroke and the loss. #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) confirmations.confirm(in: store) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(confirmations.pending == nil) // Idempotent: the binding's own dismissal fires an instant after the button. confirmations.confirm(in: store) } @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() confirmations.requestPurge(of: [card2], in: store) confirmations.cancel() #expect(confirmations.pending == nil) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) } @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 1 lane and 1 card?") confirmations.confirm(in: store) #expect(TrashModel.isEmpty(try BoardLoader.load(boardRoot: fixture.root).model)) } @Test("Neither 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) } }