Navigation's Move Left/Right learn a real card — the context menu's disabled rows compute a per-card cross-lane destination and land it through the drop's own rank machinery
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
This commit is contained in:
@@ -313,6 +313,40 @@ struct CardFaceViewEquatableTests {
|
||||
))
|
||||
}
|
||||
|
||||
/// **The Navigation inputs are compared too** (2026-08-09 ▸ "Give the card context menu's
|
||||
/// Navigation rows real card-move behavior", card 06322636) — `selectednessIsADifference`'s own
|
||||
/// reason: `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` are what let
|
||||
/// `moveLeftEnabled`/`moveRightEnabled` answer without a `store` read inside this face's own
|
||||
/// `.disabled`, so a gate that swallowed any of the three would leave a face's Navigation rows
|
||||
/// wearing a stale enabled state after a purely lane-side change nothing else here would compare.
|
||||
@Test("Each Navigation input is a compared difference on its own")
|
||||
func navigationInputsAreADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let card = try firstCard(fixture.snapshot())
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
|
||||
|
||||
func face(
|
||||
hasLeftNeighbor: Bool = false, hasRightNeighbor: Bool = false, selectionSpansLanes: Bool = false
|
||||
) -> CardFaceView {
|
||||
CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1,
|
||||
hasLeftNeighbor: hasLeftNeighbor, hasRightNeighbor: hasRightNeighbor,
|
||||
selectionSpansLanes: selectionSpansLanes
|
||||
)
|
||||
}
|
||||
|
||||
#expect(face() == face(), "identical inputs, defaults included, still compare equal")
|
||||
#expect(face() != face(hasLeftNeighbor: true))
|
||||
#expect(face() != face(hasRightNeighbor: true))
|
||||
#expect(face() != face(selectionSpansLanes: true))
|
||||
}
|
||||
|
||||
/// **The hero is a compared input too** (03-board-ui.md § Card face ▸ Hero image) — a resolution
|
||||
/// the parent does, like selected-ness, and the one input that changes the face's *height*. A gate
|
||||
/// that swallowed it would leave a card banding a picture it no longer names, or naming one it
|
||||
|
||||
Reference in New Issue
Block a user