Files
lanework/KanbanTests/SelectionGrammarTests.swift
T
rzen c339b4cecf Implement live accessibility announcements
The board speaks when files change under the user, per DESIGN/10 § Live
board announcements. BoardDiff is the pure snapshot summarizer (identity
sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so
pro-m1's semantic commit engine can build on it; edited = rendered
content only, moved beats edited, implied events don't steal the
subject). BoardAnnouncer is the decision seam: focusOutcome computes the
vanishing-focus sentence and the walk-up-then-sideways recovery (next
lane by order, else previous, board container only when none remain,
never the trash); speech(for:) is the one-sentence precedence ladder —
raised condition > bracket completion > cleared condition > vanished
focus > digest — foreign-only for the last two rungs, so app-mediated
echoes stay silent.

BoardStore.land assembles ReloadFacts and posts exactly one sentence per
reload through the injectable announce outlet (AccessibilityAnnouncer,
medium priority, never interrupting). Selection recovery layers on top
of ItemReferenceSet re-resolution — survivors veto, the emptied
selection lands on the vanished item's lane and re-arms ⌘N's active-lane
memory. performWholesale(announcing:) arms a completion phrase consumed
by the closing reload — nil on every base bracket today; pro-m1 fills
git phrasings. Locks raised outside the reload path (vanished root,
unwritable location) announce through the same ladder, and the banner
strip is a labeled "Board status" container whose row labels are the
announced sentences (AccessibilityPhrases.bannerLabel — one string for
eye and ear).

Announcements classify at reload granularity (WatchOrigin) as a
deliberate interim: DESIGN/02's EchoLedger (per-file classification, the
announcer's specified input, git-free) was scheduled with the
auto-committer that the edition split moved to pro-m1 — filed on the
Redesign board for a ruling. 1533 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 08:15:53 -04:00

590 lines
26 KiB
Swift

import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// 04-interactions.md § Selection's pointer grammar and § The trash's extensions to it, branch by
/// branch — plus the anchor that makes ⇧-click mean anything, and the rubber band's arithmetic.
///
/// 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 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.
private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
}
/// Three lanes and five cards — enough that a flatten-order range crosses two lane boundaries.
///
/// Flatten order is `[card1, card2, card3, card4, card5]`.
@MainActor
private func makeLiveBoard() 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.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)", 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 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)/\(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
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
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 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, _ container: ItemContainer = .board) -> SelectionTarget {
SelectionTarget(id: id, kind: kind, container: container)
}
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.
private func click(
_ target: SelectionTarget,
_ modifier: ClickModifier,
selection: ItemReferenceSet = .empty,
anchor: ItemID? = nil,
in snapshot: BoardModel,
togglesOnRepeat: Bool = false
) -> SelectionGrammar.Outcome {
SelectionGrammar.click(
target,
modifier: modifier,
selection: selection,
anchor: anchor,
snapshot: snapshot,
togglesOnRepeat: togglesOnRepeat
)
}
// MARK: - The order lists
@MainActor
@Suite("SelectionGrammar ▸ order")
struct SelectionOrderTests {
@Test("Board cards flatten lane order first, then card order")
func flattenOrder() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// "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.boardCards(in: snapshot) == [card1, card2, card3, card4, card5])
#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")
func trashOrder() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#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")
func kindDerivation() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#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 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([ItemID(rawValue: Ident.indexless), card1]), in: snapshot) == .card)
}
}
// MARK: - Plain
@MainActor
@Suite("SelectionGrammar ▸ plain click")
struct PlainClickTests {
@Test("A plain click replaces the selection and becomes the anchor")
func replacesAndAnchors() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let outcome = click(target(card3, .card), .plain, selection: set([card1, card2]), anchor: card1, in: snapshot)
#expect(outcome.selection == set([card3]))
#expect(outcome.anchor == card3)
}
@Test("Click again unselects — but only where the design gives that behaviour, and only on a sole selection")
func toggleOffIsSoleMembershipOnly() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// "Single click selects the lane (click again to unselect)", and the header "toggles like
// empty space (settled)".
let off = click(target(lane1, .lane), .plain, selection: set([lane1]), anchor: lane1, in: snapshot, togglesOnRepeat: true)
#expect(off.selection.isEmpty)
#expect(off.anchor == nil)
// A multi-lane selection containing this lane is *narrowed*, not wiped: the toggle is about
// the lane the user already had alone, not about a set they built with ⌘.
let narrowed = click(target(lane1, .lane), .plain, selection: set([lane1, lane2]), anchor: lane2, in: snapshot, togglesOnRepeat: true)
#expect(narrowed.selection == set([lane1]))
#expect(narrowed.anchor == lane1)
// A card face never toggles off — Finder does not deselect a file by clicking it twice.
let card = click(target(card1, .card), .plain, selection: set([card1]), anchor: card1, in: snapshot)
#expect(card.selection == set([card1]))
#expect(card.anchor == card1)
// 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]))
}
}
// MARK: - Command
@MainActor
@Suite("SelectionGrammar ▸ ⌘-click")
struct CommandClickTests {
@Test("⌘-click toggles within one kind, and the click is the new anchor either way")
func togglesWithinKind() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let added = click(target(card3, .card), .command, selection: set([card1]), anchor: card1, in: snapshot)
#expect(added.selection == set([card1, card3]))
#expect(added.anchor == card3)
let removed = click(target(card1, .card), .command, selection: set([card1, card3]), anchor: card3, in: snapshot)
#expect(removed.selection == set([card3]))
#expect(removed.anchor == card1)
}
@Test("Toggling the last member out leaves nothing selected and nothing to range from")
func emptyingClearsTheAnchor() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let outcome = click(target(card1, .card), .command, selection: set([card1]), anchor: card1, in: snapshot)
#expect(outcome.selection.isEmpty)
#expect(outcome.anchor == nil)
}
@Test("⌘-click across the kind boundary replaces — a selection is never mixed")
func acrossKindReplaces() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// "Selection is homogeneous: cards XOR lanes" (04-interactions.md § Selection).
let ontoLane = click(target(lane2, .lane), .command, selection: set([card1, card2]), anchor: card2, in: snapshot)
#expect(ontoLane.selection == set([lane2]))
#expect(ontoLane.anchor == lane2)
let ontoCard = click(target(card1, .card), .command, selection: set([lane1, lane2]), anchor: lane2, in: snapshot)
#expect(ontoCard.selection == set([card1]))
#expect(ontoCard.anchor == card1)
}
@Test("⌘-click across the container boundary replaces too")
func acrossContainerReplaces() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// "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(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")
func emptyOrGhostSelectionReplaces() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let fromEmpty = click(target(card1, .card), .command, in: snapshot)
#expect(fromEmpty.selection == set([card1]))
#expect(fromEmpty.anchor == card1)
// 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 ghost = ItemID(rawValue: Ident.indexless)
let fromGhost = click(target(card1, .card), .command, selection: set([ghost]), anchor: ghost, in: snapshot)
#expect(fromGhost.selection == set([card1]))
}
}
// MARK: - Shift
@MainActor
@Suite("SelectionGrammar ▸ ⇧-click")
struct ShiftClickTests {
@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, card4, card5]))
// Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin.
#expect(outcome.anchor == card1)
}
@Test("Direction does not matter — the range is the span between anchor and target")
func rangeIsDirectionless() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
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, card4, card5]))
#expect(backwards.anchor == card5)
}
@Test("Lanes range in their own order list")
func laneRange() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let outcome = click(target(lane3, .lane), .shift, selection: set([lane1]), anchor: lane1, in: snapshot)
#expect(outcome.selection == set([lane1, lane2, lane3]))
#expect(outcome.anchor == lane1)
}
@Test("An invalid anchor makes ⇧ a plain click — nil, vanished, or across a boundary")
func invalidAnchorActsPlain() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// Nothing to range from.
let noAnchor = click(target(card3, .card), .shift, selection: set([card1]), in: snapshot)
#expect(noAnchor.selection == set([card3]))
#expect(noAnchor.anchor == card3)
// 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)
// An anchor in the *other* kind's list: the two lists are disjoint, so the anchor is not
// findable and the click degrades — never a mixed range.
let acrossKind = click(target(card3, .card), .shift, selection: set([lane1]), anchor: lane1, in: snapshot)
#expect(acrossKind.selection == set([card3]))
#expect(acrossKind.anchor == card3)
// 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 range walks the column's own order")
func trashRangeWalksTheColumn() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// 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 range never crosses the container boundary")
func trashRangeStopsAtTheBoundary() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// `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)
}
}
// MARK: - The rubber band
@Suite("MarqueeMath")
struct MarqueeMathTests {
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))
}
@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, 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, container: .trash)
]
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, in: .board)
#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")
func trashSideTakesItsOwnCards() {
let targets = [
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)
#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, in: .board).isEmpty)
#expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty)
}
}
// MARK: - The anchor's storage and its reload rule
/// One foreign reload, start to settled — `TransientBoardStateTests`' helper, borrowed for the one
/// piece of transient state this card adds.
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
@MainActor
@Suite("TransientBoardState ▸ the selection anchor")
struct SelectionAnchorTests {
@Test("A sole selection anchors itself; a wholesale one does not; clearing drops it")
func anchorDefaults() {
let state = TransientBoardState()
state.select([card1], in: .board)
#expect(state.selectionAnchor == card1)
// "A marquee and wholesale selections pass no anchor deliberately."
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], in: .board, anchor: card2)
#expect(state.selectionAnchor == card2)
state.clearSelection()
#expect(state.selectionAnchor == nil)
}
@Test("A vanished anchor is dropped by the reload, and a surviving one is kept")
func vanishedAnchorIsDropped() async throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
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)
#expect(store.selection.ids == [card1])
#expect(store.transient.selectionAnchor == card1)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
// The anchor's card is gone — the rule this test exists for. The selection is not empty
// afterwards because the reload emptied it *and* took the cursor with it, which is
// 10-accessibility.md's vanishing-focus case: focus recovers to the card's lane, and a
// one-item selection made by any route is its own anchor (`BoardAnnouncerStoreTests`).
#expect(store.selection.ids == [lane1])
#expect(store.transient.selectionAnchor == lane1)
}
@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], in: .board, anchor: card1)
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
// "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)
}
@Test("An anchor no longer in the selection still ranges — membership is not the rule")
func anchorNeedNotBeSelected() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let state = TransientBoardState()
// A ⌘-click that toggled the anchor's own row out leaves the anchor standing.
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]))
}
}
// MARK: - Select All
@MainActor
@Suite("BoardStore ▸ Select All")
struct SelectAllTests {
@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], in: .board)
store.selectAll()
// "All visible cards on the board" (04-interactions.md ▸ The map).
#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)
}
@Test("An anchor inside the new set survives Select All")
func anchorSurvivesWhenStillInside() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card3], in: .board)
store.selectAll()
#expect(store.transient.selectionAnchor == card3)
}
@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
// "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], .trash))
}
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
func trashBranchFallsThrough() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Hidden — the column is invisible to every gesture (04 ▸ The trash).
store.select([card1], in: .trash)
store.selectAll()
#expect(store.selection.container == .board)
// Shown, but nothing in the trash is selected.
store.transient.isTrashVisible = true
store.clearSelection()
store.selectAll()
#expect(store.selection.container == .board)
// 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.container == .board)
}
@Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
func emptyBoardClears() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let store = try BoardStore(rootURL: fixture.root)
store.select([lane1], in: .board)
store.selectAll()
#expect(store.selection.isEmpty)
#expect(store.transient.selectionAnchor == nil)
}
}