Realign code with the 2026-07-31 rulings

The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:35:07 -04:00
parent 542ab169a3
commit bec75e4282
37 changed files with 1200 additions and 551 deletions
+88 -56
View File
@@ -12,10 +12,11 @@ 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, 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.
/// **One container axis, and a kind axis that stops at it** (resettled 2026-07-28; kind-blind trash
/// re-ruled 2026-07-31): a selection never mixes trash rows with board items, and *on the board* it
/// is cards XOR lanes but inside the trash "cards and lane rows select together", so the kind axis
/// does not reach into the second container. The guard that used to live there moved to the exits
/// (C/X validation and the mixed-payload drop refusal).
///
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
@@ -46,32 +47,50 @@ private func makeLiveBoard() throws -> WriterFixture {
}
/// A board with one lane and three cards in its `.trash/` the container the trash-side grammar
/// walks, in `order` display order (`[card1, card2, card3]`, newest first by ordinary ranks).
/// walks, newest first by `modified` (`[card1, card2, card3]`).
@MainActor
private func makeTrashBoard() 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)/\(More.card6)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
// **The trash's order is `modified` descending** (01-storage-format.md § Deletion, re-ruled
// 2026-07-31), so the stamps are what these fixtures state their sequence in; the `order` values
// ride along and are deliberately in the *opposite* direction, so nothing here can pass by
// accident of the retired rank rule.
try fixture.item(".trash/\(Ident.card1)", trashItem(order: "1024", title: "First", modified: "2026-05-05T09:00:00Z"))
try fixture.item(".trash/\(Ident.card2)", trashItem(order: "512", title: "Second", modified: "2026-05-03T09:00:00Z"))
try fixture.item(".trash/\(Ident.card3)", trashItem(order: "256", title: "Third", modified: "2026-05-01T09:00:00Z"))
return fixture
}
/// One trash entry, stated in the key the container actually sorts by.
private func trashItem(order: String, title: String, modified: String, kind: String? = nil) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
modified: \(modified)
\(kind.map { "kind: \($0)\n" } ?? "")---
\(title) body.
"""
}
/// 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.
/// rejoined 2026-07-29): the column order is `[card1, lane2, card2, lane3, card3]`, so every
/// kind-crossing 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"
trashItem(order: "384", title: "Doing", modified: "2026-05-04T09:00:00Z", kind: "lane")
)
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 768\nkind: lane\n---\n"
trashItem(order: "768", title: "Done", modified: "2026-05-02T09:00:00Z", kind: "lane")
)
return fixture
}
@@ -135,7 +154,7 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
}
@Test("The trash's list is its cards, in `order`; a trash with no lane rows has no lane list")
@Test("The trash's list is its rows, newest first; a trash with no lane rows is all cards")
func trashOrder() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
@@ -143,21 +162,30 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
// Kind-blind: with no lane rows in the container the two lists coincide, which is the point
// there is only ever *one* trash list to walk (04 The trash, re-ruled 2026-07-31).
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.trashLanes(in: 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")
/// **The column has one list, and the kind argument does not narrow it** (04-interactions.md
/// The trash, re-ruled 2026-07-31 kind-blind trash selection): navigation and ranging read the
/// same merged sequence, so a -range sweeps the rows of the other kind rather than skipping
/// them. The kind-scoped slices survive as `trashCards`/`trashLanes` for the consumers that
/// genuinely mean one kind, and they are still slices of the same order.
@Test("The trash's rows interleave by stamp, and `order(of:in:)` returns them whatever the kind")
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])
let rows = [card1, lane2, card2, lane3, card3]
#expect(SelectionGrammar.trashRows(in: snapshot) == rows)
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == rows)
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == rows)
// The kind-scoped slices are still slices of it.
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.trashLanes(in: 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)])
@@ -287,19 +315,21 @@ 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 {
/// **Inside the trash there is no kind boundary to cross** (04-interactions.md The trash,
/// re-ruled 2026-07-31, superseding the kind-homogeneous trash grammar): "within the trash cards
/// and lane rows select together clicks, -click ranges, -arrow extension, and the rubber
/// band all sweep every row". So a -click that used to replace now *extends*.
@Test("⌘-click adds a lane row to a card selection inside the trash")
func acrossKindInTheTrashExtends() 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))
#expect(ontoRow.selection == set([card1, 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))
#expect(ontoCard.selection == set([lane2, lane3, 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.
@@ -421,28 +451,29 @@ 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 {
/// **A trash range sweeps every row** (04-interactions.md The trash, re-ruled 2026-07-31
/// superseding the skip-by-kind ruling this suite used to pin): "-click ranges all sweep every
/// row". The kinds are not a boundary inside the container any more; the container still is.
@Test("A trash range sweeps every row between its endpoints, both kinds")
func trashRangeSweepsEveryRow() 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.
// The column reads [card1, lane2, card2, lane3, card3]: a range from the top to the bottom
// now takes all five rows rather than stepping over the two 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))
#expect(cards.selection == set([card1, lane2, card2, lane3, card3], .trash))
// And a lane-row range takes the rows, skipping the card sitting between them.
// And a range anchored on a lane row picks up the card 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))
#expect(rows.selection == set([lane2, card2, 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.
// And a range aimed from a card to a lane row is now an ordinary range rather than a
// degraded plain click: both endpoints sit in the one list, so the anchor stays put.
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)
#expect(crossed.selection == set([card1, lane2, card2, lane3], .trash))
#expect(crossed.anchor == card1)
}
@Test("A range never crosses the container boundary")
@@ -483,12 +514,13 @@ struct MarqueeMathTests {
#expect(ids == [card1, card2])
}
/// **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() {
/// **In the trash the band sweeps every row** (04-interactions.md The trash, re-ruled
/// 2026-07-31, superseding the card-only band): "the rubber band [sweeps] every row (the band's
/// full-height backdrop covers both kinds)". A trashed lane row registers its frame like a card
/// does, so this needed only the kind filter to come off on that side the *container* filter
/// stays, and the band still never leaves the side it began on.
@Test("On the trash side the band takes every row, and stays on its own side")
func trashSideTakesEveryRow() {
let targets = [
Self.card(card1, 0, container: .trash),
MarqueeTarget(id: lane2, kind: .lane, container: .trash,
@@ -498,7 +530,7 @@ struct MarqueeMathTests {
]
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2])
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, lane2, card2])
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
}
@@ -637,17 +669,19 @@ struct SelectAllTests {
store.transient.isTrashVisible = true
// "With the trash visible and a non-empty trash selection, Select All selects all visible
// trash cards" (04 The map, resettled 2026-07-28). There is no kind clause left to honour.
// trash rows" (04 The map; kind-blind since 2026-07-31). This container holds only cards,
// so rows and cards coincide the mixed case is the test below.
store.select([card2], in: .trash)
store.selectAll()
#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 {
/// **In the trash, "all" is all rows** (04-interactions.md The trash and 11-command-nexus.md
/// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
/// trash selection selects **all visible trash rows**"). The live board's own Select All stays
/// card-scoped, which the board branch above pins.
@Test("Select All in the trash takes every row, lane rows included")
func trashBranchTakesEveryRow() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
@@ -656,9 +690,7 @@ struct SelectAllTests {
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))
#expect(store.selection == set([card1, lane2, card2, lane3, card3], .trash))
}
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")