Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y

Phase 2 completes the lanes-in-trash card. TrashEntry merges the
trash's two kinds by rank in exactly ONE place (ItemPath.resolve's
own merge deleted in favor of it — the three-merge-points finding
shrinks instead of growing). TrashLaneRowView renders the opaque
row — tertiary plate, level-default lane glyph never the lane's own
icon, title + card count, no accents, no expansion; the column badge
counts rendered rows. Selection grammar: kind-homogeneous trash
selections — ranges skip the other kind, ⇧-extension stops at the
kind boundary, plain arrows walk the merged order, marquee stays
card-only (now load-bearing: rows register frames for arrows),
Select All card-scoped; successor-on-purge crosses kinds like
navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop
accepts lane sessions (drop on shown trash deletes), restoreLanes
routes a trash-sourced strip drop as an arrival-ranked within-board
move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque
lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as
the root — a same-board restore looked like an import and would
have reminted the lane it was restoring (pinned by test). A11y:
row = one flattened "title, deleted lane, N cards" element with
Delete/Reveal actions; BoardDiff crossings read lanes as
deleted/restored, shown-trash churn digested at row level. Agent
guide stays v7 — the literal already teaches lanes-trash-by-move
and kind stamping; drift-guard pins those lines. README trash
paragraph notes lanes.

Both schemes 1893 tests / 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 17:04:30 -04:00
parent 8014bde7c6
commit f7c8088783
26 changed files with 1825 additions and 176 deletions
@@ -127,6 +127,28 @@ struct AccessibilityPhrasesTests {
#expect(AccessibilityPhrases.trashValue(cards: 0) == "0 cards")
}
/// With lane rows in the column the value names both kinds the rows are what the VoiceOver
/// cursor is about to walk into, and a card-only count would understate the container.
@Test("The trash's value names its lane rows when it has any")
func trashValueCountsRows() {
#expect(AccessibilityPhrases.trashValue(cards: 3, lanes: 1) == "3 cards, 1 lane")
#expect(AccessibilityPhrases.trashValue(cards: 0, lanes: 2) == "0 cards, 2 lanes")
// A trash with no rows of the other kind reads exactly as it always did.
#expect(AccessibilityPhrases.trashValue(cards: 3, lanes: 0) == "3 cards")
}
/// "A trashed **lane** is one flattened opaque element 'title, deleted lane, N cards' never
/// a container" (10-accessibility.md Trash lane), the design's own phrase.
@Test("A trashed lane row says what it is and what it is holding")
func trashedLaneRow() {
#expect(AccessibilityPhrases.trashedLaneLabel(title: "Doing", cards: 5) == "Doing, deleted lane, 5 cards")
#expect(AccessibilityPhrases.trashedLaneLabel(title: "Doing", cards: 1) == "Doing, deleted lane, 1 card")
#expect(AccessibilityPhrases.trashedLaneLabel(title: nil, cards: 0) == "Untitled, deleted lane, 0 cards")
// The untitled placeholder is the one the face draws, not a second spelling.
#expect(AccessibilityPhrases.trashedLaneLabel(title: "", cards: 0)
== AccessibilityPhrases.trashedLaneLabel(title: nil, cards: 0))
}
/// The announcement states the resulting state rather than the action, so a user who mis-hit the
/// toggle learns where the board ended up.
@Test("Toggling trash visibility announces the resulting state")
+21
View File
@@ -666,6 +666,27 @@ struct AgentGuideContentTests {
#expect(content.contains("*file* called `attachments` in a card"))
}
/// **Lanes delete into the trash too** (08 The agent guide's present-tense list; 03-board-ui.md
/// § Trash, re-ruled 2026-07-29): the guide has to teach the *move*, the `kind` value that tells a
/// trashed lane from a card in the flat container, and the stamp on the way in. The shipped v7
/// literal already says all three this is the guard that keeps a wording pass from dropping one
/// silently, which is the failure class the per-version changelog bullets were retired over.
@Test("The guide teaches lanes-in-trash: the move, the kind value, and the stamp")
func lanesInTrashVocabularyIsPresent() {
let content = AgentGuide.content
// The delete itself is one sentence covering both levels.
#expect(content.contains("Delete a card or a lane = move its folder into `<board>/.trash/`"))
#expect(content.contains("for a whole lane (create `.trash/` if missing)"))
#expect(content.contains("travels with its cards inside it"))
// `kind` at creation, always depth says what an item is on the board, but `.trash/` is flat.
#expect(content.contains("**Always write `kind`** at creation"))
#expect(content.contains("tells a trashed lane from a card"))
// And stamped on the way in when it is missing.
#expect(content.contains("**Stamp `kind: lane` when you trash a lane that lacks it.**"))
// The one place permanence is named, and it names both kinds.
#expect(content.contains("the trash is the recoverable path for both"))
}
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
/// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate
/// mention of `deleted:` is the warning never to write it.
+118
View File
@@ -19,6 +19,9 @@ private let card1 = Ident.card1
private let card2 = Ident.card2
private let card3 = Ident.card3
private let card4 = Ident.card4
/// A fourth lane identity, used only by the trash's own rows a row must never share an id with a
/// live lane, which on a real board it cannot (board-wide uniqueness spans both containers).
private let trashedLaneID = Ident.lane4
/// Three lanes; two cards in the first, one in the second, none in the third.
private func makeBoard() throws -> WriterFixture {
@@ -445,4 +448,119 @@ struct ShownTrashDiffTests {
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
#expect(diff.cards.deleted.isEmpty, "the two cards left with their lane — that is the lane's event")
}
// MARK: - The column's other kind
/// The crossing rule at the lane level (lanes rejoined the trash 2026-07-29): a lane moved into
/// `.trash/` is a **delete**, and it reads the same shown or hidden, because the lane left
/// `lanes` either way which is exactly the "1 lane deleted" event the user watched happen.
@Test("A lane moved into the trash is deleted, shown or hidden, and never a move")
func laneIntoTheTrashIsADeletion() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try fixture.moveFolder(lane1, to: ".trash/\(lane1)")
let after = try fixture.snapshot()
for shown in [true, false] {
let diff = BoardDiff.between(before, after, includingTrash: shown)
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
#expect(diff.lanes.moved.isEmpty)
#expect(diff.cards.deleted.isEmpty, "its cards went with it — that is the lane's event")
}
}
/// And back out again: a restored lane is an **arrival**, not a move.
@Test("A lane restored out of the trash is an addition")
func laneOutOfTheTrashIsAnArrival() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.moveFolder(lane1, to: ".trash/\(lane1)")
let before = try fixture.snapshot()
try fixture.moveFolder(".trash/\(lane1)", to: lane1)
let after = try fixture.snapshot()
for shown in [true, false] {
let diff = BoardDiff.between(before, after, includingTrash: shown)
#expect(diff.lanes.added == [ItemID(rawValue: lane1)])
#expect(diff.lanes.moved.isEmpty)
#expect(diff.cards.added.isEmpty, "its cards arrived with it")
}
}
/// The news the ruling actually adds: churn that never leaves the container. A purged **row** is
/// counted while the column is shown and silent while it is hidden, exactly as a purged card is.
@Test("A purged lane row is a lane deletion while the trash is shown, and silence while hidden")
func purgedLaneRowCountsWhileShown() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url(".trash/\(trashedLaneID)"))
let after = try fixture.snapshot()
#expect(BoardDiff.between(before, after, includingTrash: true).lanes.deleted == [ItemID(rawValue: trashedLaneID)])
#expect(BoardDiff.between(before, after).isSilent)
}
/// " 41 cards and 2 lanes containing 9 more cards" is the confirmation's sentence; this is the
/// digest's: an Empty Trash over a mixed container must not understate what went.
@Test("Empty Trash counts the lane rows alongside the cards")
func emptyTrashCountsBothKinds() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
try fixture.trashedLane(trashedLaneID, order: "512", title: "Done")
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url(".trash/\(card4)"))
try FileManager.default.removeItem(at: fixture.url(".trash/\(trashedLaneID)"))
let diff = BoardDiff.between(before, try fixture.snapshot(), includingTrash: true)
#expect(diff.cards.deleted == [ItemID(rawValue: card4)])
#expect(diff.lanes.deleted == [ItemID(rawValue: trashedLaneID)])
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card deleted, 1 lane deleted")
}
/// The row's *rendered* content is its title, and only that: it takes no styling accents, so a
/// colour an agent wrote onto a trashed folder changes nothing anyone can see.
@Test("A retitled row is an edit while shown; a restyled one is not an edit at all")
func rowContentIsItsTitle() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
let before = try fixture.snapshot()
try fixture.item(".trash/\(trashedLaneID)", "---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\n---\n\n")
#expect(BoardDiff.between(before, try fixture.snapshot(), includingTrash: true).lanes.edited
== [ItemID(rawValue: trashedLaneID)])
let retitled = try fixture.snapshot()
try fixture.item(
".trash/\(trashedLaneID)",
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: blue\n---\n\n"
)
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
#expect(styled.boardChanged, "the backstop still catches it — the bytes did change")
}
/// A row that moved rank in the column is a move while shown, like a trash card reordered in
/// place the position axis is the same axis whatever the kind.
@Test("A reordered lane row is a move while the trash is shown")
func reorderedRowIsAMove() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
let before = try fixture.snapshot()
try fixture.trashedLane(trashedLaneID, order: "256", title: "Done")
let after = try fixture.snapshot()
#expect(BoardDiff.between(before, after, includingTrash: true).lanes.moved == [ItemID(rawValue: trashedLaneID)])
#expect(BoardDiff.between(before, after).isSilent)
}
}
+44
View File
@@ -53,6 +53,10 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)
/// The trash's **lane row** in the harness below the opaque unit X restores (03-board-ui.md §
/// Trash, lanes rejoined 2026-07-29).
let clipboardTrashedLane = ItemID(rawValue: Ident.lane3)
/// An ordinary card body, for the trash's resident.
func trashResidentItem(order: String, title: String) -> String {
"""
@@ -126,6 +130,21 @@ func makeClipboardHarness() throws -> ClipboardHarness {
try ClipboardHarness(fixture: try makeClipboardBoard())
}
/// The same board with a **lane row in its trash**, carrying one card the clipboard's other trash
/// subject (04-interactions.md The trash: "X works a trashed lane pastes after the anchor
/// lane"). Its own fixture rather than a line in `makeClipboardBoard`, so every suite that counts the
/// trash's cards keeps counting exactly what it did.
@MainActor
func makeTrashedLaneHarness() throws -> ClipboardHarness {
let fixture = try makeClipboardBoard()
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
)
try fixture.item("\(".trash/\(Ident.lane3)")/\(Ident.indexless)", Item.rich(order: "1024", title: "Freight"))
return try ClipboardHarness(fixture: fixture)
}
// MARK: - The manifest
@Suite("ClipboardManifest")
@@ -627,6 +646,31 @@ struct ClipboardAvailabilityTests {
#expect(harness.clipboard.canCut(from: harness.store))
}
/// The row is a lane on the clipboard's own axis: "the payload kinds never mix because the
/// selection never does" (04-interactions.md The trash), so a cut row writes a **lane** payload
/// recorded in the **trash** container.
@Test("A trashed lane row copies and cuts, and its payload is a lane in the trash")
func trashedLaneRowTakesCopyAndCut() throws {
let harness = try makeTrashedLaneHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardTrashedLane], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store))
harness.clipboard.cut(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .lane)
#expect(manifest.container == .trash)
#expect(manifest.entries.map(\.title) == ["Done"])
// The opaque unit describes itself and not its subtree: the *content* travels through the
// staged folder, which is copied whole.
#expect(manifest.entries.first?.cards.isEmpty == true)
#expect(harness.store.transient.pendingCut
== ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash))
}
@Test("The read-only lock blocks cut but never copy")
func lockBlocksCutOnly() throws {
let harness = try makeClipboardHarness()
+22 -7
View File
@@ -188,10 +188,19 @@ struct TrashDropTests {
#expect(accepts(operation: .move))
}
/// "Lanes are not deliverable this way (a lane drag proposes only lane slots)."
@Test("A lane drag never proposes into the trash")
func lanesAreNotDeliverable() {
#expect(!accepts(kind: .lanes))
/// "Dropping a live card **or lane** on the shown trash deletes it a lane drag over the
/// shown trash proposes the delete alongside its strip slots" (04-interactions.md The trash,
/// lanes extended 2026-07-29, retiring "a lane drag proposes only lane slots").
@Test("A live lane drag proposes the delete too — and no session proposes nothing")
func lanesAreDeliverable() {
#expect(accepts(kind: .lanes))
// Every other clause binds the lane exactly as it binds the card: a trashed row dragged out
// is not deletable back into the place it already is, a foreign board's lane is refused, and
// a hidden column takes nothing.
#expect(!accepts(kind: .lanes, container: .trash))
#expect(!accepts(kind: .lanes, isWithinBoard: false))
#expect(!accepts(kind: .lanes, isTrashShown: false))
#expect(!accepts(kind: .lanes, acceptsMutations: false))
// And no session at all is no proposal either the column is inert between drags.
#expect(!accepts(kind: nil))
}
@@ -451,8 +460,11 @@ struct DropSettleTests {
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
}
@Test("A lane session never proposes into the trash")
func laneSessionsHaveNoTrashLanding() throws {
/// The column draws the delete gesture's shadow for a **lane** session too (lanes extended
/// 2026-07-29): the accessor asks "is this proposal mine", and the kind question belongs to
/// `TrashDrop.accepts`, which is asked at hover and again at release.
@Test("A lane session's trash proposal reaches the column, and only on its own board")
func laneSessionsProposeIntoTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
@@ -465,7 +477,10 @@ struct DropSettleTests {
)
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
#expect(session.trashProposal(onBoardRooted: store.rootURL) == nil)
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
#expect(session.shadowCount == 1)
// Another board's column draws nothing, like every other proposal accessor.
#expect(session.trashProposal(onBoardRooted: URL(filePath: "/tmp/other-board")) == nil)
}
// MARK: The hand-off
+171
View File
@@ -765,3 +765,174 @@ struct CrossBoardRestoreTests {
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - Restoring a trashed lane
/// A board whose trash holds a **lane row** the opaque unit, subtree intact (03-board-ui.md §
/// Trash) beside an ordinary trashed card, so the restore's rank arithmetic has a strip to land
/// on and the container has more than one kind in it.
@MainActor
private func makeTrashedLaneBoard() 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.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
)
try fixture.item(".trash/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "1024", title: "Freight"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
return fixture
}
/// **Drag-to-restore at the lane level** (04-interactions.md The trash: "dropping a trashed lane
/// row onto its own board's strip is an ordinary move to the drop position"), which is
/// `BoardStore.restoreLanes` an arrival's rank arithmetic with a within-board move's identity
/// posture and an undo step, since 13-native-undo.md's inverse inventory names "restore-by-move
/// move back in".
@MainActor
@Suite("BoardStore ▸ restoring a trashed lane")
struct RestoreLaneTests {
@Test("The drop slot sets the restored lane's order, and its cards ride along")
func dropSlotSetsTheOrder() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Slot 1: between the board's two lanes.
store.restoreLanes([lane3], toIndex: 1)
let model = try loaded(fixture)
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane3, Ident.lane2])
#expect(model.trashedLanes.isEmpty)
#expect(!fixture.exists(".trash/\(Ident.lane3)"), "the folder physically left the trash")
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"), "the freight came back inside it")
#expect(model.trash.map(\.id.rawValue) == [Ident.card3], "the column's cards are untouched")
#expect(store.banners.oneShots.isEmpty)
}
@Test("The identity travels — a within-board restore is a move, never an import")
func identityTravels() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreLanes([lane3], toIndex: 99)
// The lane keeps its UUID and so does its card: the import boundary's remint is for
// *arrivals from another board*, and a row coming out of this board's own trash is not one.
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
#expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along")
}
@Test("Its undo is the ordinary move back in, at the trash rank the row was holding")
func undoMovesItBackIn() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
store.restoreLanes([lane3], toIndex: 0)
#expect(history.undoActionName == "Move Lane")
history.undo()
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card2)"), "back in, subtree intact")
#expect(!fixture.exists(Ident.lane3))
#expect(try order(fixture, ".trash/\(Ident.lane3)") == .valid(512), "at the rank it was holding")
history.redo()
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
#expect(try loaded(fixture).trashedLanes.isEmpty)
}
@Test("A row that is not in the trash, and an empty set, write nothing")
func skipsWhatIsNotThere() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let untouched = try stat(fixture, Ident.lane1)
store.restoreLanes([lane1], toIndex: 0)
store.restoreLanes([], toIndex: 0)
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2])
#expect(try stat(fixture, Ident.lane1) == untouched)
#expect(store.banners.oneShots.isEmpty)
}
/// The keyboard restore's write (04-interactions.md The trash: "X in the trash, V a trashed
/// lane pastes after the anchor lane"), whose armed-cut path lands in `receiveLanes` with a
/// source folder inside this board's own `.trash/`.
///
/// **The identity has to survive that**, which is what the trash-aware source-root derivation
/// buys: `.trash/` counts in the destination's identity scan, so a restore mistaken for an import
/// would find the row's own UUID resident and remint the very lane it was restoring.
@Test("A within-board paste out of the trash keeps the lane's identity")
func pasteRestoreKeepsTheIdentity() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.receiveLanes(
[.folder(fixture.url(".trash/\(Ident.lane3)"))],
operation: .move,
at: 2,
normalizingLooseFiles: true
)
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
#expect(try loaded(fixture).trashedLanes.isEmpty)
}
}
/// The cross-board half the ordinary arrival, exactly as for a live lane: "Dropped on *another*
/// board it follows the copy default -drag forces the true cross-board restore-move"
/// (04-interactions.md The trash).
@MainActor
@Suite("BoardStore ▸ cross-board lane restore")
struct CrossBoardLaneRestoreTests {
@Test("A cross-board copy out of the trash mints fresh identities and leaves the original")
func copyLeavesTheOriginal() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeTrashedLaneBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .copy, at: 99)
let model = try loaded(destination)
let landed = try #require(model.lanes.last)
#expect(landed.title.value == "Done")
#expect(landed.id.rawValue != Ident.lane3, "a copy out of the trash is still a copy")
#expect(landed.cards.map(\.title.value) == ["Freight"])
#expect(source.exists(".trash/\(Ident.lane3)"), "the original stays in the source trash")
}
@Test("A ⌘-drag move carries the lane's identity and empties the source trash")
func moveCarriesTheIdentity() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeTrashedLaneBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .move, at: 0)
let landed = try #require(loaded(destination).lanes.first)
#expect(landed.id.rawValue == Ident.lane3, "the lane's identity travels")
// Its card carried `Ident.card2`, which this destination already holds so the import
// boundary remints that folder and only that folder, per-folder at the finest grain.
#expect(landed.cards.map(\.title.value) == ["Freight"])
#expect(landed.cards.map(\.id.rawValue) != [Ident.card2])
#expect(!source.exists(".trash/\(Ident.lane3)"))
#expect(try loaded(source).trashedLanes.isEmpty)
}
}
+25
View File
@@ -157,6 +157,31 @@ struct NavigationMathTests {
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
)
}
/// **Inside the trash, plain arrows walk every row and extension stops at the kind boundary**
/// (04-interactions.md The trash: "plain arrows walk across, extension stops"), which is the two
/// halves of the arrow handler expressed over one registry of drawn frames a lane row registers
/// like a card face precisely so can reach it.
@Test("A trash lane row is a plain arrow's neighbour, and an extension's dead end")
func trashLaneRowsAreNavigableButNotExtendable() {
let origin = target(card1, x: 0, y: 0, container: .trash)
let row = target(lane2, x: 0, y: 120, kind: .lane, container: .trash)
let below = target(card2, x: 0, y: 240, container: .trash)
let all = [origin, row, below]
// Plain: the next row down, whatever its kind.
#expect(NavigationMath.nearest(from: origin.frame, direction: .down, among: all) == lane2)
// : the handler takes the *same* unrestricted neighbour and then tests it, so a crossing
// row makes the press inert rather than being stepped over in search of a legal one the
// rule exists so a held range is never silently widened past what the user asked for.
let next = NavigationMath.nearest(from: origin.frame, direction: .down, among: all)
#expect(next == lane2)
#expect(row.kind != origin.kind, "so the extension stops here")
// From the row itself, reaches the card below it: navigation crosses back.
#expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2)
}
}
// MARK: - SortMath
+51
View File
@@ -301,6 +301,57 @@ struct PasteFromTrashTests {
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
}
/// **The lane row's keyboard restore** (04-interactions.md The trash: "a card pastes into a
/// lane, a trashed lane pastes after the anchor lane (the lane-paste rule above, verbatim)").
///
/// The identity is the load-bearing assertion: the armed cut hands `receiveLanes` a folder inside
/// this board's own `.trash/`, and `.trash/` counts in the destination board's identity scan so
/// a restore mistaken for an import would remint the very lane it was restoring.
@Test("Cut a trashed lane row and paste: it lands after the anchor lane, identity and freight intact")
func cutALaneRowAndPasteIsTheRestore() async throws {
let harness = try makeTrashedLaneHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardTrashedLane], in: .trash)
harness.clipboard.cut(from: harness.store)
// The anchor: the paste lands *after* the selected lane, exactly as a live lane paste does.
harness.store.select([clipboardLane1], in: .board)
await harness.clipboard.paste(into: harness.store)?.value
let model = try pasted(harness.fixture)
#expect(model.trashedLanes.isEmpty, "the folder left the trash")
#expect(model.lanes.map(\.id) == [clipboardLane1, clipboardTrashedLane, clipboardLane2])
let restored = try #require(model.lanes.first { $0.id == clipboardTrashedLane })
#expect(restored.cards.map(\.title.value) == ["Freight"], "the subtree came back with it")
#expect(restored.cards.map(\.id.rawValue) == [Ident.indexless], "and kept its own identities")
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
}
/// C on a row copies out like any lane copy: fresh GUIDs throughout, original left in the trash
/// (04 The trash's copy default, Clipboard's "a pasted *copy* takes fresh GUIDs throughout").
@Test("A trashed lane copies out as a fresh-GUID lane, carrying its cards")
func laneRowCopiesOut() async throws {
let harness = try makeTrashedLaneHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardTrashedLane], in: .trash)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
let model = try pasted(destination)
let landed = try #require(model.lanes.last)
#expect(landed.title.value == "Done")
#expect(landed.id != clipboardTrashedLane, "a copy mints fresh identities at every level")
#expect(landed.cards.map(\.title.value) == ["Freight"])
#expect(try pasted(harness.fixture).trashedLanes.map(\.id) == [clipboardTrashedLane],
"the original stays in the source trash")
}
}
// MARK: - The destination's search, and the stale pasteboard
+38 -12
View File
@@ -96,16 +96,24 @@ private func makeBoard() throws -> WriterFixture {
return fixture
}
/// A trash holding both row kinds, one of each matching `login` the shape "participates in the
/// filter like any lane" needs, since a lane entry is filtered by its *own* title and body.
/// A trash holding **both row kinds**, interleaved by rank the shape the column's own filter rule
/// needs (03-board-ui.md § Trash, lanes rejoined 2026-07-29: "The row matches the search filter by
/// lane title only").
///
/// `TrashModel`'s sort is newest first, so the row order is `[card1, laneX, card2]`.
/// `laneX`'s body says `login` and its title does not, which is what makes the title-only rule
/// checkable rather than merely stated: a query of `login` leaves `card1` alone.
///
/// The ranks put the lane row between the two cards, so the merged order is `[card1, laneX, card2]`.
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
try fixture.item(
".trash/\(More.laneX)",
"---\nschema: 1\ntitle: Archive\norder: 1536\nkind: lane\n---\nOld login notes.\n"
)
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
return fixture
}
@@ -322,18 +330,34 @@ struct SearchFilterOrderTests {
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
// And there is no lane list in the trash at all "Cards only. Lanes are never trashed".
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty)
}
@Test("The trash's visible universe is its matching cards")
/// "The row matches the search filter by lane title only" (03-board-ui.md § Trash) the opaque
/// unit's own rule, and the one place a lane *is* filtered: `laneX`'s body carries the query and
/// the row still leaves the column, because its body is not on screen.
@Test("A trashed lane row filters by title alone, and its own list is kind-scoped")
func trashLaneRowsFilterByTitle() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [laneX])
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX])
// The body says "login". The row does not.
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty)
// And the kind-scoped lists are disjoint slices of one rank order, which is what makes a
// -range skip the other kind (04-interactions.md The trash).
#expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2])
}
@Test("The trash's visible universe is its matching rows, both kinds")
func trashUniverseNarrows() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2, laneX])
}
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
@@ -347,15 +371,17 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(TrashLaneView.rendered(model.trash, filter: .inactive).map(\.id) == [card1, card2])
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == [card1])
// Both kinds, interleaved purely by rank (03-board-ui.md § Trash).
#expect(TrashLaneView.rendered(model, filter: .inactive).map(\.id) == [card1, laneX, card2])
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1])
// The column and the arrow grammar cannot disagree about what is on screen.
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id)
== SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")))
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id)
== SelectionGrammar.trashRows(in: model, filter: SearchFilter(query: "login")))
// A query nothing matches empties the column without emptying the container which is why
// Empty Trash's validation reads `.trash/` and not this list.
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "zzzz")).isEmpty)
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "zzzz")).isEmpty)
#expect(!model.trash.isEmpty)
#expect(!model.trashedLanes.isEmpty)
}
@Test("The delete successor is drawn from what the lane is showing")
+121 -10
View File
@@ -12,9 +12,10 @@ import Testing
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
/// from what the loader actually produces.
///
/// **Two homogeneity axes, not three** (resettled 2026-07-28): cards XOR lanes, and board XOR trash.
/// The third card entries XOR lane entries *inside* the trash retired with the lane entries it
/// separated, because lanes are never trashed.
/// **Two homogeneity axes, and the kind one reaches into the trash** (resettled 2026-07-28; lanes
/// rejoined 2026-07-29): cards XOR lanes, and board XOR trash. The trash's rows are cards *and*
/// opaque lane units, so "a trash selection is either cards or lane rows" is the board's own kind
/// rule in a second container rather than a third axis.
///
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
@@ -58,6 +59,23 @@ private func makeTrashBoard() throws -> WriterFixture {
return fixture
}
/// The same trash with **two lane rows interleaved among its cards** (03-board-ui.md § Trash, lanes
/// rejoined 2026-07-29): the rank order is `[card1, lane2, card2, lane3, card3]`, so every
/// kind-boundary claim below has a row of the other kind sitting inside the span it asks about.
@MainActor
private func makeMixedTrashBoard() throws -> WriterFixture {
let fixture = try makeTrashBoard()
try fixture.item(
".trash/\(Ident.lane2)",
"---\nschema: 1\ntitle: Doing\norder: 384\nkind: lane\n---\n"
)
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 768\nkind: lane\n---\n"
)
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
@@ -117,7 +135,7 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
}
@Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
@Test("The trash's list is its cards, in `order`; a trash with no lane rows has no lane list")
func trashOrder() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
@@ -125,11 +143,39 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
// "Cards only. Lanes are never trashed" so there is no list to walk rather than a rule
// saying not to (03-board-ui.md § Trash).
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
}
/// **The column's three lists** (03-board-ui.md § Trash; 04-interactions.md The trash): one
/// merged rank order for *navigation*, and two kind-scoped slices of it for *ranging*. The
/// slices are what make a -range skip the other kind without a rule that says so.
@Test("The trash's rows interleave by rank, and each kind's list is a slice of that order")
func trashRowsInterleave() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.trashRows(in: snapshot) == [card1, lane2, card2, lane3, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [lane2, lane3])
// One merge, one order: the path resolver batches in exactly the order the column draws.
#expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot)
== [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)])
}
@Test("The trash's kind is derived from the snapshot for both kinds")
func trashKindDerivation() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.kind(of: set([card1, card2], .trash), in: snapshot) == .card)
#expect(SelectionGrammar.kind(of: set([lane2, lane3], .trash), in: snapshot) == .lane)
// A board id claimed on the trash side names nothing there the container is the question.
#expect(SelectionGrammar.kind(of: set([card6], .trash), in: snapshot) == nil)
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == nil, "and the reverse")
}
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
func kindDerivation() throws {
let fixture = try makeLiveBoard()
@@ -241,6 +287,26 @@ struct CommandClickTests {
#expect(ontoCard.anchor == card1)
}
/// The kind axis inside the trash: "a trash selection is either cards or lane rows,
/// kind-homogeneous like the live board's own grammar" (04-interactions.md The trash).
@Test("⌘-click across the kind boundary inside the trash replaces")
func acrossKindInTheTrashReplaces() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(ontoRow.selection == set([lane2], .trash))
let ontoCard = click(target(card1, .card, .trash), .command, selection: set([lane2, lane3], .trash), anchor: lane3, in: snapshot)
#expect(ontoCard.selection == set([card1], .trash))
// Within one kind it still toggles, which is what makes the branch above a rule rather than
// a refusal of in the trash.
let added = click(target(lane3, .lane, .trash), .command, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
#expect(added.selection == set([lane2, lane3], .trash))
}
@Test("⌘-click across the container boundary replaces too")
func acrossContainerReplaces() throws {
let fixture = try makeTrashBoard()
@@ -355,6 +421,30 @@ struct ShiftClickTests {
#expect(outcome.anchor == card1)
}
/// "-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling,
/// mooted when lanes left the trash and back with them)" 04-interactions.md The trash.
@Test("A trash range skips rows of the other kind")
func trashRangeSkipsTheOtherKind() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// The column reads [card1, lane2, card2, lane3, card3]: a card range from the top to the
// bottom takes the three cards and steps over both lane rows.
let cards = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(cards.selection == set([card1, card2, card3], .trash))
// And a lane-row range takes the rows, skipping the card sitting between them.
let rows = click(target(lane3, .lane, .trash), .shift, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
#expect(rows.selection == set([lane2, lane3], .trash))
// A range aimed across the kinds has no list holding both endpoints, so it degrades to a
// plain click never a mixed selection.
let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(crossed.selection == set([lane3], .trash))
#expect(crossed.anchor == lane3)
}
@Test("A range never crosses the container boundary")
func trashRangeStopsAtTheBoundary() throws {
let fixture = try makeTrashBoard()
@@ -393,13 +483,16 @@ struct MarqueeMathTests {
#expect(ids == [card1, card2])
}
/// **There is no kind rule any more.** The tombstone model interleaved card rows and lane rows
/// in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind; lanes
/// are never trashed now, so both containers hold cards and one line serves both.
@Test("On the trash side the band takes the trash's cards, and stays on its own side")
/// **The kind filter is the whole rule, and in the trash it is load-bearing.** A trashed lane row
/// registers its frame like a card does the arrows navigate by those frames so the band
/// genuinely sweeps over one and must still leave it out: "the rubber band selects cards only
/// (as the board marquee does); lane rows join by click grammar" (04-interactions.md The trash).
@Test("On the trash side the band takes cards only, and stays on its own side")
func trashSideTakesItsOwnCards() {
let targets = [
Self.card(card1, 0, container: .trash),
MarqueeTarget(id: lane2, kind: .lane, container: .trash,
frame: CGRect(x: 0, y: 50, width: 100, height: 30)),
Self.card(card2, 100, container: .trash),
Self.card(card3, 50)
]
@@ -550,6 +643,24 @@ struct SelectAllTests {
#expect(store.selection == set([card1, card2, card3], .trash))
}
/// "Select All is card-scoped everywhere, never lane rows" (04-interactions.md The trash,
/// re-affirmed 2026-07-29): a lane-row selection is a *trash* selection, so the command reads the
/// column and what it selects there is its cards.
@Test("A lane-row selection still selects the trash's cards, never the rows")
func trashBranchIsCardScopedWithLaneRows() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([lane2], in: .trash)
store.selectAll()
#expect(store.selection == set([card1, card2, card3], .trash))
#expect(!store.selection.ids.contains(lane2))
#expect(!store.selection.ids.contains(lane3))
}
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
func trashBranchFallsThrough() throws {
let fixture = try makeTrashBoard()
+222
View File
@@ -21,6 +21,22 @@ private enum More {
static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
}
/// A lane already sitting in `<root>/.trash/` the opaque unit, `kind: lane` being the only thing
/// that tells it from a card in the flat container (01-storage-format.md § Deletion).
private func trashedLane(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
kind: lane
project: lanework # agent overlay
---
\(title) body.
"""
}
/// A card already sitting in `<root>/.trash/` an ordinary card in a special place, with an unknown
/// key so the verbatim-preservation claims have something to preserve.
private func trashResident(order: String, title: String) -> String {
@@ -346,6 +362,33 @@ struct DeleteLaneTests {
#expect(store.selection.ids == [lane3])
}
/// "Dropping a live card **or lane** on the shown trash deletes it" (04-interactions.md The
/// trash, lanes extended 2026-07-29): the drop writes exactly what writes, and like the card
/// gesture says nothing about the selection, because a drag has no keystroke to keep repeatable
/// and its run is not necessarily the selection at all.
@Test("Drop-on-trash writes exactly what ⌫ writes at the lane level, and leaves the selection alone")
func laneDropOnTrashMatchesTheKeystroke() throws {
let byKey = try makeBoard()
defer { byKey.tearDown() }
let byDrag = try makeBoard()
defer { byDrag.tearDown() }
let keyStore = try BoardStore(rootURL: byKey.root)
let dragStore = try BoardStore(rootURL: byDrag.root)
dragStore.select([card1], in: .board)
keyStore.delete([lane3])
dragStore.deleteLanesByDrag(laneIDs: [lane3])
// Same folder, same subtree, same rank `moveLanesToTrash` is the one write both take.
#expect(byDrag.exists(".trash/\(Ident.lane3)/\(Ident.card4)"))
#expect(try untouchedLines(byDrag.indexText(".trash/\(Ident.lane3)"))
== untouchedLines(byKey.indexText(".trash/\(Ident.lane3)")))
#expect(try loaded(byDrag).trashedLanes.map(\.order) == loaded(byKey).trashedLanes.map(\.order))
// moved the selection to the successor lane; the drag left it exactly where it was.
#expect(dragStore.selection.ids == [card1])
#expect(dragStore.banners.oneShots.isEmpty)
}
@Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes")
func lanesWinAMixedSet() throws {
let fixture = try makeBoard()
@@ -488,6 +531,185 @@ struct PurgeTests {
}
}
// MARK: - The trash's other kind of row
/// A board whose trash holds **both kinds, interleaved by rank**: a card on top, then a lane row
/// carrying two cards, then a second card (03-board-ui.md § Trash, lanes rejoined 2026-07-29).
///
/// The lane row sits between the two cards deliberately every claim below about crossing kinds
/// needs a row of the other kind on both sides of it.
@MainActor
private func makeTrashedLaneBoard() 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(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "768", title: "Doing"))
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed"))
return fixture
}
/// The permanent delete, the confirmation that stands in front of it, and the successor it leaves
/// on a **trashed lane row** (03-board-ui.md § Trash: "it restores whole or purges whole").
@MainActor
@Suite("BoardStore ▸ purging a trashed lane row")
struct PurgeTrashedLaneTests {
@Test("The row purges whole — its subtree with it — and registers no undo step")
func rowPurgesWhole() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
store.deleteTrashEntries([lane2])
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
#expect(!fixture.exists(".trash/\(Ident.lane2)/\(Ident.card3)"), "the freight went with it")
let model = try loaded(fixture)
#expect(model.trashedLanes.isEmpty)
#expect(model.trash.map(\.id) == [newer, trashed], "the column's cards are untouched")
// 13-native-undo.md Rules: "lanes and their freight included" the confirm is the safety.
#expect(!history.canUndo)
}
/// "Confirms name the freight honestly a trashed lane's alert counts its cards" (03 § Trash).
@Test("The row's confirmation names the lane and counts its cards")
func rowConfirmationCountsTheFreight() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([lane2], in: .trash)
confirmations.requestDelete(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 2 cards?")
#expect(pending.action == .deleteTrashEntries([lane2]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
}
/// Empty Trash's own sentence, with lanes in the container: " 41 cards and 2 lanes containing 9
/// more cards" (03 § Trash) and the walk really removes the subtrees.
@Test("Empty Trash counts both kinds and walks the lane subtrees")
func emptyTrashCountsBothKinds() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
let confirmations = TrashConfirmations()
#expect(store.canEmptyTrash)
confirmations.requestEmptyTrash(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete 2 cards and 1 lane containing 2 more cards?")
confirmations.confirm(in: store)
let model = try loaded(fixture)
#expect(model.trash.isEmpty)
#expect(model.trashedLanes.isEmpty)
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
}
/// **The interim successor** (`SelectionGrammar.successor`'s trash branch): 04 settles navigation
/// and extension for the column's two kinds but not the successor, so it follows *navigation*
/// the next row down whatever its kind rather than stranding the selection. Gap card
/// 7b5cbc90 tracks the ruling; this test is the interim's marker as much as its cover.
@Test("The successor after purging a lane row is the next row down, kind notwithstanding")
func successorCrossesKinds() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
store.deleteTrashEntries([lane2])
// The column reads [newer, lane2, trashed]; forward-first lands on the card below.
#expect(store.selection == ItemReferenceSet(ids: [trashed], container: .trash))
}
@Test("A row purged from the bottom falls back to its predecessor, and an emptied column clears")
func successorFallsBackAndClears() async 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(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "1024", title: "Doing"))
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
store.deleteTrashEntries([lane2])
#expect(store.selection.ids == [newer], "nothing below, so the row above")
// The successor is drawn from the *pre-write* snapshot, so the second purge needs the reload
// that tells the store the row is gone the lane-delete suite's rule, on the trash side.
await reload(store)
store.deleteTrashEntries([newer])
#expect(store.selection.isEmpty, "an emptied container selects nothing")
}
}
/// The lane row's own menu validation "everything edit-shaped is disabled on trash selections
/// **and lane width ops on lane rows**" (04-interactions.md The trash).
@MainActor
@Suite("Edit-shaped commands on a trashed lane row")
struct TrashedLaneRowValidationTests {
@Test("Rename, Style… and Open Card all refuse a lane row")
func editShapedRefusals() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
// The container answers all three, which is why none of them needed a kind clause.
#expect(store.renameTarget == nil)
#expect(store.boardStyleTarget == nil)
#expect(store.openCardTarget == nil)
// The within-lane sort is inert too its plan reads a *board* card selection.
#expect(store.sortPlan(.up) == nil)
#expect(store.sortPlan(.down) == nil)
}
/// The width pair batches over "the selected live lanes", which is `snapshot.lanes` narrowed by a
/// **board** selection so a trashed row contributes nothing and both items disable.
@Test("The width stepper's batch is empty for a lane row")
func widthOpsRefuse() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
let selected = store.snapshot.lanes.filter { store.selection.ids.contains($0.id) }
#expect(selected.isEmpty)
#expect(store.selection.container == .trash)
// And the row is not a lane on the strip at all, so Move Left/Right cannot name it either.
#expect(!SelectionGrammar.lanes(in: store.snapshot).contains(lane2))
}
/// Delete stays enabled and honest the row is a real entry the container holds, so the one
/// Delete predicate answers `true` and the write it stages is the permanent one.
@Test("Delete is enabled on a lane row and stages the permanent write")
func deleteStaysHonest() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
== [.trashLane(lane2)])
}
}
// MARK: - The legacy tombstone migration
/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never
+7
View File
@@ -161,6 +161,13 @@ extension WriterFixture {
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\n")
}
/// A **lane** written straight into `<root>/.trash/` the opaque unit (03-board-ui.md § Trash),
/// where `kind: lane` is the only thing that tells it from a card in the flat container.
@discardableResult
func trashedLane(_ id: String, order: String, title: String) throws -> URL {
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\nkind: lane\n---\n\n")
}
/// The board as the loader reads it right now the value a reload would have landed.
func snapshot() throws -> BoardModel {
try BoardLoader.load(boardRoot: root).model