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:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user