The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md travels with it, rewritten for lanes, the interior masonry, multi-drag, cross-board sessions, the re-grounding trio, and the committed-overlay hold): - DropSlotMath — resting-layout zones from analytic lane arithmetic and the pure masonry placement (MasonryLayout now lays out through the same MasonryPlacement the drag reads, so geometry cannot drift), span-capped triggers sized to the dragged run's future footprint, hysteresis holds with the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold. - DragAutoScrollMath — the activation bands and velocity ramp, pure. - The drop commits, one performWrite bracket each: moveCards/copyCards within a board (insertion ranks touch only the dragged cards; renumber fallback); receiveCards/receiveLanes/receiveRestoredCards on the destination store for cross-board copy and ⌘-move with the import-boundary remint, lane copies stripping tombstoned cards while moves carry them; restoreByDrag is now positional, writing order only when the drop names a new one. Gestures, sessions, previews, and delegates are the second half. 773 unit tests (87 new since the keyboard grammar). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
521 lines
22 KiB
Swift
521 lines
22 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("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, 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)
|
|
}
|
|
}
|