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])
    }
}
