Files
lanework/KanbanTests/KeyboardGrammarTests.swift
T
rzen f6105d4389 The marquee registry stops being observable — reflow writes cost a dictionary store and nothing else
Drops @Observable from MarqueeTargetRegistry (MarqueeSession keeps it —
its rect genuinely renders the band). The audit found no body read
anywhere: the begin guard and sample loop read from inside the drag
gesture, the arrows from inside a key handler, so nothing ever needed
invalidating when a frame moved — while every make-room reflow had each
sliding face re-firing onGeometryChange per display frame, each write
paying Observation registrar bookkeeping on top of the reflow's own
render work (the confirmed A/B culprit of 2026-07-31). Write-gating on
drag-active was rejected: a suppressed write never replays, leaving the
band and arrows navigating stale rectangles. A tripwire test pins the
registry against anyone re-adding the macro.

Drag-perf confirmed culprit, card eb7b75ce.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 19:14:11 -04:00

623 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}
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)
}
}
// 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])
}
}