Files
lanework/KanbanTests/KeyboardGrammarTests.swift
T
rzen c339b4cecf Implement live accessibility announcements
The board speaks when files change under the user, per DESIGN/10 § Live
board announcements. BoardDiff is the pure snapshot summarizer (identity
sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so
pro-m1's semantic commit engine can build on it; edited = rendered
content only, moved beats edited, implied events don't steal the
subject). BoardAnnouncer is the decision seam: focusOutcome computes the
vanishing-focus sentence and the walk-up-then-sideways recovery (next
lane by order, else previous, board container only when none remain,
never the trash); speech(for:) is the one-sentence precedence ladder —
raised condition > bracket completion > cleared condition > vanished
focus > digest — foreign-only for the last two rungs, so app-mediated
echoes stay silent.

BoardStore.land assembles ReloadFacts and posts exactly one sentence per
reload through the injectable announce outlet (AccessibilityAnnouncer,
medium priority, never interrupting). Selection recovery layers on top
of ItemReferenceSet re-resolution — survivors veto, the emptied
selection lands on the vanished item's lane and re-arms ⌘N's active-lane
memory. performWholesale(announcing:) arms a completion phrase consumed
by the closing reload — nil on every base bracket today; pro-m1 fills
git phrasings. Locks raised outside the reload path (vanished root,
unwritable location) announce through the same ladder, and the banner
strip is a labeled "Board status" container whose row labels are the
announced sentences (AccessibilityPhrases.bannerLabel — one string for
eye and ear).

Announcements classify at reload granularity (WatchOrigin) as a
deliberate interim: DESIGN/02's EchoLedger (per-file classification, the
announcer's specified input, git-free) was scheduled with the
auto-committer that the edition split moved to pro-m1 — filed on the
Redesign board for a ruling. 1533 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 08:15:53 -04:00

533 lines
24 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,
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
)
}
}
// 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])
}
}