CardMoveTarget (BoardCommands.swift), LaneMoveTarget's cousin: validates the clicked card's own current lane against the live lane order rather than the board's live selection, refuses a target that reaches outside that lane (no coherent left for a spread), and answers an index — the clicked card's own position in its lane, clamped — for the adjacent live lane in either direction. Trash is never a candidate (not a Lane); a collapsed lane is a fine landing (a fold hides cards, it doesn't close the lane). CardFaceView's Navigation rows now call moveCardAcrossLane(by:), which hands CardMoveTarget's answer straight to BoardStore.moveCards(_:toLane:at:) — the exact call a released drag makes, so rank-minting, the undo step, the watcher echo and the banner all come free. Targeting is Copy/Cut's own widening (targetIDs): the clicked card, or the live selection when the clicked card is a member of it. Enablement stays render-safe the way isSelected/selectedCount already are: three new CardFaceView parameters (hasLeftNeighbor, hasRightNeighbor, selectionSpansLanes) are hoisted once per lane in LaneView.scrollableCards and handed down as compared parameters, never read from inside a card face's own .disabled. Caught and fixed a real regression here during development: an early cut of the multi-lane-spread check answered true for any lane that simply didn't contain the selected card, which flipped a compared parameter for most of the board on an ordinary single-card select and defeated CardFaceView's equality gate wholesale (BoardRenderPerformanceTests.selectionStillRepaints caught it at 151 of 180 card faces). Tests: CardMoveTargetTests (KeyboardGrammarTests.swift) pins the pure predicate — leftmost/rightmost lane, single lane, index clamping, a widened group anchoring on the clicked member rather than its own extent, the multi-lane-spread refusal, and an integration test feeding the answer straight through moveCards. CardFaceViewEquatableTests gains a case pinning the three new compared parameters. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1317 lines
62 KiB
Swift
1317 lines
62 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)
|
||
}
|
||
}
|
||
|
||
// MARK: - CardMoveTarget's destination predicate
|
||
|
||
/// `CardMoveTarget.destination` — the pure predicate behind the card context menu's Navigation ▸
|
||
/// Move Left/Move Right (2026-08-09 ▸ "Give the card context menu's Navigation rows real card-move
|
||
/// behavior", card 06322636), `LaneMoveTarget.destination`'s cousin: same board-order lane stepping,
|
||
/// but validated against a **card's own current lane** rather than the live selection's lane
|
||
/// membership. `hasNeighbor` and `selectionSpansOtherLanes` are the two halves `CardFaceView` hoists
|
||
/// to the arrangement level (`LaneView.scrollableCards`) as `hasLeftNeighbor`/`hasRightNeighbor`/
|
||
/// `selectionSpansLanes` — pinned here directly, `LaneMoveTargetTests`' own reason.
|
||
///
|
||
/// Three cards in lane one, one in lane two, one in lane three — enough room for an index that has to
|
||
/// clamp landing in a lane, a card in every lane an edge case needs one in, and a third lane to prove
|
||
/// a target spanning two of them is refused regardless of which two.
|
||
@MainActor
|
||
private func makeCardMoveBoard() 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.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", 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
|
||
}
|
||
|
||
@MainActor
|
||
@Suite("CardMoveTarget ▸ destination")
|
||
struct CardMoveTargetTests {
|
||
|
||
@Test("The leftmost lane's own card refuses a further-left move")
|
||
func leftmostLaneRefusesLeft() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: card1, targeting: [card1], snapshot: store.snapshot, delta: -1) == nil)
|
||
#expect(CardMoveTarget.hasNeighbor(of: lane1, delta: -1, in: store.snapshot) == false)
|
||
}
|
||
|
||
@Test("The rightmost lane's own card refuses a further-right move")
|
||
func rightmostLaneRefusesRight() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: card5, targeting: [card5], snapshot: store.snapshot, delta: 1) == nil)
|
||
#expect(CardMoveTarget.hasNeighbor(of: lane3, delta: 1, in: store.snapshot) == false)
|
||
}
|
||
|
||
@Test("A mid-board card's own card answers both directions, landing at its own index")
|
||
func midBoardCardAnswersBothWays() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// Fourth is lane two's sole card — index 0 — and lane two sits between lane one (three
|
||
// cards) and lane three (one card), so both directions have somewhere to land.
|
||
let right = CardMoveTarget.destination(
|
||
clicked: card4, targeting: [card4], snapshot: store.snapshot, delta: 1)
|
||
#expect(right?.laneID == lane3)
|
||
#expect(right?.index == 0)
|
||
|
||
let left = CardMoveTarget.destination(
|
||
clicked: card4, targeting: [card4], snapshot: store.snapshot, delta: -1)
|
||
#expect(left?.laneID == lane1)
|
||
#expect(left?.index == 0)
|
||
}
|
||
|
||
@Test("An index past the destination lane's card count clamps rather than trapping")
|
||
func indexClamps() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// Third is index 2 in lane one; lane two holds exactly one card, so the only valid insertion
|
||
// indices there are 0 and 1 — 2 must clamp down to 1, not trap or land past the end silently.
|
||
let target = CardMoveTarget.destination(
|
||
clicked: card3, targeting: [card3], snapshot: store.snapshot, delta: 1)
|
||
#expect(target?.laneID == lane2)
|
||
#expect(target?.index == 1)
|
||
}
|
||
|
||
@Test("A widened group lands at the CLICKED member's index, not the group's own extent")
|
||
func groupAnchorsOnTheClickedMember() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let group: Set<ItemID> = [card1, card3]
|
||
|
||
// Clicked First (index 0) — the group's own leading edge.
|
||
let clickedFirst = CardMoveTarget.destination(
|
||
clicked: card1, targeting: group, snapshot: store.snapshot, delta: 1)
|
||
#expect(clickedFirst?.laneID == lane2)
|
||
#expect(clickedFirst?.index == 0, "anchored on the clicked card's own index, not the group's")
|
||
|
||
// Clicked Third (index 2) — the same group, the other member clicked, clamped the same way
|
||
// `indexClamps` pins for a single-card target.
|
||
let clickedThird = CardMoveTarget.destination(
|
||
clicked: card3, targeting: group, snapshot: store.snapshot, delta: 1)
|
||
#expect(clickedThird?.laneID == lane2)
|
||
#expect(clickedThird?.index == 1)
|
||
}
|
||
|
||
@Test("A target reaching outside the clicked card's own lane answers nil — no coherent left for a spread")
|
||
func multiLaneTargetAnswersNil() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let spread: Set<ItemID> = [card1, card5]
|
||
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: card1, targeting: spread, snapshot: store.snapshot, delta: 1) == nil)
|
||
#expect(CardMoveTarget.selectionSpansOtherLanes(spread, laneCardIDs: [card1, card2, card3]))
|
||
}
|
||
|
||
@Test("An empty target, and a clicked id the board no longer holds, both answer nil")
|
||
func emptyOrUnknownClickedAnswersNil() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: card1, targeting: [], snapshot: store.snapshot, delta: 1) == nil)
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: ItemID(rawValue: Ident.indexless), targeting: [ItemID(rawValue: Ident.indexless)],
|
||
snapshot: store.snapshot, delta: 1) == nil)
|
||
}
|
||
|
||
@Test("A single-lane board has no neighbour in either direction")
|
||
func singleLaneHasNoNeighbor() throws {
|
||
let fixture = try WriterFixture()
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Solo"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Only"))
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(CardMoveTarget.hasNeighbor(of: lane1, delta: -1, in: store.snapshot) == false)
|
||
#expect(CardMoveTarget.hasNeighbor(of: lane1, delta: 1, in: store.snapshot) == false)
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: card1, targeting: [card1], snapshot: store.snapshot, delta: -1) == nil)
|
||
#expect(CardMoveTarget.destination(
|
||
clicked: card1, targeting: [card1], snapshot: store.snapshot, delta: 1) == nil)
|
||
}
|
||
|
||
@Test("hasNeighbor answers false for a lane id the snapshot no longer holds")
|
||
func hasNeighborAnswersFalseForAnUnknownLane() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let unknown = ItemID(rawValue: Ident.indexless)
|
||
|
||
#expect(CardMoveTarget.hasNeighbor(of: unknown, delta: -1, in: store.snapshot) == false)
|
||
#expect(CardMoveTarget.hasNeighbor(of: unknown, delta: 1, in: store.snapshot) == false)
|
||
}
|
||
|
||
@Test("selectionSpansOtherLanes: confined selections and the empty selection both answer false")
|
||
func selectionSpansOtherLanesFalseCases() throws {
|
||
#expect(CardMoveTarget.selectionSpansOtherLanes([], laneCardIDs: [card1, card2]) == false)
|
||
#expect(CardMoveTarget.selectionSpansOtherLanes([card1], laneCardIDs: [card1, card2]) == false)
|
||
#expect(CardMoveTarget.selectionSpansOtherLanes([card1, card2], laneCardIDs: [card1, card2]) == false)
|
||
}
|
||
|
||
/// **The regression this predicate exists not to reintroduce**: a selection with no member in
|
||
/// this lane at all — the ordinary case for every lane the user did not click into — must answer
|
||
/// `false`, the same as an empty selection, or every untouched lane's faces would read a spread
|
||
/// whenever *anything* elsewhere on the board got selected (caught the hard way, by
|
||
/// `BoardRenderPerformanceTests.selectionStillRepaints` going from 8 faces to 151 on a single
|
||
/// click — see this function's own doc comment).
|
||
@Test("A selection with no member in this lane at all answers false, not true")
|
||
func selectionEntirelyOutsideTheLaneAnswersFalse() throws {
|
||
#expect(CardMoveTarget.selectionSpansOtherLanes([card4], laneCardIDs: [card1, card2, card3]) == false)
|
||
}
|
||
|
||
/// **Feeding `destination`'s own answer to `BoardStore.moveCards` lands the card exactly there** —
|
||
/// the reuse the owner ruling asks for ("rank minting through the existing gapped-rank machinery
|
||
/// … reuse, don't reinvent") closes the loop: the predicate's `(laneID, index)` is not merely
|
||
/// shaped like what `moveCards` wants, it actually produces the destination this test reads back
|
||
/// off disk.
|
||
@Test("The predicate's answer, handed to moveCards, actually lands the card there")
|
||
func destinationFeedsMoveCardsCorrectly() throws {
|
||
let fixture = try makeCardMoveBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
let target = try #require(CardMoveTarget.destination(
|
||
clicked: card2, targeting: [card2], snapshot: store.snapshot, delta: 1))
|
||
store.moveCards([card2], toLane: target.laneID, at: target.index)
|
||
|
||
let moved = try BoardLoader.load(boardRoot: fixture.root).model
|
||
let sourceLane = try #require(moved.lanes.first { $0.id == lane1 })
|
||
let destinationLane = try #require(moved.lanes.first { $0.id == lane2 })
|
||
#expect(sourceLane.cards.map(\.id) == [card1, card3], "Second really left lane one")
|
||
// Lane two held one card (Fourth) before the move; index 1 among it is "after Fourth",
|
||
// Second's own index (1) carried over from a three-card lane one, clamped by `moveCards`.
|
||
#expect(destinationLane.cards.map(\.id) == [card4, card2], "and landed after Fourth")
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
}
|