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