Realign code with the 2026-07-30 findings-resolution rulings

Delete Immediately is removed entirely (ruling ae1dd96, Redesign card
d40bfac1): the delete vocabulary is purely staged — board → trash,
trash → permanent (confirmed on no-git boards), Empty Trash for bulk.
Gone: File ▸ Delete Immediately (⌥⌘⌫) and its validation, both
⌥-alternate context rows (card + the permanently-disabled lane row),
the VO custom action, BoardStore.deleteImmediately,
TrashModel.canDeleteImmediately, TrashConfirmations' .purge action
(zero surviving callers — trash-side Delete always used
.deleteTrashCards), and the pinning tests. purgePrompt drops its
now-single-purpose container parameter (.trash is the only surviving
caller). BoardWriter.purgeItem survives — create-undo rollback still
needs it — with its comment rewritten. README's trash paragraph drops
the ⌥⌘⌫ sentence.

The other 2026-07-30 rulings (2ec2c95 registry freshness stamp,
c741b02 unified-log-as-coerce-consumer) required no code changes —
already conformant.

Both schemes 1844 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 15:13:31 -04:00
parent 9ca8ed84de
commit 785ef5fe14
15 changed files with 83 additions and 483 deletions
+12 -141
View File
@@ -2,9 +2,8 @@ import Foundation
import Testing
@testable import Kanban
/// `BoardStore`'s trash operations Delete (staged by place), Delete Immediately, Empty Trash, and
/// the legacy tombstone migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md
/// § Deletion).
/// `BoardStore`'s trash operations Delete (staged by place), Empty Trash, and the legacy tombstone
/// migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md § Deletion).
///
/// These drive a **real store over a real temp board** and then read the **raw bytes** back, never
/// the app's own read path, like every other write suite here: the interesting claims are about the
@@ -377,51 +376,12 @@ struct StagedDeleteTests {
}
}
// MARK: - Delete Immediately and Empty Trash
// MARK: - Empty Trash
@MainActor
@Suite("BoardStore ▸ purge")
struct PurgeTests {
@Test("Delete Immediately skips the trash from a lane")
func skipsTheTrashFromTheBoard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
store.deleteImmediately([card1], in: .board)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere")
#expect(store.selection.isEmpty)
}
@Test("Delete Immediately purges a card already in the trash")
func purgesFromTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash)
store.deleteImmediately([trashed], in: .trash)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
#expect(fixture.exists(".trash/\(More.newer)"), "and only what it named")
}
@Test("A lane in the set is never purged — cards only")
func lanesAreNotPurged() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], in: .board)
store.deleteImmediately([lane3], in: .board)
#expect(fixture.exists(Ident.lane3))
}
@Test("Empty Trash removes every card in the container, and leaves strays verbatim")
func emptyTrashIsWholeScope() throws {
let fixture = try makeBoard()
@@ -473,10 +433,10 @@ struct PurgeTests {
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteImmediately([newer], in: .trash)
store.deleteTrashCards([newer])
store.emptyTrash()
// 13-native-undo.md Rules: "Permanently delete (Delete Immediately, Empty Trash)
// 13-native-undo.md Rules: "Permanently delete (the trash's Delete, Empty Trash)
// the confirm *is* the safety." A stack entry here would be a promise the filesystem
// cannot keep.
#expect(!history.canUndo)
@@ -733,95 +693,6 @@ struct MigrationNoticeTests {
@Suite("TrashConfirmations")
struct TrashConfirmationsTests {
@Test("Delete Immediately raises the alert where the loss is real, and purges on confirm")
func purgeConfirmsThenActs() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([trashed], in: .trash)
confirmations.requestPurge(of: [trashed], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .purge([trashed], .trash))
// Nothing has happened yet the alert is what stands between the keystroke and the loss.
#expect(fixture.exists(".trash/\(Ident.indexless)"))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
#expect(confirmations.pending == nil)
// Idempotent: the binding's own dismissal fires an instant after the button.
confirmations.confirm(in: store)
}
/// **The card and lane context menus' -alternate** Delete Immediately, routed through
/// `requestBoardPurge` rather than through `requestPurge` (11-command-nexus.md Context menus'
/// Card and Lane rows: "Delete with Delete Immediately as its -alternate").
/// `purgeConfirmsThenActs`'s twin for the board side: same alert, same rule, a board card as the
/// target instead of a trash one.
@Test("The board-side ⌥-alternate raises the same alert, and purges the board card on confirm")
func boardPurgeConfirmsThenActs() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestBoardPurge(of: [card1], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}First\u{201D}?")
#expect(pending.action == .purge([card1], .board))
// Nothing has happened yet same alert, same rule.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
confirmations.confirm(in: store)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"), "skips the trash — purged, not moved")
#expect(confirmations.pending == nil)
}
/// A context menu names its target by where it was invoked, so a card row's Delete Immediately
/// must purge the *clicked* card even while a different card is selected `TrashMenuValidation
/// Tests.contextMenuDeleteIgnoresTheSelection`'s claim, mirrored onto the board side.
@Test("The board-side ⌥-alternate acts on its own target, not the standing selection")
func boardPurgeIgnoresTheSelection() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
// A right-click on `card1` without first selecting it must still purge `card1`, never the
// card the standing selection happens to hold (`CardFaceView.targetIDs`'s targeting rule).
store.select([card2], in: .board)
confirmations.requestBoardPurge(of: [card1], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.action == .purge([card1], .board))
confirmations.confirm(in: store)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"), "the clicked card is gone")
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the selected card was never the subject")
}
/// `TrashModel.canDeleteImmediately` is cards only (`TrashValidationTests
/// .canDeleteImmediatelyIsCardsOnly`: "a lane's delete is physical already there is nothing for
/// 'skip the trash' to mean on one"), and the lane row's alternate inherits that unchanged: it is
/// wired per 11-command-nexus.md's Lane row, but presently inert on a lane-only target the same
/// posture File Delete Immediately already takes on a lane-only selection.
@Test("A lane-only target raises no prompt — the alternate is still cards only")
func boardPurgeIsStillCardsOnlyForALaneTarget() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestBoardPurge(of: [lane1], in: store)
#expect(confirmations.pending == nil)
#expect(fixture.exists(Ident.lane1))
}
/// 03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent Both confirm exactly
/// where the loss is real."
@Test("The trash's own Delete confirms; the board's goes straight through")
@@ -856,7 +727,7 @@ struct TrashConfirmationsTests {
let confirmations = TrashConfirmations()
store.select([trashed], in: .trash)
confirmations.requestPurge(of: [trashed], in: store)
confirmations.requestTrashDelete(of: [trashed], in: store)
confirmations.cancel()
#expect(confirmations.pending == nil)
@@ -892,20 +763,21 @@ struct TrashConfirmationsTests {
confirmations.requestEmptyTrash(in: store)
#expect(confirmations.pending == nil)
confirmations.requestPurge(of: [lane1], in: store)
// A board lane names nothing in the trash the trash-side Delete's own refusal.
confirmations.requestTrashDelete(of: [lane1], in: store)
#expect(confirmations.pending == nil)
}
}
// MARK: - The menu-validation seams
/// The trash's three File-menu rows, validated as predicates rather than as menu items 11-command
/// The trash's two File-menu rows, validated as predicates rather than as menu items 11-command
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
///
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled()` per row over these
/// answers; what is worth pinning is the answers. `TrashModel.canDelete`/`canDeleteImmediately` are
/// pinned as pure functions in `TrashModelTests`; this suite covers the two seams that need a live
/// store Empty Trash's scope, and the staging a actually performs.
/// answers; what is worth pinning is the answers. `TrashModel.canDelete` is pinned as a pure function
/// in `TrashModelTests`; this suite covers the two seams that need a live store Empty Trash's scope,
/// and the staging a actually performs.
@MainActor
@Suite("The trash's menu validation")
struct TrashMenuValidationTests {
@@ -1076,7 +948,6 @@ struct TrashGrammarExclusionTests {
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
#expect(TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot))
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
}