Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -8,40 +8,27 @@ import Testing
|
||||
///
|
||||
/// The grammar is written as a pure function precisely so it can be tested like one: a click, a
|
||||
/// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no
|
||||
/// modifier flags. The boards underneath are **real loads off real temp trees**, because every rule
|
||||
/// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel`
|
||||
/// would let all three drift from what the loader actually produces.
|
||||
/// modifier flags. The boards underneath are **real loads off real temp trees**, because the rules
|
||||
/// 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.
|
||||
///
|
||||
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's
|
||||
/// interleaving needs entries whose folder names are distinguishable in the sort's tie-break.
|
||||
/// More literal identities than `Ident` offers: a three-lane range needs five cards.
|
||||
private enum More {
|
||||
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
}
|
||||
|
||||
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
deleted: \(deleted)
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// Three live lanes, five live cards and one tombstoned card — enough that a flatten-order range
|
||||
/// crosses two lane boundaries and has something to *skip* on the way.
|
||||
/// Three lanes and five cards — enough that a flatten-order range crosses two lane boundaries.
|
||||
///
|
||||
/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own
|
||||
/// `deleted:` and is in none of it.
|
||||
/// Flatten order is `[card1, card2, card3, card4, card5]`.
|
||||
@MainActor
|
||||
private func makeLiveBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
@@ -51,27 +38,23 @@ private func makeLiveBoard() throws -> WriterFixture {
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", tombstoned(order: "2048", title: "Fourth"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
|
||||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||||
try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips.
|
||||
///
|
||||
/// `TrashModel`'s sort is newest `deleted` first, so the entry order is
|
||||
/// `[card1, laneX, card2, laneY, card3]` — a card range from `card1` to `card3` has two lane rows
|
||||
/// sitting inside its span, and a lane range from `laneX` to `laneY` has a card row inside its own.
|
||||
/// 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).
|
||||
@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)/\(Ident.card1)", tombstoned(order: "1024", title: "First", deleted: "2026-03-05T10:00:00Z"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z"))
|
||||
try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z"))
|
||||
try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z"))
|
||||
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"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -83,19 +66,18 @@ private let card2 = ItemID(rawValue: Ident.card2)
|
||||
private let card3 = ItemID(rawValue: Ident.card3)
|
||||
private let card4 = ItemID(rawValue: Ident.card4)
|
||||
private let card5 = ItemID(rawValue: More.card5)
|
||||
private let laneX = ItemID(rawValue: More.laneX)
|
||||
private let laneY = ItemID(rawValue: More.laneY)
|
||||
private let card6 = ItemID(rawValue: More.card6)
|
||||
|
||||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: fixture.root).model
|
||||
}
|
||||
|
||||
private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget {
|
||||
SelectionTarget(id: id, kind: kind, side: side)
|
||||
private func target(_ id: ItemID, _ kind: SelectionKind, _ container: ItemContainer = .board) -> SelectionTarget {
|
||||
SelectionTarget(id: id, kind: kind, container: container)
|
||||
}
|
||||
|
||||
private func set(_ ids: Set<ItemID>, _ side: Liveness = .live) -> ItemReferenceSet {
|
||||
ItemReferenceSet(ids: ids, liveness: side)
|
||||
private func set(_ ids: Set<ItemID>, _ container: ItemContainer = .board) -> ItemReferenceSet {
|
||||
ItemReferenceSet(ids: ids, container: container)
|
||||
}
|
||||
|
||||
/// One click, with the grammar's own defaults filled in.
|
||||
@@ -123,7 +105,7 @@ private func click(
|
||||
@Suite("SelectionGrammar ▸ order")
|
||||
struct SelectionOrderTests {
|
||||
|
||||
@Test("Live cards flatten lane order first, then card order — tombstones excluded")
|
||||
@Test("Board cards flatten lane order first, then card order")
|
||||
func flattenOrder() throws {
|
||||
let fixture = try makeLiveBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -131,22 +113,21 @@ struct SelectionOrderTests {
|
||||
|
||||
// "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right,
|
||||
// top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop).
|
||||
#expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5])
|
||||
#expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3])
|
||||
#expect(SelectionGrammar.boardCards(in: snapshot) == [card1, card2, card3, card4, card5])
|
||||
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
|
||||
}
|
||||
|
||||
@Test("Trash order lists are one sort, filtered to one kind")
|
||||
func trashOrderPerKind() throws {
|
||||
@Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
|
||||
func trashOrder() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
// The single ordering the quasi-lane shows, newest first.
|
||||
#expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3])
|
||||
// Each kind's list is that ordering with the other kind's rows dropped — which is exactly
|
||||
// what makes a ⇧-range step over them (04-interactions.md ▸ The trash).
|
||||
#expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3])
|
||||
#expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY])
|
||||
#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)
|
||||
}
|
||||
|
||||
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
|
||||
@@ -158,10 +139,10 @@ struct SelectionOrderTests {
|
||||
#expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card)
|
||||
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
|
||||
#expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil)
|
||||
// A tombstoned card is on neither side's live list, and the live side is what this set says.
|
||||
#expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil)
|
||||
// A set claiming a container that does not hold its members answers nothing.
|
||||
#expect(SelectionGrammar.kind(of: set([card1], .trash), in: snapshot) == nil)
|
||||
// Members that name nothing are ignored; one that names something still answers.
|
||||
#expect(SelectionGrammar.kind(of: set([card4, card1]), in: snapshot) == .card)
|
||||
#expect(SelectionGrammar.kind(of: set([ItemID(rawValue: Ident.indexless), card1]), in: snapshot) == .card)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,9 +186,9 @@ struct PlainClickTests {
|
||||
#expect(card.selection == set([card1]))
|
||||
#expect(card.anchor == card1)
|
||||
|
||||
// Nor does the toggle reach across the boundary: a live click on a trashed sole selection
|
||||
// of the same id is a replace.
|
||||
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trashed), anchor: lane1, in: snapshot, togglesOnRepeat: true)
|
||||
// Nor does the toggle reach across the boundary: a board click on a trash-side sole
|
||||
// selection of the same id is a replace.
|
||||
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trash), anchor: lane1, in: snapshot, togglesOnRepeat: true)
|
||||
#expect(crossed.selection == set([lane1]))
|
||||
}
|
||||
}
|
||||
@@ -260,24 +241,19 @@ struct CommandClickTests {
|
||||
#expect(ontoCard.anchor == card1)
|
||||
}
|
||||
|
||||
@Test("⌘-click across the liveness boundary replaces too")
|
||||
func acrossSideReplaces() throws {
|
||||
@Test("⌘-click across the container boundary replaces too")
|
||||
func acrossContainerReplaces() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
// "Selection is homogeneous by liveness … a selection never mixes live and tombstoned"
|
||||
// (04 ▸ The trash). The live lane1 is the only live thing on this board.
|
||||
let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot)
|
||||
#expect(intoTrash.selection == set([card1], .trashed))
|
||||
// "A selection never mixes trash cards with board cards — a single container rule replacing
|
||||
// the old liveness law" (04 ▸ The trash, resettled 2026-07-28).
|
||||
let intoTrash = click(target(card1, .card, .trash), .command, selection: set([card6]), anchor: card6, in: snapshot)
|
||||
#expect(intoTrash.selection == set([card1], .trash))
|
||||
|
||||
let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
|
||||
#expect(backOut.selection == set([lane1]))
|
||||
|
||||
// And within the trash, the second axis: card entries XOR lane entries.
|
||||
let ontoLaneEntry = click(target(laneX, .lane, .trashed), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
|
||||
#expect(ontoLaneEntry.selection == set([laneX], .trashed))
|
||||
#expect(ontoLaneEntry.anchor == laneX)
|
||||
let backOut = click(target(card6, .card), .command, selection: set([card1, card2], .trash), anchor: card2, in: snapshot)
|
||||
#expect(backOut.selection == set([card6]))
|
||||
}
|
||||
|
||||
@Test("⌘-click with nothing — or nothing real — selected replaces")
|
||||
@@ -292,7 +268,8 @@ struct CommandClickTests {
|
||||
|
||||
// A selection whose members all name nothing the board renders counts as empty: a ⌘-click
|
||||
// after a foreign delete starts a fresh set rather than extending a ghost.
|
||||
let fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot)
|
||||
let ghost = ItemID(rawValue: Ident.indexless)
|
||||
let fromGhost = click(target(card1, .card), .command, selection: set([ghost]), anchor: ghost, in: snapshot)
|
||||
#expect(fromGhost.selection == set([card1]))
|
||||
}
|
||||
}
|
||||
@@ -303,18 +280,16 @@ struct CommandClickTests {
|
||||
@Suite("SelectionGrammar ▸ ⇧-click")
|
||||
struct ShiftClickTests {
|
||||
|
||||
@Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, and leaves the anchor put")
|
||||
@Test("A ⇧-range spans the flatten order across lanes and leaves the anchor put")
|
||||
func rangeAcrossLanes() throws {
|
||||
let fixture = try makeLiveBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||
#expect(outcome.selection == set([card1, card2, card3, card5]))
|
||||
#expect(outcome.selection == set([card1, card2, card3, card4, card5]))
|
||||
// Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin.
|
||||
#expect(outcome.anchor == card1)
|
||||
// card4 is tombstoned and in no order list, so no range can pick it up.
|
||||
#expect(!outcome.selection.ids.contains(card4))
|
||||
}
|
||||
|
||||
@Test("Direction does not matter — the range is the span between anchor and target")
|
||||
@@ -324,7 +299,7 @@ struct ShiftClickTests {
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot)
|
||||
#expect(backwards.selection == set([card1, card2, card3, card5]))
|
||||
#expect(backwards.selection == set([card1, card2, card3, card4, card5]))
|
||||
#expect(backwards.anchor == card5)
|
||||
}
|
||||
|
||||
@@ -350,8 +325,9 @@ struct ShiftClickTests {
|
||||
#expect(noAnchor.selection == set([card3]))
|
||||
#expect(noAnchor.anchor == card3)
|
||||
|
||||
// An anchor that names nothing the board renders (card4 is tombstoned).
|
||||
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot)
|
||||
// An anchor that names nothing the board renders.
|
||||
let ghost = ItemID(rawValue: Ident.indexless)
|
||||
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: ghost, in: snapshot)
|
||||
#expect(vanished.selection == set([card3]))
|
||||
#expect(vanished.anchor == card3)
|
||||
|
||||
@@ -361,32 +337,35 @@ struct ShiftClickTests {
|
||||
#expect(acrossKind.selection == set([card3]))
|
||||
#expect(acrossKind.anchor == card3)
|
||||
|
||||
// Same for a side crossing: the live card list holds no trash row.
|
||||
let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||
#expect(acrossSide.selection == set([card3], .trashed))
|
||||
// Same for a container crossing: the board's card list holds no trash card.
|
||||
let acrossContainer = click(target(card3, .card, .trash), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||
#expect(acrossContainer.selection == set([card3], .trash))
|
||||
}
|
||||
|
||||
@Test("A trash card range steps over the lane entries inside its span")
|
||||
func trashCardRangeSkipsLaneEntries() throws {
|
||||
@Test("A trash range walks the column's own order")
|
||||
func trashRangeWalksTheColumn() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
// Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only.
|
||||
let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot)
|
||||
#expect(outcome.selection == set([card1, card2, card3], .trashed))
|
||||
// The column's order is `[card1, card2, card3]`; a range between the ends takes all three,
|
||||
// with no kind to step over — lanes are never trashed.
|
||||
let outcome = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||
#expect(outcome.selection == set([card1, card2, card3], .trash))
|
||||
#expect(outcome.anchor == card1)
|
||||
}
|
||||
|
||||
@Test("A trash lane range steps over the card entries inside its span")
|
||||
func trashLaneRangeSkipsCardEntries() throws {
|
||||
@Test("A range never crosses the container boundary")
|
||||
func trashRangeStopsAtTheBoundary() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot)
|
||||
#expect(outcome.selection == set([laneX, laneY], .trashed))
|
||||
#expect(outcome.anchor == laneX)
|
||||
// `card6` is on the board and `card1` is in the trash: no single order list holds both, so
|
||||
// the anchor is not findable and the ⇧-click degrades to a plain one.
|
||||
let outcome = click(target(card1, .card, .trash), .shift, selection: set([card6]), anchor: card6, in: snapshot)
|
||||
#expect(outcome.selection == set([card1], .trash))
|
||||
#expect(outcome.anchor == card1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,53 +374,46 @@ struct ShiftClickTests {
|
||||
@Suite("MarqueeMath")
|
||||
struct MarqueeMathTests {
|
||||
|
||||
private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget {
|
||||
MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
||||
private static func card(_ id: ItemID, _ y: CGFloat, container: ItemContainer = .board) -> MarqueeTarget {
|
||||
MarqueeTarget(id: id, kind: .card, container: container, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
||||
}
|
||||
|
||||
private static func laneEntry(_ id: ItemID, _ y: CGFloat) -> MarqueeTarget {
|
||||
MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
||||
}
|
||||
|
||||
@Test("On the live side the band takes intersecting cards, and only cards")
|
||||
func liveSideTakesCards() {
|
||||
@Test("On the board side the band takes intersecting cards, and only cards")
|
||||
func boardSideTakesCards() {
|
||||
let targets = [
|
||||
Self.card(card1, 0),
|
||||
Self.card(card2, 100),
|
||||
// A lane registered by mistake is still never swept: "click-drag rubber-bands across
|
||||
// lanes" (04-interactions.md § Selection) — across, not over.
|
||||
MarqueeTarget(id: lane1, kind: .lane, side: .live, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
|
||||
MarqueeTarget(id: lane1, kind: .lane, container: .board, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
|
||||
// A trash row cannot be reached by a band that began on the board.
|
||||
Self.card(card3, 10, side: .trashed)
|
||||
Self.card(card3, 10, container: .trash)
|
||||
]
|
||||
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live)
|
||||
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, in: .board)
|
||||
#expect(ids == [card1, card2])
|
||||
}
|
||||
|
||||
@Test("On the trash side the topmost intersecting row's kind wins")
|
||||
func trashSideIsHomogeneousByKind() {
|
||||
// Interleaved rows, the trash's own shape: card, lane, card.
|
||||
/// **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")
|
||||
func trashSideTakesItsOwnCards() {
|
||||
let targets = [
|
||||
Self.card(card1, 0, side: .trashed),
|
||||
Self.laneEntry(laneX, 50),
|
||||
Self.card(card2, 100, side: .trashed)
|
||||
Self.card(card1, 0, container: .trash),
|
||||
Self.card(card2, 100, container: .trash),
|
||||
Self.card(card3, 50)
|
||||
]
|
||||
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
|
||||
|
||||
// Begun on a card row: the lane row between the two cards is stepped over, exactly as a
|
||||
// ⇧-range does (04-interactions.md ▸ The trash).
|
||||
#expect(MarqueeMath.selection(rect: all, targets: targets, side: .trashed) == [card1, card2])
|
||||
|
||||
// Begun below it, so the lane row is topmost: only lane entries come back.
|
||||
let lower = CGRect(x: 0, y: 60, width: 50, height: 200)
|
||||
#expect(MarqueeMath.selection(rect: lower, targets: targets, side: .trashed) == [laneX])
|
||||
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2])
|
||||
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
|
||||
}
|
||||
|
||||
@Test("A band touching nothing selects nothing")
|
||||
func emptyBand() {
|
||||
let targets = [Self.card(card1, 0), Self.card(card2, 100)]
|
||||
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, side: .live).isEmpty)
|
||||
#expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty)
|
||||
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, in: .board).isEmpty)
|
||||
#expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,15 +435,15 @@ struct SelectionAnchorTests {
|
||||
func anchorDefaults() {
|
||||
let state = TransientBoardState()
|
||||
|
||||
state.select([card1], liveness: .live)
|
||||
state.select([card1], in: .board)
|
||||
#expect(state.selectionAnchor == card1)
|
||||
|
||||
// "A marquee and wholesale selections pass no anchor deliberately."
|
||||
state.select([card1, card2], liveness: .live)
|
||||
state.select([card1, card2], in: .board)
|
||||
#expect(state.selectionAnchor == nil)
|
||||
|
||||
// An explicit anchor wins over the default in both directions.
|
||||
state.select([card1, card2, card3], liveness: .live, anchor: card2)
|
||||
state.select([card1, card2, card3], in: .board, anchor: card2)
|
||||
#expect(state.selectionAnchor == card2)
|
||||
|
||||
state.clearSelection()
|
||||
@@ -484,7 +456,7 @@ struct SelectionAnchorTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([card1, card2], liveness: .live, anchor: card1)
|
||||
store.select([card1, card2], in: .board, anchor: card1)
|
||||
// A survivor of the same reload proves the rule is about the anchor, not about reloading.
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||||
await reload(store)
|
||||
@@ -497,17 +469,18 @@ struct SelectionAnchorTests {
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
}
|
||||
|
||||
@Test("A liveness flip is a vanish for the anchor too")
|
||||
func flippedAnchorIsDropped() async throws {
|
||||
@Test("A container crossing is a vanish for the anchor too")
|
||||
func crossedAnchorIsDropped() async throws {
|
||||
let fixture = try makeLiveBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([card1, card2], liveness: .live, anchor: card1)
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
||||
store.select([card1, card2], in: .board, anchor: card1)
|
||||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||
await reload(store)
|
||||
|
||||
// "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule).
|
||||
// "A container crossing is a vanish for this purpose" (02-architecture.md's reload rule,
|
||||
// resettled 2026-07-28).
|
||||
#expect(store.selection.ids == [card2])
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
}
|
||||
@@ -520,7 +493,7 @@ struct SelectionAnchorTests {
|
||||
let state = TransientBoardState()
|
||||
|
||||
// A ⌘-click that toggled the anchor's own row out leaves the anchor standing.
|
||||
state.select([card2, card3], liveness: .live, anchor: card1)
|
||||
state.select([card2, card3], in: .board, anchor: card1)
|
||||
#expect(state.selectionAnchor == card1)
|
||||
let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
|
||||
#expect(outcome.selection == set([card1, card2, card3]))
|
||||
@@ -533,17 +506,17 @@ struct SelectionAnchorTests {
|
||||
@Suite("BoardStore ▸ Select All")
|
||||
struct SelectAllTests {
|
||||
|
||||
@Test("Select All takes every rendered card — tombstones excluded, lanes never")
|
||||
@Test("Select All takes every rendered card, and never a lane")
|
||||
func liveBranch() throws {
|
||||
let fixture = try makeLiveBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([lane2], liveness: .live)
|
||||
store.select([lane2], in: .board)
|
||||
store.selectAll()
|
||||
|
||||
// "All visible cards on the board" (04-interactions.md ▸ The map).
|
||||
#expect(store.selection == set([card1, card2, card3, card5]))
|
||||
#expect(store.selection == set([card1, card2, card3, card4, card5]))
|
||||
// The lane the anchor named is not in the new set, so the anchor goes with it.
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
}
|
||||
@@ -554,25 +527,23 @@ struct SelectAllTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([card3], liveness: .live)
|
||||
store.select([card3], in: .board)
|
||||
store.selectAll()
|
||||
#expect(store.transient.selectionAnchor == card3)
|
||||
}
|
||||
|
||||
@Test("On the trash side Select All stays within the selection's kind")
|
||||
func trashBranchIsHomogeneousByKind() throws {
|
||||
@Test("On the trash side Select All takes every visible trash card")
|
||||
func trashBranchTakesTheColumn() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.isTrashVisible = true
|
||||
|
||||
store.select([card2], liveness: .trashed)
|
||||
// "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.
|
||||
store.select([card2], in: .trash)
|
||||
store.selectAll()
|
||||
#expect(store.selection == set([card1, card2, card3], .trashed))
|
||||
|
||||
store.select([laneX], liveness: .trashed)
|
||||
store.selectAll()
|
||||
#expect(store.selection == set([laneX, laneY], .trashed))
|
||||
#expect(store.selection == set([card1, card2, card3], .trash))
|
||||
}
|
||||
|
||||
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
||||
@@ -582,20 +553,20 @@ struct SelectAllTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// Hidden — the column is invisible to every gesture (04 ▸ The trash).
|
||||
store.select([card1], liveness: .trashed)
|
||||
store.select([card1], in: .trash)
|
||||
store.selectAll()
|
||||
#expect(store.selection.liveness == .live)
|
||||
#expect(store.selection.container == .board)
|
||||
|
||||
// Shown, but nothing tombstoned is selected.
|
||||
// Shown, but nothing in the trash is selected.
|
||||
store.transient.isTrashVisible = true
|
||||
store.clearSelection()
|
||||
store.selectAll()
|
||||
#expect(store.selection.liveness == .live)
|
||||
#expect(store.selection.container == .board)
|
||||
|
||||
// Shown, trashed side, but the ids name no row: a guess would be worse than the board.
|
||||
store.select([card5], liveness: .trashed)
|
||||
// Shown, trash side, but the ids name no card there: a guess would be worse than the board.
|
||||
store.select([card5], in: .trash)
|
||||
store.selectAll()
|
||||
#expect(store.selection.liveness == .live)
|
||||
#expect(store.selection.container == .board)
|
||||
}
|
||||
|
||||
@Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
|
||||
@@ -606,7 +577,7 @@ struct SelectAllTests {
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([lane1], liveness: .live)
|
||||
store.select([lane1], in: .board)
|
||||
store.selectAll()
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
|
||||
Reference in New Issue
Block a user