Build the trash quasi-lane
Deletion becomes a two-stage, Finder-style story. File > Delete and plain Backspace tombstone the live selection; View > Show Trash (no chord — Shift-Cmd-T stays with the system's tab bar) reveals the quasi-lane: trailing, one fixed width unit consumed only while shown, hatched dimmed header, count badge, no new-card button, exempt from resize and reorder alike. Its contents are a pure view over the snapshot — the deterministic sort (deleted newest first, folder-name ties, unparseable stamps oldest) interleaves card rows with a tombstoned lane's single entry, whose count names what Put Back returns; the ancestor walk is absolute, so an own-flag card beneath a tombstoned lane has no row and recovery is deliberately two steps. Put Back twins Delete on Cmd-Backspace with validation enabling exactly one; restore fidelity is byte-perfect because nothing ever moved. Delete Immediately confirms exactly where loss is real (every board is mode-none today; the predicate names the git carve-out for m7), Empty Trash always confirms with the true whole-board count, and dragging a tombstoned card onto a live lane restores it there — positional drops and cross-board locality arrive with m5's machinery. The banner's delete phrasing drops "move to the trash" per the naming constraint: board deletion says Delete, "Move to Trash" stays reserved for the system Trash. 47 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
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("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: - 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)
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||
#expect(!after.contains("deleted"))
|
||||
// The `order` is untouched, so the card returns where it was rather than at the bottom —
|
||||
// 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)
|
||||
|
||||
#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"))
|
||||
// m5-drag: the positional drop replaces this append. Lane two's one visible card is at
|
||||
// 1024, so the arrival lands at 2048 — the Writer's own append over visible siblings.
|
||||
#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)
|
||||
// A lane that is not on the board at all.
|
||||
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless))
|
||||
// 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)
|
||||
store.restoreByDrag(cardID: card4, intoLane: lane1)
|
||||
|
||||
#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)
|
||||
|
||||
#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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user