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
701 lines
32 KiB
Swift
701 lines
32 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, 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`.
|
|
|
|
// 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
|
|
}
|
|
|
|
/// 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)
|
|
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`; a trash with no lane rows has no lane list")
|
|
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])
|
|
#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()
|
|
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)
|
|
}
|
|
|
|
/// 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()
|
|
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)
|
|
}
|
|
|
|
/// "⇧-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()
|
|
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])
|
|
}
|
|
|
|
/// **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)
|
|
]
|
|
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))
|
|
}
|
|
|
|
/// "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()
|
|
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)
|
|
}
|
|
}
|