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:
@@ -1109,3 +1109,208 @@ struct LaneMoveTargetTests {
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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