Files
lanework/KanbanTests/TrashWriteTests.swift
T
rzen 40322247e0 Build the style, details, and actions sidebar sections
The sidebar completes: the shared style editor gains a second anchor —
StyleEditorLayout carries the geometry (the popover keeps its settled
268/14/7/8 untouched as the default; the sidebar packs columns to its
width with no inner scroller) while every well, the batch display, the
arrow grammar, and the one applyStyle bracket stay the shared
component's. The card anchor is fixed, not tracking: the target is
this card, and the fate walk retires the window when the card goes.
Details renders every unknown frontmatter key read-only in file order —
Card.document already carried them — showing the author's own bytes
where the raw span is a value and the engine's rendering for block
scalars and empties; reserved enhanced-schema keys are ordinary
unknowns, and no keys means no section. Actions: Delete rides the same
tombstone bytes as Backspace and drop-on-trash through a one-line
seam, says nothing about selection, and lets the fate walk dismiss;
Reveal in Finder resolves through the attachment scope so the two
paths cannot disagree. History reserves its m7 slot without drawing a
header no base board can honor.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 12:22:04 -04:00

677 lines
30 KiB
Swift

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)
}
}