Implement the selection model
The full pointer grammar of 04-interactions.md § Selection, stated once as a pure function (SelectionGrammar) and reached through one store funnel from every click surface — card face, lane header, lane empty space, trash row: - Plain click replaces and anchors; the lane surfaces (header and empty space alike, per the settled one-lane-click-behavior rule) toggle off on a sole-membership repeat. - ⌘-click toggles within a homogeneous set; crossing any axis — cards XOR lanes, live XOR trashed, card entries XOR lane entries in the trash — degrades to a replace, so no click can produce a mixed selection. - ⇧-click ranges from the anchor in the (side, kind) order list: flatten order for cards, lane order for lanes, the trash's deterministic sort filtered to kind — the pointer twin of the keyboard's boundary rule (the keyboard goes inert, the pointer skips). - The rubber band (MarqueeSession/MarqueeMath) arms from lane empty space, the board backdrop, and the trash column; side frozen at the origin, trash bands homogeneous by topmost kind, frames self-registered in strip space, geometric begin guard, never animated. - Fast plain double-click opens the card window (⌘↩'s pointer twin); Select All answers the standard Edit menu item via the responder chain, trash- and kind-respecting. - The range anchor lives in TransientBoardState beside the selection and obeys the same reload vanish rule. 659 unit tests (28 new in SelectionGrammarTests). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
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 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.
|
||||
///
|
||||
/// `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.
|
||||
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"
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own
|
||||
/// `deleted:` and is in none of it.
|
||||
@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)", tombstoned(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.
|
||||
@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"))
|
||||
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 laneX = ItemID(rawValue: More.laneX)
|
||||
private let laneY = ItemID(rawValue: More.laneY)
|
||||
|
||||
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 set(_ ids: Set<ItemID>, _ side: Liveness = .live) -> ItemReferenceSet {
|
||||
ItemReferenceSet(ids: ids, liveness: side)
|
||||
}
|
||||
|
||||
/// 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("Live cards flatten lane order first, then card order — tombstones excluded")
|
||||
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.liveCards(in: snapshot) == [card1, card2, card3, card5])
|
||||
#expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3])
|
||||
}
|
||||
|
||||
@Test("Trash order lists are one sort, filtered to one kind")
|
||||
func trashOrderPerKind() 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])
|
||||
}
|
||||
|
||||
@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 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)
|
||||
// Members that name nothing are ignored; one that names something still answers.
|
||||
#expect(SelectionGrammar.kind(of: set([card4, 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 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)
|
||||
#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 liveness boundary replaces too")
|
||||
func acrossSideReplaces() 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))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@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 fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot)
|
||||
#expect(fromGhost.selection == set([card1]))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shift
|
||||
|
||||
@MainActor
|
||||
@Suite("SelectionGrammar ▸ ⇧-click")
|
||||
struct ShiftClickTests {
|
||||
|
||||
@Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, 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]))
|
||||
// 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")
|
||||
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, 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 (card4 is tombstoned).
|
||||
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, 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 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))
|
||||
}
|
||||
|
||||
@Test("A trash card range steps over the lane entries inside its span")
|
||||
func trashCardRangeSkipsLaneEntries() 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))
|
||||
#expect(outcome.anchor == card1)
|
||||
}
|
||||
|
||||
@Test("A trash lane range steps over the card entries inside its span")
|
||||
func trashLaneRangeSkipsCardEntries() 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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The rubber band
|
||||
|
||||
@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 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() {
|
||||
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)),
|
||||
// A trash row cannot be reached by a band that began on the board.
|
||||
Self.card(card3, 10, side: .trashed)
|
||||
]
|
||||
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live)
|
||||
#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.
|
||||
let targets = [
|
||||
Self.card(card1, 0, side: .trashed),
|
||||
Self.laneEntry(laneX, 50),
|
||||
Self.card(card2, 100, side: .trashed)
|
||||
]
|
||||
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])
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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], liveness: .live)
|
||||
#expect(state.selectionAnchor == card1)
|
||||
|
||||
// "A marquee and wholesale selections pass no anchor deliberately."
|
||||
state.select([card1, card2], liveness: .live)
|
||||
#expect(state.selectionAnchor == nil)
|
||||
|
||||
// An explicit anchor wins over the default in both directions.
|
||||
state.select([card1, card2, card3], liveness: .live, 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], liveness: .live, 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)
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
}
|
||||
|
||||
@Test("A liveness flip is a vanish for the anchor too")
|
||||
func flippedAnchorIsDropped() 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"))
|
||||
await reload(store)
|
||||
|
||||
// "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule).
|
||||
#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], liveness: .live, 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 — tombstones excluded, lanes never")
|
||||
func liveBranch() throws {
|
||||
let fixture = try makeLiveBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([lane2], liveness: .live)
|
||||
store.selectAll()
|
||||
|
||||
// "All visible cards on the board" (04-interactions.md ▸ The map).
|
||||
#expect(store.selection == set([card1, card2, card3, 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], liveness: .live)
|
||||
store.selectAll()
|
||||
#expect(store.transient.selectionAnchor == card3)
|
||||
}
|
||||
|
||||
@Test("On the trash side Select All stays within the selection's kind")
|
||||
func trashBranchIsHomogeneousByKind() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.isTrashVisible = true
|
||||
|
||||
store.select([card2], liveness: .trashed)
|
||||
store.selectAll()
|
||||
#expect(store.selection == set([card1, card2, card3], .trashed))
|
||||
|
||||
store.select([laneX], liveness: .trashed)
|
||||
store.selectAll()
|
||||
#expect(store.selection == set([laneX, laneY], .trashed))
|
||||
}
|
||||
|
||||
@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], liveness: .trashed)
|
||||
store.selectAll()
|
||||
#expect(store.selection.liveness == .live)
|
||||
|
||||
// Shown, but nothing tombstoned is selected.
|
||||
store.transient.isTrashVisible = true
|
||||
store.clearSelection()
|
||||
store.selectAll()
|
||||
#expect(store.selection.liveness == .live)
|
||||
|
||||
// Shown, trashed side, but the ids name no row: a guess would be worse than the board.
|
||||
store.select([card5], liveness: .trashed)
|
||||
store.selectAll()
|
||||
#expect(store.selection.liveness == .live)
|
||||
}
|
||||
|
||||
@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], liveness: .live)
|
||||
store.selectAll()
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user