Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -829,3 +829,219 @@ struct TrashConfirmationsTests {
|
||||
#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
|
||||
/// -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.
|
||||
@MainActor
|
||||
@Suite("The trash's menu validation")
|
||||
struct TrashMenuValidationTests {
|
||||
|
||||
/// 11-command-nexus.md: "Board window, trash shown and non-empty (whole-trash scope,
|
||||
/// search-independent)"; 03 § Trash: "menu validation's 'non-empty' reads `.trash/`, not the
|
||||
/// filtered view".
|
||||
@Test("Empty Trash needs the column shown and the container non-empty — never the filtered view")
|
||||
func emptyTrashValidation() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// Hidden, the trash "is invisible to every gesture" — the command included.
|
||||
#expect(!store.canEmptyTrash)
|
||||
|
||||
store.transient.isTrashVisible = true
|
||||
#expect(store.canEmptyTrash)
|
||||
|
||||
// A query that hides every trash card leaves it enabled: the scope is the container, not
|
||||
// what is on screen.
|
||||
store.searchQuery = "zzzz-nothing-matches"
|
||||
#expect(store.searchFilter.visibleIDs(in: store.snapshot, container: .trash).isEmpty)
|
||||
#expect(store.canEmptyTrash)
|
||||
|
||||
// And the confirmation still names the true count, for the same reason.
|
||||
let prompt = try #require(TrashModel.emptyTrashPrompt(in: store.snapshot, unrecoverable: true))
|
||||
#expect(prompt.title == "Permanently delete 2 cards?")
|
||||
}
|
||||
|
||||
@Test("An empty container disables it however visible the column is")
|
||||
func emptyTrashNeedsCards() 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)
|
||||
store.transient.isTrashVisible = true
|
||||
|
||||
#expect(!store.canEmptyTrash)
|
||||
}
|
||||
|
||||
/// **One Delete, staged by place** — 04-interactions.md ▸ The map: "File ▸ Delete is the chord's
|
||||
/// only owner — no twin menu items, no shared-equivalent routing". Put Back's ⌘⌫ twin is retired,
|
||||
/// so exactly one predicate enables the row and the selection's *container* decides which write
|
||||
/// it performs.
|
||||
@Test("Delete is one enabled row on both sides, and the container picks the write")
|
||||
func deleteIsOneRowStagedByPlace() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
store.select([card1], in: .board)
|
||||
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
||||
confirmations.requestDelete(in: store)
|
||||
// The board staging: a move, no alert, the card now in the trash.
|
||||
#expect(confirmations.pending == nil)
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
||||
confirmations.requestDelete(in: store)
|
||||
// The trash staging: permanent, and behind the alert.
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.action == .deleteTrashCards([trashed]))
|
||||
}
|
||||
|
||||
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
|
||||
/// the clicked card even while a *board* selection stands — the case a selection-reading path
|
||||
/// would silently no-op on (`TrashConfirmations.requestTrashDelete`).
|
||||
@Test("The trash card's context-menu Delete acts on its own target, not on the selection")
|
||||
func contextMenuDeleteIgnoresTheSelection() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
store.select([card1], in: .board)
|
||||
|
||||
confirmations.requestTrashDelete(of: [trashed], in: store)
|
||||
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
|
||||
#expect(pending.action == .deleteTrashCards([trashed]))
|
||||
|
||||
confirmations.confirm(in: store)
|
||||
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
||||
// The board selection was never the subject and is untouched on disk.
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Everything edit-shaped refuses a trash selection
|
||||
|
||||
/// 04-interactions.md ▸ The trash: "Everything edit-shaped is disabled on trash selections — Open
|
||||
/// Card, Rename, Style…". Each of the three answers with one expression used for both its `disabled`
|
||||
/// state and its action, which is what this suite drives (`BoardStore.openCardTarget`,
|
||||
/// `.renameTarget`, `.boardStyleTarget`).
|
||||
@MainActor
|
||||
@Suite("Edit-shaped commands on a trash selection")
|
||||
struct TrashGrammarExclusionTests {
|
||||
|
||||
@Test("Open Card takes a sole board card and nothing else")
|
||||
func openRefusesTheTrash() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([card1], in: .board)
|
||||
#expect(store.openCardTarget == card1)
|
||||
|
||||
// "Trash cards don't open — double-click stops at selection; move it out first" (03 § Trash).
|
||||
store.select([trashed], in: .trash)
|
||||
#expect(store.openCardTarget == nil)
|
||||
store.select([trashed, newer], in: .trash)
|
||||
#expect(store.openCardTarget == nil)
|
||||
|
||||
// A lane and a multi-selection refuse too — a card window is tied to one card.
|
||||
store.select([lane1], in: .board)
|
||||
#expect(store.openCardTarget == nil)
|
||||
store.select([card1, card2], in: .board)
|
||||
#expect(store.openCardTarget == nil)
|
||||
}
|
||||
|
||||
@Test("Rename takes a sole board item, card or lane, and never a trash card")
|
||||
func renameRefusesTheTrash() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([card1], in: .board)
|
||||
#expect(store.renameTarget?.id == card1)
|
||||
#expect(store.renameTarget?.title == "First")
|
||||
store.select([lane1], in: .board)
|
||||
#expect(store.renameTarget?.id == lane1)
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
#expect(store.renameTarget == nil)
|
||||
}
|
||||
|
||||
/// The one with a fall-through worth guarding: an empty selection styles *the board*, so a trash
|
||||
/// selection has to disable rather than land there — "quietly restyling the board because the
|
||||
/// user had a trashed card selected would be the silent retarget 03 forbids".
|
||||
@Test("Style… falls through to the board on an empty selection, but a trash selection disables it")
|
||||
func styleRefusesTheTrashWithoutFallingThrough() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.clearSelection()
|
||||
#expect(store.boardStyleTarget == .board)
|
||||
|
||||
store.select([card1], in: .board)
|
||||
#expect(store.boardStyleTarget == .items([card1]))
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
#expect(store.boardStyleTarget == nil)
|
||||
}
|
||||
|
||||
/// The trash's Delete is not edit-shaped and neither is Reveal, so both stay available — the two
|
||||
/// rows 11-command-nexus.md gives a trash card, and no others.
|
||||
@Test("Delete and Reveal are what a trash selection keeps")
|
||||
func whatTheTrashKeeps() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Put Back is gone
|
||||
|
||||
/// 03-board-ui.md § Trash: "**No Put Back** (settled) … Restoring is an ordinary move out". The
|
||||
/// retirement is mostly a compile-time fact — there is no `putBack` on the store, no restore write on
|
||||
/// the Writer, and no second ⌫-titled menu row — so what is left to state at runtime is that a
|
||||
/// restore registers, phrases and writes as the ordinary move it now is.
|
||||
@MainActor
|
||||
@Suite("Put Back is retired")
|
||||
struct PutBackRetirementTests {
|
||||
|
||||
@Test("The undo vocabulary has no restore verb — a restore is a Move")
|
||||
func noRestoreVerb() {
|
||||
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Restore"))
|
||||
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Put Back"))
|
||||
// What a drag-out or a ⌘X/⌘V restore actually reads as in the Edit menu.
|
||||
#expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
|
||||
}
|
||||
|
||||
/// One predicate for both stagings, because there is only one item: the mirror-image pair that
|
||||
/// existed to make two ⌘⌫ twins enable exactly one of themselves retired with Put Back.
|
||||
@Test("One Delete predicate covers both containers")
|
||||
func oneDeletePredicate() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try loaded(fixture)
|
||||
|
||||
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
|
||||
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [trashed], container: .trash), in: snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user