Files
lanework/KanbanTests/KeyboardGrammarTests.swift
T
rzen 4035ba7986 Implement the keyboard grammar and full command map
The board's fixed grammar keys and the menu-backed chords of
04-interactions.md § Keyboard, per the Command Nexus inventory:

- Spatial arrow navigation (NavigationMath.nearest over the marquee
  registry's frames — one geometry source), walking across interior
  masonry columns, lanes, and into the shown trash; ⇧-arrows extend via
  the same range function as ⇧-click and go inert at the liveness and
  kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓
  descent; the empty selection seeds at the first lane's first card;
  selection scrolls into view.
- selectionHead — the navigation cursor beside the anchor, set by every
  click, moved by every arrow, dropped by the reload vanish rule.
- Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the
  placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓
  (within-lane sort, gather-then-step, rank-permuting writes in one
  bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the
  trash) — all validating and acting off one shared answer.
- Delete now selects the Finder-style successor sibling from the
  pre-write snapshot, so repeated ⌫ walks down a lane; external
  vanishing still only shrinks the selection.
- handleReturn rejects modified Returns; the trash column renders
  eagerly so every row stays registered for navigation and the marquee.

686 unit tests (27 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 19:32:30 -04:00

537 lines
23 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 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,
side: Liveness = .live
) -> MarqueeTarget {
MarqueeTarget(id: id, kind: kind, side: side, 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, side: .trashed)
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.side == .live }) == card3
)
}
}
// 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], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2)
#expect(
SelectionGrammar.successor(afterDeleting: [card1, card2], in: 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], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: 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], in: 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], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil)
#expect(
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: 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], in: 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], in: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: 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], liveness: .live)
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], liveness: .live)
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], liveness: .live)
#expect(state.selectionHead == card1)
state.select([card1, card2], liveness: .live)
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
state.select([card1, card2], liveness: .live, 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, side: .live),
modifier: .shift,
selection: ItemReferenceSet(ids: [card1], liveness: .live),
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], liveness: .live, 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 liveness flip is a vanish for the head too")
func resolveDropsALivenessFlippedHead() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], liveness: .live)
#expect(store.transient.selectionHead == card1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: First
order: 1024
deleted: 2026-03-03T09:00:00Z
---
First body.
""")
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.selection.isEmpty)
#expect(store.transient.selectionHead == nil)
}
}
// 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], liveness: .live)
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], liveness: .live)
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], liveness: .live)
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], liveness: .live)
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], liveness: .live)
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
store.select([card2, card5], liveness: .live)
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
store.select([card1], liveness: .trashed)
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
store.select([card1], liveness: .live)
#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.liveLanes(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.liveLanes(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])
}
}