import AppKit
import Foundation
import Testing
@testable import Kanban

/// The board strip's three rebuild gates — `LaneView.==`, `CardFaceView.==` and
/// `TrashLaneRowView.==`, applied through `.equatable()` at `BoardView.laneSlot`,
/// `LaneView.scrollableCards` and `TrashLaneView.scrollableCards`.
///
/// They exist for the drag: `BoardView`'s body reads the drag session, so **every drop-proposal
/// change re-evaluates the whole strip**, and a lane's body reads it too, so every proposal change
/// reached every card face as well. The gates are what turn "the cursor moved" into "the one lane
/// under it re-runs" rather than "every lane and every face on the board re-runs".
///
/// What these tests pin is the *comparison list*, because that is where a gate goes wrong in either
/// direction. Too strict and the gate does nothing — the strip rebuilds the drop context and the
/// card opener on every body pass, so any view comparing closures is unequal every time. Too loose
/// and the board stops repainting — an edited card must make its lane a different value, and a
/// newly selected card must make its face one.
///
/// **Selection is on the compared side now, and these tests are where that is held.** It used to be
/// the headline example of the Observation half these gates deliberately do not cover: every card
/// face read `store.selection` for itself, so one click invalidated all of them directly and
/// `.equatable()` never got a say (measured at 180 bodies per marquee sample on the 6×30 fixture —
/// RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). A face now takes `isSelected`
/// and `selectedCount` from its parent, which means the gate is what decides whether a selection
/// change repaints a given face — so it has to be unequal exactly when the flags differ, and
/// `MarqueeRenderCostTests` measures the consequence.
///
/// They still do **not** pin the rest of the Observation half, and cannot: `store.transient`'s
/// pending cut and rename editor, `drops.session`'s proposal and the rest invalidate these views
/// directly. That is the design — the gate only suppresses *parent-driven* re-evaluation.
///
/// Boards are real loads off real temp trees, `SearchFilterTests`' reason: a hand-assembled `Lane`
/// would be comparing something `BoardLoader` can never produce, and "an edit makes the lane
/// unequal" is only worth asserting against the values a reload actually lands.

// MARK: - Fixtures

/// Two lanes, two cards in the first — enough for a lane value that changes when a card under it
/// does, and for two card values that differ from each other.
@MainActor
private func makeFixture() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.board()
    try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
    try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "The auth flow.")
    try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Polish copy", body: "Tighten it.")
    try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
    return fixture
}

/// A drop context with the four members the gates compare made explicit, and the three closures
/// freshly allocated on every call — which is exactly what `BoardView.dropContext` does per body
/// pass, and the thing the gates must survive.
@MainActor
private func makeDrops(
    store: BoardStore,
    session: DragSession,
    registry: LaneDropRegistry,
    gap: CGFloat = 12
) -> BoardDropContext {
    BoardDropContext(
        store: store,
        session: session,
        registry: registry,
        gap: gap,
        window: { nil },
        stripFrame: { .zero },
        standard: { 260 }
    )
}

@MainActor
private func makeLane(
    store: BoardStore,
    lane: Lane,
    columns: Int = 1,
    slotWidth: CGFloat = 260,
    drops: BoardDropContext,
    marquee: MarqueeControl,
    openCard: @escaping @MainActor (ItemID) -> Void = { _ in }
) -> LaneView {
    LaneView(
        store: store,
        lane: lane,
        columns: columns,
        slotWidth: slotWidth,
        drops: drops,
        marquee: marquee,
        openCard: openCard
    )
}

private func firstLane(_ model: BoardModel) throws -> Lane {
    try #require(model.lanes.first { $0.id == ItemID(rawValue: Ident.lane1) })
}

private func firstCard(_ model: BoardModel) throws -> Card {
    try #require(try firstLane(model).cards.first { $0.id == ItemID(rawValue: Ident.card1) })
}

// MARK: - The strip's gate

@MainActor
@Suite("LaneView — the strip's rebuild gate")
struct LaneViewEquatableTests {

    @Test("Identical inputs compare equal — the pass a proposal change would otherwise rebuild")
    func identicalInputsAreEqual() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let lane = try firstLane(fixture.snapshot())
        let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )

        #expect(makeLane(store: store, lane: lane, drops: drops, marquee: marquee)
            == makeLane(store: store, lane: lane, drops: drops, marquee: marquee))
    }

    @Test("A rebuilt drop context and a fresh opener still compare equal — the whole point of the gate")
    func freshClosuresAreNotADifference() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let lane = try firstLane(fixture.snapshot())
        let session = DragSession()
        let registry = LaneDropRegistry()
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )

        // Two body passes of `BoardView`: same store, same session, same registry, same gap — three
        // brand-new closures in the context and a brand-new `openCard` besides. A gate that compared
        // any of them would be unequal here, which is to say it would never suppress anything.
        let before = makeLane(
            store: store,
            lane: lane,
            drops: makeDrops(store: store, session: session, registry: registry),
            marquee: marquee,
            openCard: { _ in }
        )
        let after = makeLane(
            store: store,
            lane: lane,
            drops: makeDrops(store: store, session: session, registry: registry),
            marquee: marquee,
            openCard: { _ in Issue.record("the gate must not care which opener it holds") }
        )
        #expect(before == after)
    }

    @Test("An edited card makes its lane unequal — the gate never withholds a repaint")
    func anEditUnderTheLaneIsADifference() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let before = try firstLane(fixture.snapshot())

        // A foreign edit, the way a reload lands one: `Lane` is `Equatable` through its cards, so a
        // card's new title is a new lane value even though the lane's own frontmatter is untouched.
        try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix auth", body: "The auth flow.")
        let after = try firstLane(fixture.snapshot())
        #expect(before != after)

        #expect(makeLane(store: store, lane: before, drops: drops, marquee: marquee)
            != makeLane(store: store, lane: after, drops: drops, marquee: marquee))
    }

    @Test("The two layout figures the strip resolves are compared: a resize tick and a width change")
    func theStripsLayoutFiguresAreCompared() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let lane = try firstLane(fixture.snapshot())
        let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let base = makeLane(store: store, lane: lane, columns: 1, slotWidth: 260, drops: drops, marquee: marquee)

        // `columns` follows the resize session's snapped unit count and `slotWidth` the strip's
        // standard width — neither is readable off `lane`, so neither can ride in on its value.
        #expect(base != makeLane(store: store, lane: lane, columns: 2, slotWidth: 260, drops: drops, marquee: marquee))
        #expect(base != makeLane(store: store, lane: lane, columns: 1, slotWidth: 532, drops: drops, marquee: marquee))
    }

    @Test("The window-lived collaborators are compared by identity, the strip's gap by value")
    func theCollaboratorsAreComparedByIdentity() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let other = try BoardStore(rootURL: fixture.root)
        let lane = try firstLane(fixture.snapshot())
        let session = DragSession()
        let registry = LaneDropRegistry()
        let bandSession = MarqueeSession()
        let bandRegistry = MarqueeTargetRegistry()
        let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
        let drops = makeDrops(store: store, session: session, registry: registry)
        let base = makeLane(store: store, lane: lane, drops: drops, marquee: marquee)

        // A different board window in every direction the two structs can differ.
        #expect(base != makeLane(store: other, lane: lane, drops: drops, marquee: marquee))
        #expect(base != makeLane(
            store: store, lane: lane,
            drops: makeDrops(store: store, session: DragSession(), registry: registry),
            marquee: marquee
        ))
        #expect(base != makeLane(
            store: store, lane: lane,
            drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
            marquee: marquee
        ))
        #expect(base != makeLane(
            store: store, lane: lane,
            drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
            marquee: marquee
        ))
        #expect(base != makeLane(
            store: store, lane: lane, drops: drops,
            marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store)
        ))
        #expect(base != makeLane(
            store: store, lane: lane, drops: drops,
            marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store)
        ))
    }
}

// MARK: - The lane's gate

@MainActor
@Suite("CardFaceView — the lane's rebuild gate")
struct CardFaceViewEquatableTests {

    @Test("Identical inputs compare equal, fresh opener and fresh drop context included")
    func identicalInputsAreEqual() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let card = try firstCard(fixture.snapshot())
        let session = DragSession()
        let registry = LaneDropRegistry()
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )

        // The `.board` role's payload is a closure the lane rebuilds on every pass; comparing it
        // would make every face on the board unequal on every proposal change, which is the rebuild
        // this gate exists to stop.
        let before = CardFaceView(
            store: store,
            card: card,
            role: .board(openCard: { _ in }),
            marquee: marquee,
            drops: makeDrops(store: store, session: session, registry: registry),
            hero: nil, isSelected: false,
            selectedCount: 1
        )
        let after = CardFaceView(
            store: store,
            card: card,
            role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
            marquee: marquee,
            drops: makeDrops(store: store, session: session, registry: registry),
            hero: nil, isSelected: false,
            selectedCount: 1
        )
        #expect(before == after)
    }

    @Test("Selected-ness is a compared input — the whole reason the faces stopped reading the store")
    func selectednessIsADifference() 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())

        let unselected = CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }),
            marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1
        )

        // The gate is now the *only* thing standing between a click and this face's repaint: nothing
        // in this body reads `store.selection` any more, so a gate that swallowed the flag would
        // leave a selected card wearing no accent ring at all.
        #expect(unselected != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }),
            marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
        ))

        // And the count, which the drag replica's fan and count badge are drawn from: a card that is
        // still selected but now travels with four others has a different image under the cursor.
        let alone = CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }),
            marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
        )
        #expect(alone != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }),
            marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 5
        ))
    }

    /// **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
    /// never draws.
    @Test("The resolved hero file is a difference")
    func theHeroIsADifference() 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())
        let sketch = fixture.root.appendingPathComponent("sketch.png")

        func face(_ hero: URL?) -> CardFaceView {
            CardFaceView(
                store: store, card: card, role: .board(openCard: { _ in }),
                marquee: marquee, drops: drops, hero: hero, isSelected: false, selectedCount: 1
            )
        }

        #expect(face(nil) == face(nil))
        #expect(face(sketch) == face(sketch))
        #expect(face(nil) != face(sketch))
        #expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
    }

    /// **The comment count is a difference too** (design ruling 2026-08-09, card e729e30a) — but
    /// unlike the hero it needs no parameter of its own: it rides inside `card`, a snapshot field
    /// the loader fills with a readdir (`Card.commentCount`), so this pins that `Card`'s synthesized
    /// `Equatable` is not quietly excluding it from the gate the way a hand-written `==` might.
    @Test("A comment added to the card is a difference")
    func aCommentCountChangeIsADifference() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let registry = LaneDropRegistry()
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let drops = makeDrops(store: store, session: session, registry: registry)
        let before = try firstCard(fixture.snapshot())

        try fixture.item(
            "\(Ident.lane1)/\(Ident.card1)/comments/\(UUID().uuidString.lowercased())",
            "---\nschema: 1\n---\n"
        )
        let after = try firstCard(fixture.snapshot())
        #expect(before.commentCount == 0)
        #expect(after.commentCount == 1)

        #expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
                             marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
            != CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
                            marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
    }

    @Test("An edited card is unequal — the gate never withholds a repaint")
    func anEditedCardIsADifference() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let registry = LaneDropRegistry()
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let drops = makeDrops(store: store, session: session, registry: registry)
        let before = try firstCard(fixture.snapshot())

        try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix auth", body: "The auth flow.")
        let after = try firstCard(fixture.snapshot())
        #expect(before != after)

        #expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
                             marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
            != CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
                            marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))

        // And a different card, which is the ordinary within-lane case.
        let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
            $0.id == ItemID(rawValue: Ident.card2)
        })
        #expect(CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
                             marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
            != CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
                            marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
    }

    @Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
    func theRolesHomeIsCompared() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let card = try firstCard(fixture.snapshot())
        let session = DragSession()
        let registry = LaneDropRegistry()
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let drops = makeDrops(store: store, session: session, registry: registry)
        let confirmations = TrashConfirmations()

        let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
                                 marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
        let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
                                 marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
        // The role decides which container a click selects in, whether the face has an Open gesture
        // at all, and whether Delete is the permanent one — never a difference to swallow.
        #expect(board != trash)
        #expect(trash == CardFaceView(store: store, card: card,
                                      role: .trash(confirmations: confirmations),
                                      marquee: marquee, drops: drops,
                                      hero: nil, isSelected: false, selectedCount: 1))
        // Window-lived state, so identity is meaningful as well as cheap.
        #expect(trash != CardFaceView(store: store, card: card,
                                      role: .trash(confirmations: TrashConfirmations()),
                                      marquee: marquee, drops: drops,
                                      hero: nil, isSelected: false, selectedCount: 1))
    }

    @Test("The window-lived collaborators are compared by identity, the strip's gap by value")
    func theCollaboratorsAreComparedByIdentity() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let other = try BoardStore(rootURL: fixture.root)
        let card = try firstCard(fixture.snapshot())
        let session = DragSession()
        let registry = LaneDropRegistry()
        let bandSession = MarqueeSession()
        let bandRegistry = MarqueeTargetRegistry()
        let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
        let drops = makeDrops(store: store, session: session, registry: registry)
        let base = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
                                marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)

        #expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
                                     marquee: marquee, drops: drops,
                                     hero: nil, isSelected: false, selectedCount: 1))
        #expect(base != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
            drops: makeDrops(store: store, session: DragSession(), registry: registry),
            hero: nil, isSelected: false, selectedCount: 1
        ))
        #expect(base != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
            drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
            hero: nil, isSelected: false, selectedCount: 1
        ))
        #expect(base != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
            drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
            hero: nil, isSelected: false, selectedCount: 1
        ))
        #expect(base != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }),
            marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
            drops: drops, hero: nil, isSelected: false, selectedCount: 1
        ))
        #expect(base != CardFaceView(
            store: store, card: card, role: .board(openCard: { _ in }),
            marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
            drops: drops, hero: nil, isSelected: false, selectedCount: 1
        ))
    }
}

// MARK: - The trash column's gate

/// `TrashLaneRowView.==` — the card face's gate at the other level, and new with the selection
/// change: the column's body did not read the selection until the rows stopped reading it for
/// themselves, so until then there was nothing here worth suppressing
/// (`TrashLaneView.scrollableCards`).
@MainActor
@Suite("TrashLaneRowView — the trash column's rebuild gate")
struct TrashLaneRowViewEquatableTests {

    /// The opaque unit the column draws — hand-assembled rather than loaded, because a trashed lane
    /// is exactly the row's whole input and `BoardLoader` needs a real deletion to produce one.
    private func makeRow(title: String, heldCards: Int = 5) -> TrashedLane {
        TrashedLane(
            id: ItemID(rawValue: Ident.lane3),
            schema: 1,
            title: .valid(title),
            modified: .missing,
            order: 1024,
            heldCards: heldCards,
            document: FrontmatterDocument(body: "")
        )
    }

    @MainActor
    private func makeView(
        store: BoardStore,
        lane: TrashedLane,
        confirmations: TrashConfirmations,
        drops: BoardDropContext,
        marquee: MarqueeControl,
        isSelected: Bool = false,
        selectedCount: Int = 1
    ) -> TrashLaneRowView {
        TrashLaneRowView(
            store: store,
            lane: lane,
            confirmations: confirmations,
            drops: drops,
            marquee: marquee,
            isSelected: isSelected,
            selectedCount: selectedCount
        )
    }

    @Test("Identical inputs compare equal, a freshly rebuilt drop context included")
    func identicalInputsAreEqual() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let registry = LaneDropRegistry()
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let confirmations = TrashConfirmations()
        let lane = makeRow(title: "Retired")

        // Two body passes of the window: same collaborators, three brand-new closures in the drop
        // context each time — a gate that compared any of them would never suppress anything.
        #expect(makeView(
            store: store, lane: lane, confirmations: confirmations,
            drops: makeDrops(store: store, session: session, registry: registry), marquee: marquee
        ) == makeView(
            store: store, lane: lane, confirmations: confirmations,
            drops: makeDrops(store: store, session: session, registry: registry), marquee: marquee
        ))
    }

    @Test("A different lane value is unequal — the gate never withholds a repaint")
    func aDifferentLaneValueIsADifference() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
        let confirmations = TrashConfirmations()
        let base = makeView(store: store, lane: makeRow(title: "Retired"),
                            confirmations: confirmations, drops: drops, marquee: marquee)

        // The row draws exactly two things — the title and the held-card count — so both have to be
        // differences, and `TrashedLane` being `Equatable` is what makes them one comparison.
        #expect(base != makeView(store: store, lane: makeRow(title: "Retired lanes"),
                                 confirmations: confirmations, drops: drops, marquee: marquee))
        #expect(base != makeView(store: store, lane: makeRow(title: "Retired", heldCards: 6),
                                 confirmations: confirmations, drops: drops, marquee: marquee))
    }

    @Test("Selected-ness and the selection's size are compared, the card face's rule")
    func selectednessIsADifference() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let marquee = MarqueeControl(
            session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
        )
        let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
        let confirmations = TrashConfirmations()
        let lane = makeRow(title: "Retired")
        let base = makeView(store: store, lane: lane, confirmations: confirmations,
                            drops: drops, marquee: marquee)

        #expect(base != makeView(store: store, lane: lane, confirmations: confirmations,
                                 drops: drops, marquee: marquee, isSelected: true))
        #expect(makeView(store: store, lane: lane, confirmations: confirmations, drops: drops,
                         marquee: marquee, isSelected: true, selectedCount: 1)
            != makeView(store: store, lane: lane, confirmations: confirmations, drops: drops,
                        marquee: marquee, isSelected: true, selectedCount: 4))
    }

    @Test("The window-lived collaborators are compared by identity, the strip's gap by value")
    func theCollaboratorsAreComparedByIdentity() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let other = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let registry = LaneDropRegistry()
        let bandSession = MarqueeSession()
        let bandRegistry = MarqueeTargetRegistry()
        let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
        let drops = makeDrops(store: store, session: session, registry: registry)
        let confirmations = TrashConfirmations()
        let lane = makeRow(title: "Retired")
        let base = makeView(store: store, lane: lane, confirmations: confirmations,
                            drops: drops, marquee: marquee)

        #expect(base != makeView(store: other, lane: lane, confirmations: confirmations,
                                 drops: drops, marquee: marquee))
        // Window-lived state the row's permanent Delete goes through — identity is meaningful here
        // for `CardFaceRole.isEquivalent(to:)`'s reason exactly.
        #expect(base != makeView(store: store, lane: lane, confirmations: TrashConfirmations(),
                                 drops: drops, marquee: marquee))
        #expect(base != makeView(
            store: store, lane: lane, confirmations: confirmations,
            drops: makeDrops(store: store, session: DragSession(), registry: registry),
            marquee: marquee
        ))
        #expect(base != makeView(
            store: store, lane: lane, confirmations: confirmations,
            drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
            marquee: marquee
        ))
        #expect(base != makeView(
            store: store, lane: lane, confirmations: confirmations,
            drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
            marquee: marquee
        ))
        #expect(base != makeView(
            store: store, lane: lane, confirmations: confirmations, drops: drops,
            marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store)
        ))
        #expect(base != makeView(
            store: store, lane: lane, confirmations: confirmations, drops: drops,
            marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store)
        ))
    }
}
