CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.
ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.
LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).
Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).
Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.
Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1112 lines
51 KiB
Swift
1112 lines
51 KiB
Swift
import CoreGraphics
|
||
import Foundation
|
||
import Observation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The keyboard grammar's pure halves — 04-interactions.md ▸ Grammar's spatial navigation
|
||
/// (`NavigationMath`), ▸ The map's within-lane sort (`SortMath`) and its successor-on-delete rule
|
||
/// (`SelectionGrammar.successor`), plus the navigation head the arrows step from
|
||
/// (`TransientBoardState.selectionHead`).
|
||
///
|
||
/// The arrow *handlers* are deliberately absent: they are dispatch over these functions and a
|
||
/// registry of drawn frames, so everything with a rule in it is here and the views hold nothing that
|
||
/// could be asserted without a window.
|
||
///
|
||
/// The board-level suites drive a **real `BoardStore` over a real temp tree** and read the result
|
||
/// back off disk, the write suites' rule — a sort is only correct if the bytes say so.
|
||
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||
|
||
// MARK: - Identities
|
||
|
||
/// Two more card identities than `Ident` offers: the successor rule needs a *second* multi-card lane
|
||
/// to prove it reads the last selected member's lane rather than the first's.
|
||
private enum More {
|
||
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||
/// A card an agent files mid-navigation — a reload that changes the board without disturbing the
|
||
/// cursor, which the sticky ordinal's survival rule needs.
|
||
static let filed = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||
}
|
||
|
||
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)
|
||
|
||
/// Four cards in one lane, two in the next, and an empty third — the shapes every rule below needs:
|
||
/// a block with room on both sides, a second container to be redirected into, and a lane that
|
||
/// contributes nothing to card navigation.
|
||
@MainActor
|
||
private func makeBoard() 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.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "4096", title: "Fourth"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
try fixture.item("\(Ident.lane2)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
|
||
try fixture.item("\(Ident.lane2)/\(More.card6)", Item.rich(order: "2048", title: "Sixth"))
|
||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||
return fixture
|
||
}
|
||
|
||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||
try BoardLoader.load(boardRoot: fixture.root).model
|
||
}
|
||
|
||
/// The ids a lane renders, top to bottom, as they are **on disk right now**.
|
||
private func cardOrder(_ laneID: String, in fixture: WriterFixture) throws -> [ItemID] {
|
||
let model = try load(fixture)
|
||
let lane = try #require(model.lanes.first { $0.id.rawValue == laneID })
|
||
return lane.cards.filter { !$0.isDeleted }.map(\.id)
|
||
}
|
||
|
||
// MARK: - Frames
|
||
|
||
/// A drawn frame, with the two axes the score reads spelled out at the call site.
|
||
private func target(
|
||
_ id: ItemID,
|
||
x: CGFloat,
|
||
y: CGFloat,
|
||
width: CGFloat = 100,
|
||
height: CGFloat = 100,
|
||
kind: SelectionKind = .card,
|
||
container: ItemContainer = .board
|
||
) -> MarqueeTarget {
|
||
MarqueeTarget(id: id, kind: kind, container: container, frame: CGRect(x: x, y: y, width: width, height: height))
|
||
}
|
||
|
||
/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them — the smallest
|
||
/// board shape with an interior column *and* a lane boundary to cross.
|
||
private let grid: [MarqueeTarget] = [
|
||
target(card1, x: 0, y: 0),
|
||
target(card2, x: 0, y: 120),
|
||
target(card3, x: 120, y: 0),
|
||
target(card4, x: 120, y: 120)
|
||
]
|
||
|
||
private let originFrame = CGRect(x: 0, y: 0, width: 100, height: 100)
|
||
|
||
// MARK: - NavigationMath
|
||
|
||
@Suite("NavigationMath ▸ nearest in the direction")
|
||
struct NavigationMathTests {
|
||
|
||
@Test("Each direction picks its own neighbour")
|
||
func fourDirections() {
|
||
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .down, among: grid) == card2)
|
||
#expect(NavigationMath.nearest(from: grid[1].frame, direction: .up, among: grid) == card1)
|
||
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .right, among: grid) == card3)
|
||
#expect(NavigationMath.nearest(from: grid[2].frame, direction: .left, among: grid) == card1)
|
||
}
|
||
|
||
@Test("A card straight ahead beats a nearer one off to the side — orthogonal drift costs double")
|
||
func orthogonalDriftIsPenalised() {
|
||
// Straight down at 100pt of primary distance (score 100) versus 40pt down but 200pt across
|
||
// (score 40 + 400). Without the penalty the second would win and ↓ would wander out of the
|
||
// column instead of walking it (04-interactions.md ▸ Grammar).
|
||
let straight = target(card2, x: 0, y: 100)
|
||
let sideways = target(card3, x: 400, y: 40)
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [straight, sideways]) == card2)
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [sideways, straight]) == card2)
|
||
}
|
||
|
||
@Test("A tie is broken by position, then identity — and the input order never decides")
|
||
func tiesAreDeterministic() {
|
||
// Both score 50 + 2 × 50: same primary distance, same drift, opposite sides.
|
||
let right = target(card2, x: 50, y: 50)
|
||
let left = target(card3, x: -50, y: 50)
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [right, left]) == card3)
|
||
#expect(
|
||
NavigationMath.nearest(from: originFrame, direction: .down, among: [left, right]) == card3,
|
||
"reversing the candidate list must not change the answer"
|
||
)
|
||
|
||
// Same frame twice: position cannot separate them, so identity does.
|
||
let low = target(ItemID(rawValue: "aaaa"), x: 0, y: 200)
|
||
let high = target(ItemID(rawValue: "zzzz"), x: 0, y: 200)
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [high, low])?.rawValue == "aaaa")
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [low, high])?.rawValue == "aaaa")
|
||
}
|
||
|
||
@Test("Nothing beyond the origin in that direction is nil, and the origin never picks itself")
|
||
func noCandidate() {
|
||
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .up, among: grid) == nil)
|
||
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .left, among: grid) == nil)
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: []) == nil)
|
||
// A candidate level with the origin is not beyond it: the 1pt threshold excludes the origin
|
||
// itself and its exact row-mates.
|
||
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [target(card2, x: 300, y: 0)]) == nil)
|
||
}
|
||
|
||
@Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
|
||
func predicateRestrictsCandidates() {
|
||
let trashed = target(card2, x: 0, y: 100, container: .trash)
|
||
let live = target(card3, x: 0, y: 400)
|
||
let all = [trashed, live]
|
||
|
||
#expect(
|
||
NavigationMath.nearest(from: originFrame, direction: .down, among: all) == card2,
|
||
"a plain arrow walks across the boundary"
|
||
)
|
||
#expect(
|
||
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
|
||
)
|
||
}
|
||
|
||
/// **Inside the trash, plain arrows walk every row and extension stops at the kind boundary**
|
||
/// (04-interactions.md ▸ The trash: "plain arrows walk across, extension stops"), which is the two
|
||
/// halves of the arrow handler expressed over one registry of drawn frames — a lane row registers
|
||
/// like a card face precisely so ↓ can reach it.
|
||
@Test("A trash lane row is a plain arrow's neighbour, and an extension's dead end")
|
||
func trashLaneRowsAreNavigableButNotExtendable() {
|
||
let origin = target(card1, x: 0, y: 0, container: .trash)
|
||
let row = target(lane2, x: 0, y: 120, kind: .lane, container: .trash)
|
||
let below = target(card2, x: 0, y: 240, container: .trash)
|
||
let all = [origin, row, below]
|
||
|
||
// Plain: the next row down, whatever its kind.
|
||
#expect(NavigationMath.nearest(from: origin.frame, direction: .down, among: all) == lane2)
|
||
|
||
// ⇧: the handler takes the *same* unrestricted neighbour and then tests it, so a crossing
|
||
// row makes the press inert rather than being stepped over in search of a legal one — the
|
||
// rule exists so a held range is never silently widened past what the user asked for.
|
||
let next = NavigationMath.nearest(from: origin.frame, direction: .down, among: all)
|
||
#expect(next == lane2)
|
||
#expect(row.kind != origin.kind, "so the extension stops here")
|
||
|
||
// From the row itself, ↓ reaches the card below it: navigation crosses back.
|
||
#expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2)
|
||
}
|
||
|
||
/// **A collapsed lane is scanned past by the absolute destinations** (03-board-ui.md § Lane ▸
|
||
/// Collapsed lanes: "cards inside are not rendered — they are excluded from … keyboard spatial
|
||
/// navigation"), which is what ⌥←/⌥→ and the empty-selection seed land through.
|
||
///
|
||
/// The *relative* half — a plain or ⇧ arrow — needs nothing: a folded lane registers no card frame,
|
||
/// so `nearest` has no candidate to reject, exactly as the hidden trash has none.
|
||
/// `@MainActor` where its neighbours are not, and only because the fixture is: a board on disk is
|
||
/// the honest way to ask what a *read-side* fold reads as, and `WriterFixture` is main-actor bound.
|
||
@MainActor
|
||
@Test("The absolute destinations scan past a folded lane, an empty one, and one the query emptied")
|
||
func firstCardSkipsFoldedLanes() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
// Lane 1 folded, lane 2 open: the first card the *board* is showing is lane 2's.
|
||
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||
let lanes = try load(fixture).lanes
|
||
|
||
#expect(NavigationMath.firstCard(scanning: lanes) == card5)
|
||
// Reversed — ⌥→'s scan — the last showing lane is lane 2 as well, since lane 3 is empty and
|
||
// lane 1 is folded.
|
||
#expect(NavigationMath.firstCard(scanning: lanes.reversed()) == card5)
|
||
|
||
// The filter narrows the same scan: "Sixth" is in the open lane, "First" is in the folded one
|
||
// and stays unreachable however well it matches.
|
||
#expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "Sixth")) == card6)
|
||
#expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "First")) == nil)
|
||
|
||
// Every lane folded: there is nowhere to land at all, which is the same answer an empty board
|
||
// gives and leaves the press inert rather than selecting something drawn nowhere.
|
||
let allFolded = try WriterFixture()
|
||
defer { allFolded.tearDown() }
|
||
try allFolded.item("", Item.board)
|
||
try allFolded.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||
try allFolded.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||
#expect(NavigationMath.firstCard(scanning: try load(allFolded).lanes) == nil)
|
||
|
||
// And unfolding restores it, key removal and all — nothing about the cards changed.
|
||
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n\n")
|
||
#expect(NavigationMath.firstCard(scanning: try load(fixture).lanes) == card1)
|
||
}
|
||
}
|
||
|
||
// MARK: - The sticky ordinal
|
||
|
||
/// A UUID-shaped folder name for the walk board — `(lane, position)`, with position `0` naming the
|
||
/// lane itself, so ten cards in a lane cost no ten literals.
|
||
private func name(_ lane: Int, _ position: Int) -> String {
|
||
String(format: "%08x-0000-4000-8000-%012x", lane, position)
|
||
}
|
||
|
||
private func walkID(_ lane: Int, _ position: Int) -> ItemID {
|
||
ItemID(rawValue: name(lane, position))
|
||
}
|
||
|
||
/// **The card's own board**: ten cards, three, ten. The middle lane is what a walk has to be clamped
|
||
/// by, and the third is what proves the clamp was not remembered.
|
||
///
|
||
/// The third lane is **two units wide**, so its cards lay out across interior masonry columns — the
|
||
/// case the ruling settles by saying interior position "does not participate". Nothing here can see
|
||
/// that: the hop takes no frames, which is the enforcement.
|
||
///
|
||
/// Every card's body carries two tokens the filter suites query for: `even`/`odd` by position, and
|
||
/// `keeplane` on the outer lanes only — so one query narrows all three lanes and another empties the
|
||
/// middle one entirely.
|
||
@MainActor
|
||
private func makeWalk() throws -> WriterFixture {
|
||
let fixture = try WriterFixture()
|
||
try fixture.board()
|
||
for (lane, count) in [(1, 10), (2, 3), (3, 10)] {
|
||
try fixture.lane(
|
||
name(lane, 0),
|
||
order: "\(lane * 1024)",
|
||
title: "Lane \(lane)",
|
||
width: lane == 3 ? 2 : nil
|
||
)
|
||
for position in 1...count {
|
||
try fixture.card(
|
||
name(lane, position),
|
||
in: name(lane, 0),
|
||
order: "\(position * 1024)",
|
||
title: "L\(lane)C\(position)",
|
||
body: "\(position.isMultiple(of: 2) ? "even" : "odd") \(lane == 2 ? "" : "keeplane")"
|
||
)
|
||
}
|
||
}
|
||
return fixture
|
||
}
|
||
|
||
@Suite("NavigationMath ▸ the sticky ordinal")
|
||
@MainActor
|
||
struct LateralHopTests {
|
||
|
||
@Test("10-3-10: the 8th card walks out through the 3rd and back to the 8th")
|
||
func roundTrip() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let lanes = try fixture.snapshot().lanes
|
||
|
||
// Out: clamped to the short lane's last card, and still carrying the 8 it started with —
|
||
// the clamp is a landing, never a new position to remember.
|
||
let out = try #require(NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: lanes))
|
||
#expect(out.target == walkID(2, 3))
|
||
#expect(out.ordinal == 8)
|
||
|
||
// Onward: the wide lane's 8th in *logical* order, whichever masonry column drew it.
|
||
let onward = try #require(
|
||
NavigationMath.lateralHop(from: out.target, .right, scanning: lanes, sticky: out.ordinal)
|
||
)
|
||
#expect(onward.target == walkID(3, 8))
|
||
#expect(onward.ordinal == 8)
|
||
|
||
// And back the way it came, which is the card's title in full.
|
||
let returning = try #require(
|
||
NavigationMath.lateralHop(from: onward.target, .left, scanning: lanes, sticky: onward.ordinal)
|
||
)
|
||
#expect(returning.target == walkID(2, 3))
|
||
let home = try #require(
|
||
NavigationMath.lateralHop(from: returning.target, .left, scanning: lanes, sticky: returning.ordinal)
|
||
)
|
||
#expect(home.target == walkID(1, 8), "the 8th again — the whole point")
|
||
}
|
||
|
||
@Test("Without a run in flight the hop starts one at the origin's own position")
|
||
func noStickyStartsARun() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let lanes = try fixture.snapshot().lanes
|
||
|
||
// This is what a *reset* leaves behind, and it is also the failure the sticky exists to fix:
|
||
// starting a fresh run from the clamped 3rd card lands on the third, not the eighth.
|
||
let fresh = try #require(NavigationMath.lateralHop(from: walkID(2, 3), .right, scanning: lanes))
|
||
#expect(fresh.target == walkID(3, 3))
|
||
#expect(fresh.ordinal == 3)
|
||
|
||
let first = try #require(NavigationMath.lateralHop(from: walkID(1, 4), .right, scanning: lanes))
|
||
#expect(first.ordinal == 4, "the origin's 1-based position in its lane")
|
||
}
|
||
|
||
@Test("The clamp is the target lane's count, and a lane of one takes everything")
|
||
func clampsToTheShortLane() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let lanes = try fixture.snapshot().lanes
|
||
|
||
for ordinal in 3...10 {
|
||
let hop = try #require(
|
||
NavigationMath.lateralHop(from: walkID(1, ordinal), .right, scanning: lanes, sticky: ordinal)
|
||
)
|
||
#expect(hop.target == walkID(2, 3), "everything past the 3rd lands on the 3rd")
|
||
#expect(hop.ordinal == ordinal)
|
||
}
|
||
// Under the *filter* the middle lane shows one card, and the clamp follows what is showing
|
||
// rather than what is on disk.
|
||
let narrowed = try #require(
|
||
NavigationMath.lateralHop(
|
||
from: walkID(1, 8), .right, scanning: lanes, filter: SearchFilter(query: "even"), sticky: 4
|
||
)
|
||
)
|
||
#expect(narrowed.target == walkID(2, 2))
|
||
}
|
||
|
||
@Test("The ordinal counts the cards the query left showing, not the cards on disk")
|
||
func countsTheFilteredLane() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let lanes = try fixture.snapshot().lanes
|
||
let evens = SearchFilter(query: "even")
|
||
|
||
// Lane 1 shows 2,4,6,8,10 — so card 8 is the *4th* thing the user can see, and starting a run
|
||
// there carries 4 rather than 8.
|
||
let out = try #require(
|
||
NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: lanes, filter: evens)
|
||
)
|
||
#expect(out.ordinal == 4)
|
||
#expect(out.target == walkID(2, 2), "the middle lane shows one card; 4 clamps onto it")
|
||
|
||
let onward = try #require(
|
||
NavigationMath.lateralHop(
|
||
from: out.target, .right, scanning: lanes, filter: evens, sticky: out.ordinal
|
||
)
|
||
)
|
||
#expect(onward.target == walkID(3, 8), "the 4th showing card of the far lane is card 8")
|
||
}
|
||
|
||
@Test("A lane the query emptied is hopped straight over, exactly as an absent one would be")
|
||
func skipsAQueryEmptiedLane() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let lanes = try fixture.snapshot().lanes
|
||
// `keeplane` is on lanes 1 and 3 only: the middle lane shows nothing, so it is not a
|
||
// destination and one press crosses it.
|
||
let hop = try #require(
|
||
NavigationMath.lateralHop(
|
||
from: walkID(1, 8), .right, scanning: lanes, filter: SearchFilter(query: "keeplane")
|
||
)
|
||
)
|
||
#expect(hop.target == walkID(3, 8))
|
||
#expect(hop.ordinal == 8)
|
||
}
|
||
|
||
@Test("A collapsed lane is hopped over too, and its cards are never a landing")
|
||
func skipsACollapsedLane() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
// 03-board-ui.md § Lane ▸ Collapsed lanes: the slim strip lays out no card faces, so a hop
|
||
// that landed in there would select something drawn nowhere.
|
||
try fixture.item(name(2, 0), "---\nschema: 1\ntitle: Lane 2\norder: 2048\ncollapsed: true\n---\n\n")
|
||
let lanes = try fixture.snapshot().lanes
|
||
|
||
let hop = try #require(NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: lanes))
|
||
#expect(hop.target == walkID(3, 8), "one press, straight past the strip")
|
||
#expect(hop.ordinal == 8)
|
||
|
||
// And back: the strip is no more a destination from the right than from the left.
|
||
let back = try #require(
|
||
NavigationMath.lateralHop(from: hop.target, .left, scanning: lanes, sticky: hop.ordinal)
|
||
)
|
||
#expect(back.target == walkID(1, 8))
|
||
|
||
// Folding the *last* lane leaves nothing to the right at all — the handler's cue to fall back
|
||
// to geometry, which is how the shown trash stays reachable by →.
|
||
try fixture.item(name(3, 0), "---\nschema: 1\ntitle: Lane 3\norder: 3072\ncollapsed: true\n---\n\n")
|
||
#expect(
|
||
NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: try fixture.snapshot().lanes) == nil
|
||
)
|
||
}
|
||
|
||
@Test("The ends, the vertical directions and an origin the board is not showing are all nil")
|
||
func refusals() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let lanes = try fixture.snapshot().lanes
|
||
|
||
#expect(NavigationMath.lateralHop(from: walkID(1, 1), .left, scanning: lanes) == nil)
|
||
#expect(NavigationMath.lateralHop(from: walkID(3, 1), .right, scanning: lanes) == nil)
|
||
#expect(
|
||
NavigationMath.lateralHop(from: walkID(1, 8), .down, scanning: lanes) == nil,
|
||
"↑/↓ are not a lateral run and must never consume the ordinal"
|
||
)
|
||
#expect(NavigationMath.lateralHop(from: walkID(1, 8), .up, scanning: lanes) == nil)
|
||
#expect(NavigationMath.lateralHop(from: card1, .right, scanning: lanes) == nil, "not on this board")
|
||
#expect(
|
||
NavigationMath.lateralHop(
|
||
from: walkID(1, 1), .right, scanning: lanes, filter: SearchFilter(query: "even")
|
||
) == nil,
|
||
"an origin the query hid is in no showing lane, so there is no position to carry"
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The sticky ordinal's resets
|
||
|
||
@Suite("TransientBoardState ▸ the lateral run's resets")
|
||
@MainActor
|
||
struct LateralOrdinalResetTests {
|
||
|
||
@Test("Only a lateral hop stores one — every other selection change is a reset")
|
||
func selectResets() {
|
||
let state = TransientBoardState()
|
||
|
||
state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
#expect(state.lateralOrdinal == 8)
|
||
|
||
// Verbatim `BoardView.replaceSelection`, which is what ↑/↓, the ⌥-jumps and the lane domain
|
||
// all land through — no ordinal passed, so the run ends.
|
||
state.select([card2], in: .board, anchor: card2, head: card2)
|
||
#expect(state.lateralOrdinal == nil, "a vertical step ends the run")
|
||
|
||
state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
state.select([card1, card2], in: .board, anchor: card1, head: card2)
|
||
#expect(state.lateralOrdinal == nil, "so does a ⇧-arrow's extension")
|
||
|
||
state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
state.select([card1, card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
|
||
#expect(state.lateralOrdinal == nil, "and so does a rubber band")
|
||
|
||
state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
state.clearSelection()
|
||
#expect(state.lateralOrdinal == nil, "Escape leaves no run to be in the middle of")
|
||
}
|
||
|
||
@Test("A click ends the run, whatever it selects")
|
||
func clickResets() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
store.click(SelectionTarget(id: card3, kind: .card, container: .board), modifier: .plain)
|
||
#expect(store.selection.ids == [card3])
|
||
#expect(store.transient.lateralOrdinal == nil)
|
||
|
||
// A ⌘-click's toggle and a ⇧-click's range go through the same door, so they reset too.
|
||
store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
store.click(SelectionTarget(id: card3, kind: .card, container: .board), modifier: .shift)
|
||
#expect(store.transient.lateralOrdinal == nil)
|
||
}
|
||
|
||
@Test("A ⌫ successor ends it, because a run's cursor was just deleted out from under it")
|
||
func deleteResets() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card2], in: .board, anchor: card2, head: card2, lateralOrdinal: 8)
|
||
store.deleteSelection()
|
||
#expect(store.selection.ids == [card3])
|
||
#expect(store.transient.lateralOrdinal == nil)
|
||
}
|
||
|
||
@Test("A reload keeps the run while the cursor stands, and drops it when the cursor goes")
|
||
func reloadKeepsThenDrops() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
|
||
// An agent filing a card is not the user changing their mind: the head still names a card, so
|
||
// the run survives untouched.
|
||
try fixture.item("\(Ident.lane1)/\(More.filed)", Item.rich(order: "5120", title: "Filed"))
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
#expect(store.transient.selectionHead == card1)
|
||
#expect(store.transient.lateralOrdinal == 8)
|
||
|
||
// The cursor's own card vanishing is the other case, and the ordinal goes with it. What lands
|
||
// afterwards is 10-accessibility.md's vanishing-focus recovery (`SelectionHeadTests` pins it):
|
||
// the selection had emptied, so focus falls to the card's lane and takes the head with it —
|
||
// through `select`, which is exactly the door that ends a run.
|
||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
#expect(store.transient.selectionHead == lane1)
|
||
#expect(store.transient.lateralOrdinal == nil)
|
||
}
|
||
|
||
@Test("A vanished cursor takes the ordinal with it, before any recovery gets a say")
|
||
func resolveDropsTheOrdinalWithTheHead() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let state = TransientBoardState()
|
||
|
||
state.select([card1, card2], in: .board, anchor: card1, head: card2, lateralOrdinal: 8)
|
||
|
||
// The head's card goes and the selection does not: so it is the head rule doing this, not
|
||
// `clearSelection` sweeping everything out on the way past.
|
||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||
state.resolve(against: try load(fixture))
|
||
|
||
#expect(state.selection.ids == [card1])
|
||
#expect(state.selectionHead == nil)
|
||
#expect(state.lateralOrdinal == nil)
|
||
}
|
||
|
||
@Test("A query that hides the cursor ends the run, because the ordinal counts what is showing")
|
||
func hidingTheCursorResets() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8)
|
||
store.searchQuery = "Second"
|
||
|
||
#expect(store.selection.isEmpty)
|
||
#expect(store.transient.selectionHead == nil)
|
||
#expect(store.transient.lateralOrdinal == nil)
|
||
}
|
||
|
||
@Test("The store's walk: hop, store, hop again — 10-3-10 through the selection itself")
|
||
func theWalkThroughTheStore() throws {
|
||
let fixture = try makeWalk()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// Exactly what `BoardView.step` does on a lane hop, three presses running.
|
||
func press(_ direction: NavigationMath.Direction) throws {
|
||
let head = try #require(store.transient.selectionHead)
|
||
let hop = try #require(
|
||
NavigationMath.lateralHop(
|
||
from: head,
|
||
direction,
|
||
scanning: store.snapshot.lanes,
|
||
filter: store.searchFilter,
|
||
sticky: store.transient.lateralOrdinal
|
||
)
|
||
)
|
||
store.select(
|
||
[hop.target], in: .board, anchor: hop.target, head: hop.target, lateralOrdinal: hop.ordinal
|
||
)
|
||
}
|
||
|
||
store.select([walkID(1, 8)], in: .board)
|
||
#expect(store.transient.lateralOrdinal == nil, "a fresh selection is not a run")
|
||
|
||
try press(.right)
|
||
#expect(store.selection.ids == [walkID(2, 3)])
|
||
try press(.right)
|
||
#expect(store.selection.ids == [walkID(3, 8)])
|
||
try press(.left)
|
||
try press(.left)
|
||
#expect(store.selection.ids == [walkID(1, 8)], "back where the walk began")
|
||
}
|
||
}
|
||
|
||
// MARK: - The registry the arrows and the band read
|
||
|
||
/// The registry is the one piece of the marquee/arrow pair that is *written* from a view — every card
|
||
/// face keeps its drawn frame in it — and it is deliberately unobserved so those writes invalidate
|
||
/// nothing (`MarqueeTargetRegistry`, `LaneDropRegistry`'s rule). That is a property of the type rather
|
||
/// than of any call site, so it is pinned here: a drag's make-room reflow animates positions, and an
|
||
/// observed registry turns each sliding face into a stream of view invalidations at the display's
|
||
/// refresh rate for as long as the reflow runs.
|
||
@Suite("MarqueeTargetRegistry ▸ registering a frame invalidates nothing")
|
||
@MainActor
|
||
struct MarqueeTargetRegistryTests {
|
||
|
||
/// A box the tracking callback can flip — `withObservationTracking`'s `onChange` is `@Sendable`,
|
||
/// and Observation calls it from wherever the mutation happened.
|
||
private final class Tripwire: @unchecked Sendable {
|
||
var fired = false
|
||
}
|
||
|
||
@Test("Every reader the band and the arrows use registers no observation")
|
||
func writesAreNotObserved() {
|
||
let registry = MarqueeTargetRegistry()
|
||
registry.update(target(card1, x: 0, y: 0))
|
||
let tripwire = Tripwire()
|
||
|
||
withObservationTracking {
|
||
// Exactly the three reads the event-time callers make: the sample loop's `all`
|
||
// (`MarqueeControl.gesture`), the arrows' keyed lookup (`BoardView.step`/`.extend`), and
|
||
// the band's begin guard.
|
||
_ = registry.all
|
||
_ = registry.targets[card1]
|
||
_ = registry.contains(.zero)
|
||
} onChange: {
|
||
tripwire.fired = true
|
||
}
|
||
|
||
// A reflow's worth of re-registration: the same card at a new frame, a new card, a removal.
|
||
registry.update(target(card1, x: 0, y: 40))
|
||
registry.update(target(card2, x: 0, y: 200))
|
||
registry.remove(card2)
|
||
|
||
#expect(tripwire.fired == false, "MarqueeTargetRegistry must not be @Observable")
|
||
}
|
||
|
||
@Test("The frames it hands back are still live after those writes")
|
||
func readsStayCorrect() {
|
||
let registry = MarqueeTargetRegistry()
|
||
registry.update(target(card1, x: 0, y: 0))
|
||
registry.update(target(card2, x: 0, y: 120))
|
||
|
||
registry.update(target(card1, x: 0, y: 40))
|
||
#expect(registry.targets[card1]?.frame.minY == 40)
|
||
#expect(registry.all.count == 2)
|
||
// Inside the moved card1 (y 40…140), and above where it now starts.
|
||
#expect(registry.contains(CGPoint(x: 10, y: 50)))
|
||
#expect(!registry.contains(CGPoint(x: 10, y: 20)))
|
||
// Inside card2 (y 120…220) and nothing else.
|
||
#expect(registry.contains(CGPoint(x: 10, y: 200)))
|
||
|
||
registry.remove(card2)
|
||
#expect(registry.targets[card2] == nil)
|
||
#expect(!registry.contains(CGPoint(x: 10, y: 200)))
|
||
}
|
||
}
|
||
|
||
// MARK: - SortMath
|
||
|
||
@Suite("SortMath ▸ within-lane sort")
|
||
struct SortMathTests {
|
||
|
||
private let ordered = [card1, card2, card3, card4]
|
||
|
||
@Test("A contiguous block steps one position, hopping its neighbour")
|
||
func stepsOnePosition() {
|
||
#expect(SortMath.reordered(ordered, moving: [card3], .up) == [card1, card3, card2, card4])
|
||
#expect(SortMath.reordered(ordered, moving: [card2], .down) == [card1, card3, card2, card4])
|
||
#expect(SortMath.reordered(ordered, moving: [card2, card3], .up) == [card2, card3, card1, card4])
|
||
#expect(SortMath.reordered(ordered, moving: [card2, card3], .down) == [card1, card4, card2, card3])
|
||
}
|
||
|
||
@Test("A non-contiguous selection gathers behind its first card, relative order preserved")
|
||
func gathersOnTheFirstPress() {
|
||
// "Anchored at the first selected card (first = lowest logical order; the rest follow in
|
||
// preserved relative order)" — and the press that gathers does not also step, which is why
|
||
// both directions give the same answer.
|
||
#expect(SortMath.reordered(ordered, moving: [card2, card4], .up) == [card1, card2, card4, card3])
|
||
#expect(SortMath.reordered(ordered, moving: [card2, card4], .down) == [card1, card2, card4, card3])
|
||
#expect(SortMath.reordered(ordered, moving: [card1, card3], .up) == [card1, card3, card2, card4])
|
||
#expect(
|
||
SortMath.reordered(ordered, moving: [card1, card4], .down) == [card1, card4, card2, card3],
|
||
"the unselected cards keep their relative order around the block"
|
||
)
|
||
}
|
||
|
||
@Test("At the ladder's end, and with nothing to move, the answer is nil rather than a no-op write")
|
||
func edgesAndEmptyAreNil() {
|
||
#expect(SortMath.reordered(ordered, moving: [card1], .up) == nil)
|
||
#expect(SortMath.reordered(ordered, moving: [card4], .down) == nil)
|
||
#expect(SortMath.reordered(ordered, moving: [card1, card2], .up) == nil)
|
||
#expect(SortMath.reordered(ordered, moving: Set(ordered), .up) == nil)
|
||
#expect(SortMath.reordered(ordered, moving: Set(ordered), .down) == nil)
|
||
#expect(SortMath.reordered(ordered, moving: [], .up) == nil)
|
||
#expect(SortMath.reordered([card1], moving: [card1], .down) == nil, "a lane of one has nowhere to go")
|
||
#expect(
|
||
SortMath.reordered(ordered, moving: [card5], .up) == nil,
|
||
"ids the lane does not render are ignored, so a stale selection moves nothing"
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The successor rule
|
||
|
||
@MainActor
|
||
@Suite("SelectionGrammar ▸ successor on delete")
|
||
struct SuccessorTests {
|
||
|
||
@Test("The next card in the lane, so repeated ⌫ walks down it")
|
||
func nextCardInTheLane() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3)
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2)
|
||
#expect(
|
||
SelectionGrammar.successor(afterDeleting: [card1, card2], snapshot: snapshot) == card3,
|
||
"a block's successor is the first survivor after its last member"
|
||
)
|
||
}
|
||
|
||
@Test("The last sibling falls back to its predecessor")
|
||
func predecessorFallback() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3)
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2)
|
||
}
|
||
|
||
@Test("A survivor between the members is found forwards first")
|
||
func forwardSearchWinsOverBackward() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
// Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes
|
||
// repeated ⌫ keep moving down rather than bouncing back up the lane.
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], snapshot: snapshot) == card4)
|
||
}
|
||
|
||
@Test("An emptied container selects nothing")
|
||
func emptiedContainerIsNil() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil)
|
||
#expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil)
|
||
#expect(
|
||
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], snapshot: snapshot) == nil,
|
||
"ids naming nothing name no container either"
|
||
)
|
||
}
|
||
|
||
@Test("A cross-lane selection is answered in its last member's lane, in flatten order")
|
||
func crossLaneUsesTheLastMembersLane() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
// card5 is later than card2 in flatten order (lane `order`, then card `order`), so the
|
||
// container is lane2 — the same "last member" anchor ⌘N and paste already share.
|
||
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], snapshot: snapshot) == card6)
|
||
}
|
||
|
||
@Test("Lanes follow the same rule in the live lane order")
|
||
func laneSuccessors() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
#expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2)
|
||
#expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor")
|
||
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil)
|
||
}
|
||
|
||
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
|
||
func deleteSelectsTheSuccessor() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card2], in: .board)
|
||
store.deleteSelection()
|
||
|
||
#expect(store.selection.ids == [card3])
|
||
#expect(store.transient.selectionAnchor == card3, "the successor is a legitimate range origin")
|
||
#expect(store.transient.selectionHead == card3, "and the place the next arrow steps from")
|
||
|
||
// Repeated ⌫ walks down the lane — the whole point of the rule. The store's snapshot has not
|
||
// reloaded, so card2 is still in it and card3's successor is card4.
|
||
store.deleteSelection()
|
||
#expect(store.selection.ids == [card4])
|
||
}
|
||
|
||
@Test("An emptied lane clears the selection instead of inventing one")
|
||
func deleteClearsWhenNothingSurvives() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card5, card6], in: .board)
|
||
store.deleteSelection()
|
||
|
||
#expect(store.selection.isEmpty)
|
||
#expect(store.transient.selectionHead == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - The navigation head
|
||
|
||
@MainActor
|
||
@Suite("TransientBoardState ▸ the navigation head")
|
||
struct SelectionHeadTests {
|
||
|
||
@Test("A sole member is its own head; any other count leaves none")
|
||
func headDefaults() throws {
|
||
let state = TransientBoardState()
|
||
|
||
state.select([card1], in: .board)
|
||
#expect(state.selectionHead == card1)
|
||
|
||
state.select([card1, card2], in: .board)
|
||
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
|
||
|
||
state.select([card1, card2], in: .board, anchor: card1, head: card2)
|
||
#expect(state.selectionAnchor == card1)
|
||
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
|
||
|
||
state.clearSelection()
|
||
#expect(state.selectionHead == nil)
|
||
#expect(state.selectionAnchor == nil)
|
||
}
|
||
|
||
@Test("A ⇧-gesture moves the head and leaves the anchor — that asymmetry is why both exist")
|
||
func shiftMovesOnlyTheHead() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let snapshot = try load(fixture)
|
||
|
||
let outcome = SelectionGrammar.click(
|
||
SelectionTarget(id: card3, kind: .card, container: .board),
|
||
modifier: .shift,
|
||
selection: ItemReferenceSet(ids: [card1], container: .board),
|
||
anchor: card1,
|
||
snapshot: snapshot
|
||
)
|
||
|
||
#expect(outcome.selection.ids == [card1, card2, card3])
|
||
#expect(outcome.anchor == card1, "the range origin stays put")
|
||
#expect(outcome.head == card3, "the cursor walks to what was clicked")
|
||
}
|
||
|
||
@Test("A vanished head is dropped by the reload, like every other item reference")
|
||
func resolveDropsAVanishedHead() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card1, card2], in: .board, anchor: card1, head: card2)
|
||
|
||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
|
||
#expect(store.selection.ids == [card1])
|
||
#expect(store.transient.selectionHead == nil)
|
||
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree")
|
||
}
|
||
|
||
@Test("A container crossing is a vanish for the head too")
|
||
func resolveDropsAContainerCrossedHead() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card1], in: .board)
|
||
#expect(store.transient.selectionHead == card1)
|
||
|
||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
|
||
// The crossing dropped the head, which is this test's rule. What stands afterwards is
|
||
// 10-accessibility.md's vanishing-focus recovery, layered on top: the selection had emptied
|
||
// and the cursor's card had gone, so focus lands on the card's lane and takes the head with
|
||
// it (`BoardAnnouncerStoreTests`, where the recovery itself is pinned). The card's own head
|
||
// reference is gone either way, which is what "a container crossing is a vanish" claims.
|
||
#expect(store.selection.ids == [lane1])
|
||
#expect(store.transient.selectionHead == lane1)
|
||
}
|
||
}
|
||
|
||
// MARK: - The sort's write
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ sortSelection")
|
||
struct SortWriteTests {
|
||
|
||
@Test("A step rewrites the two cards that swapped and nothing else")
|
||
func stepWritesTheMinimum() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
|
||
|
||
store.select([card3], in: .board)
|
||
store.sortSelection(.up)
|
||
|
||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
|
||
#expect(
|
||
try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == untouchedFirst,
|
||
"a card whose position did not change keeps its bytes — no stamp, no commit"
|
||
)
|
||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") == untouchedFourth)
|
||
#expect(store.selection.ids == [card3], "the ids all survive, so the selection is left alone")
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A gather collects the block behind its first card, on disk")
|
||
func gatherWrites() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card2, card4], in: .board)
|
||
store.sortSelection(.up)
|
||
|
||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
|
||
}
|
||
|
||
@Test("A step down moves the block past its following sibling")
|
||
func stepDownWrites() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.select([card1, card2], in: .board)
|
||
store.sortSelection(.down)
|
||
|
||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4])
|
||
}
|
||
|
||
@Test("Duplicate ranks are compacted first, because a permutation cannot outrank a name tie-break")
|
||
func duplicateOrdersRenumberFirst() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||
// Two cards sharing one rank: display order falls to the folder-name tie-break
|
||
// (`Ranks.isOrderedForDisplay`), which card1's `5555…` wins over card2's `6666…`.
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
|
||
|
||
store.select([card2], in: .board)
|
||
store.sortSelection(.up)
|
||
|
||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("The plan refuses every case the design calls inert")
|
||
func planRefusals() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(store.sortPlan(.up) == nil, "nothing selected")
|
||
|
||
store.select([lane1], in: .board)
|
||
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
|
||
|
||
store.select([card2, card5], in: .board)
|
||
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
|
||
|
||
store.select([card1], in: .trash)
|
||
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
|
||
|
||
store.select([card1], in: .board)
|
||
#expect(store.sortPlan(.up) == nil, "already at the top")
|
||
#expect(store.sortPlan(.down) != nil, "but the other direction is live")
|
||
}
|
||
}
|
||
|
||
// MARK: - The lane move's index convention
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ moveLane's one-slot convention")
|
||
struct MoveLaneConventionTests {
|
||
|
||
/// The index `MoveLaneCommands` passes: the lane's display position among the live lanes, plus
|
||
/// or minus one. `moveLane` counts that position **with the moved lane already removed**, which
|
||
/// is exactly what makes `from ± 1` one slot — and is easy enough to get backwards that it is
|
||
/// pinned here rather than left to the drag path's coverage.
|
||
@Test("from − 1 moves one slot left, from + 1 moves one slot right")
|
||
func oneSlotEachWay() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||
#expect(lanes == [lane1, lane2, lane3])
|
||
let from = try #require(lanes.firstIndex(of: lane2))
|
||
|
||
store.moveLane(lane2, toIndex: from - 1)
|
||
#expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3])
|
||
}
|
||
|
||
@Test("A step right hops exactly one lane, never to the end")
|
||
func stepRightHopsOne() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||
let from = try #require(lanes.firstIndex(of: lane1))
|
||
|
||
store.moveLane(lane1, toIndex: from + 1)
|
||
#expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3])
|
||
}
|
||
}
|
||
|
||
// MARK: - LaneMoveTarget's destination predicate
|
||
|
||
/// `LaneMoveTarget.destination` — the pure predicate behind Board ▸ Move Left/Move Right
|
||
/// (`MoveLaneCommands`) and, since 2026-08-09 ▸ "redesign context menu for cards", the card context
|
||
/// menu's Navigation submenu (`CardFaceView`). Pinned directly rather than only through
|
||
/// `MoveLaneCommands`'s own view, which the suite above already exercises via `store.moveLane`'s
|
||
/// convention but never through this gate — the predicate itself was previously untested in
|
||
/// isolation.
|
||
@MainActor
|
||
@Suite("LaneMoveTarget ▸ destination")
|
||
struct LaneMoveTargetTests {
|
||
|
||
@Test("A sole selected mid-board lane answers both directions")
|
||
func midBoardLaneAnswersBothWays() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let selection = ItemReferenceSet(ids: [lane2], container: .board)
|
||
|
||
let left = LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: -1)
|
||
#expect(left?.lane == lane2)
|
||
#expect(left?.index == 0)
|
||
|
||
let right = LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: 1)
|
||
#expect(right?.lane == lane2)
|
||
#expect(right?.index == 2)
|
||
}
|
||
|
||
@Test("The left wall refuses left and the right wall refuses right")
|
||
func wallsRefuseTheirOwnDirection() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
let atLeftWall = ItemReferenceSet(ids: [lane1], container: .board)
|
||
#expect(LaneMoveTarget.destination(selection: atLeftWall, snapshot: store.snapshot, delta: -1) == nil)
|
||
#expect(LaneMoveTarget.destination(selection: atLeftWall, snapshot: store.snapshot, delta: 1) != nil)
|
||
|
||
let atRightWall = ItemReferenceSet(ids: [lane3], container: .board)
|
||
#expect(LaneMoveTarget.destination(selection: atRightWall, snapshot: store.snapshot, delta: 1) == nil)
|
||
#expect(LaneMoveTarget.destination(selection: atRightWall, snapshot: store.snapshot, delta: -1) != nil)
|
||
}
|
||
|
||
/// "A card id is in no lane order, so this is also the 'not a lane' test" — the exact clause the
|
||
/// card context menu's Navigation rows lean on to justify their unconditional `.disabled(true)`
|
||
/// (`CardFaceView.boardMenu`'s own doc comment): a card selection can never itself make this
|
||
/// predicate answer `true`, whatever card it names.
|
||
@Test("A card selection answers nil in both directions — a card id is in no lane order")
|
||
func cardSelectionAnswersNil() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let selection = ItemReferenceSet(ids: [card1], container: .board)
|
||
|
||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: -1) == nil)
|
||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: 1) == nil)
|
||
}
|
||
|
||
@Test("A multi-lane selection answers nil — one slot has no meaning for a discontiguous pair")
|
||
func multiLaneSelectionAnswersNil() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let selection = ItemReferenceSet(ids: [lane1, lane2], container: .board)
|
||
|
||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: -1) == nil)
|
||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: 1) == nil)
|
||
}
|
||
|
||
@Test("An empty selection and a trash-container selection both answer nil")
|
||
func emptyAndTrashSelectionsAnswerNil() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(LaneMoveTarget.destination(selection: .empty, snapshot: store.snapshot, delta: 1) == nil)
|
||
|
||
let trashSelection = ItemReferenceSet(ids: [lane1], container: .trash)
|
||
#expect(LaneMoveTarget.destination(selection: trashSelection, snapshot: store.snapshot, delta: 1) == nil)
|
||
}
|
||
}
|