diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 1f6c003..dcead9b 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -110,6 +110,23 @@ struct BoardDropContext { /// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`). let standard: @MainActor () -> CGFloat + /// Whether two of these would send a drop to the same place — **the whole of what this value + /// contributes to `LaneView.==` and `CardFaceView.==`.** + /// + /// `BoardView.dropContext` rebuilds this struct on every body pass, closures and all, so a naive + /// value comparison is impossible and a reference comparison is meaningless: the three closures + /// are freshly allocated each time and would make every lane and every card face unequal on every + /// pass, which is exactly the rebuild the gates exist to stop. They are also the members it is + /// safe to ignore — each one *reads* window and strip geometry at event time rather than carrying + /// any (see this type's own note), so two contexts with the same store, session and registry + /// resolve every hover identically whatever closure objects they happen to hold. + nonisolated func isEquivalent(to other: BoardDropContext) -> Bool { + store === other.store + && session === other.session + && registry === other.registry + && gap == other.gap + } + // MARK: The cursor /// The physical cursor in the window's SwiftUI global space. diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index be690b4..4bb7634 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -461,6 +461,10 @@ struct BoardView: View { marquee: marqueeControl, openCard: openCard ) + // **The value gate** (`LaneView.==`): this body re-runs on every drop-proposal change, + // and without this every lane on the board — and every card face in it — rebuilds on + // every cursor move of every drag. + .equatable() .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) } .frame(width: slotWidth, alignment: .topLeading) diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 5e39626..25e8ecc 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -49,6 +49,26 @@ enum CardFaceRole { case .trash: .trash } } + + /// Whether two roles put the face in the same home with the same collaborator — the comparison + /// `CardFaceView.==` makes, and the reason this enum is not simply `Equatable`. + /// + /// **The board case's `openCard` is deliberately not compared.** It is a closure the strip + /// rebuilds on every body pass, so comparing it is impossible and ignoring it is correct: it is a + /// pure hand-off to the host's `WindowGroup` key, identical in behaviour whatever closure object + /// carries it, and a face that changed *which window it opens into* would be a face in a + /// different window and therefore a different view identity entirely. + /// + /// The trash case's `confirmations` **is** compared, by identity: it is window-lived state + /// (`@State` in `BoardWindowHost`), so identity is both cheap and meaningful, and it is the one + /// collaborator a role carries that the face actually reads state off. + nonisolated func isEquivalent(to other: CardFaceRole) -> Bool { + switch (self, other) { + case (.board, .board): true + case let (.trash(lhs), .trash(rhs)): lhs === rhs + default: false + } + } } // MARK: - Card face @@ -94,7 +114,15 @@ enum CardFaceRole { /// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the /// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card /// face). -struct CardFaceView: View { +/// +/// ### Equality gate +/// +/// The face is `Equatable` and instantiated through `.equatable()` (`LaneView.scrollableCards`, +/// `TrashLaneView.scrollableCards`), because its parent re-runs for reasons that have nothing to do +/// with any one card: a lane's body re-evaluates on **every drop-proposal change** while a drag is +/// in flight, and without a gate that rebuilds every face in every lane on every cursor move. See +/// the `==` below for what the gate covers and what it deliberately does not. +struct CardFaceView: View, Equatable { let store: BoardStore let card: Card @@ -140,6 +168,30 @@ struct CardFaceView: View { /// fallback is for. @State private var measuredWidth: CGFloat = 0 + /// The whole of what this face is a function of **as far as its parent is concerned**: the card + /// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home + /// it is drawn in (`CardFaceRole.isEquivalent(to:)`), and the three window-lived collaborators — + /// the store by identity, the band and the drop machinery by their own equivalence tests, which + /// exist because the strip rebuilds both structs, closures and all, on every body pass. + /// + /// **What the gate does not suppress is the point.** Everything this body reads through + /// Observation — `store.selection`, `store.searchFilter`, `store.transient.pendingCut` and the + /// rename editor, `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view + /// directly, and `.equatable()` has no say in that. The gate only stops the *parent* handing a + /// face a new-but-identical set of inputs and re-running it for nothing, which during a card drag + /// is what every proposal change does to every face in the lane. + /// + /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway), + /// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's + /// `openCard` closure (see `CardFaceRole.isEquivalent(to:)`). + nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool { + lhs.card == rhs.card + && lhs.role.isEquivalent(to: rhs.role) + && lhs.store === rhs.store + && lhs.marquee.isEquivalent(to: rhs.marquee) + && lhs.drops.isEquivalent(to: rhs.drops) + } + /// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both /// sides share is in `face`; what the board has and the trash does not is attached here, so the /// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index ac429c0..f0d0fa9 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -32,7 +32,15 @@ import SwiftUI /// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on /// a click, and viewing an attachment's media is the card window's job, not the face's /// (03-board-ui.md § Card face). -struct LaneView: View { +/// +/// ### Equality gate +/// +/// The lane is `Equatable` and instantiated through `.equatable()` (`BoardView.laneSlot`), because +/// the strip re-runs for reasons that have nothing to do with any one lane: `BoardView`'s body reads +/// the drag session, so **every drop-proposal change re-evaluates it**, and without a gate that +/// rebuilds every lane on the board — and, through them, every card face — on every cursor move +/// during a drag. See the `==` below for what the gate covers. +struct LaneView: View, Equatable { let store: BoardStore let lane: Lane @@ -109,6 +117,34 @@ struct LaneView: View { /// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll). @State private var autoScroller = DragAutoScroller() + /// The whole of what this lane is a function of **as far as the strip is concerned**: the lane + /// value (`Lane` is `Equatable`, its cards included, so an edit anywhere under this lane makes it + /// a different value), the two layout figures the strip resolves rather than the lane + /// (`columns` follows the live resize session, `slotWidth` the strip's standard width), and the + /// window-lived collaborators — the store by identity, the band and the drop machinery by their + /// own equivalence tests, which exist because `BoardView` rebuilds both structs, closures and + /// all, on every body pass. + /// + /// **What the gate does not suppress is the point.** Everything this body reads through + /// Observation — `drops.session`'s proposal, members and file target, `store.selection`, + /// `store.searchFilter`, `store.transient`, `appModel.styleRecents` — invalidates this view + /// directly, and `.equatable()` has no say in that. So the lane a drag is actually over still + /// re-runs on every proposal change, and its `shadowRun` animation key still moves with it; what + /// stops is the *other* lanes re-running because the strip did. + /// + /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway), + /// `@Environment` values (SwiftUI invalidates on those itself), and `openCard` — a closure the + /// host rebuilds every pass, which is a pure hand-off to a `WindowGroup` key and identical in + /// behaviour whatever closure object carries it. + nonisolated static func == (lhs: LaneView, rhs: LaneView) -> Bool { + lhs.lane == rhs.lane + && lhs.columns == rhs.columns + && lhs.slotWidth == rhs.slotWidth + && lhs.store === rhs.store + && lhs.marquee.isEquivalent(to: rhs.marquee) + && lhs.drops.isEquivalent(to: rhs.drops) + } + var body: some View { // `spacing: 0` and the padding moved inside: the accent band is **full-width** along the // lane's top edge, so it must sit outside the content inset rather than in it. @@ -679,6 +715,10 @@ struct LaneView: View { marquee: marquee, drops: drops ) + // **The value gate** (`CardFaceView.==`) — the lane's own, one level + // down: this body re-runs on every proposal change while a drag is over + // *this* lane, and the faces it draws are almost never what changed. + .equatable() case let .placeholder(phase): NewCardStubView(store: store, phase: phase, openCard: openCard) case let .shadow(_, height): diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift index e660c88..3b004b6 100644 --- a/Kanban/UI/Board/SelectionClicks.swift +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -40,6 +40,17 @@ struct MarqueeControl { let registry: MarqueeTargetRegistry let store: BoardStore + /// Whether two of these lend the same band — the whole of what this value contributes to + /// `LaneView.==` and `CardFaceView.==` (`BoardDropContext.isEquivalent(to:)` is its twin). + /// + /// All three members are window-lived objects, so identity is the comparison: this struct holds + /// no geometry and no closures of its own, and the strip rebuilds it on every body pass. + nonisolated func isEquivalent(to other: MarqueeControl) -> Bool { + session === other.session + && registry === other.registry + && store === other.store + } + /// The band, as one gesture attached with `simultaneousGesture` wherever empty space is. /// /// - **The begin guard is geometric**: a drag whose start lands inside a registered frame is diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index 9897e1c..f59030d 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -336,6 +336,8 @@ struct TrashLaneView: View { marquee: marquee, drops: drops ) + // The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`). + .equatable() case let .entry(.lane(lane)): // The opaque unit's row — its own view, because "no styling accents" and // "never expandable" are exactly what a card face is not diff --git a/KanbanTests/ViewEquatableTests.swift b/KanbanTests/ViewEquatableTests.swift new file mode 100644 index 0000000..7da10f2 --- /dev/null +++ b/KanbanTests/ViewEquatableTests.swift @@ -0,0 +1,369 @@ +import AppKit +import Foundation +import Testing +@testable import Kanban + +/// The board strip's two rebuild gates — `LaneView.==` and `CardFaceView.==`, 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. +/// +/// They do **not** pin the Observation half, and cannot: `store.selection`, `store.searchFilter`, +/// `drops.session`'s proposal and the rest invalidate these views directly, and `.equatable()` has +/// no say in that. 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 (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) + ) + 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) + ) + #expect(before == after) + } + + @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) + != CardFaceView(store: store, card: after, role: .board(openCard: { _ in }), + marquee: marquee, drops: drops)) + + // 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) + != CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }), + marquee: marquee, drops: drops)) + } + + @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) + let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations), + marquee: marquee, drops: drops) + // 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)) + // 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)) + } + + @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) + + #expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }), + marquee: marquee, drops: drops)) + #expect(base != CardFaceView( + store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee, + drops: makeDrops(store: store, session: DragSession(), registry: registry) + )) + #expect(base != CardFaceView( + store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee, + drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()) + )) + #expect(base != CardFaceView( + store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee, + drops: makeDrops(store: store, session: session, registry: registry, gap: 20) + )) + #expect(base != CardFaceView( + store: store, card: card, role: .board(openCard: { _ in }), + marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store), + drops: drops + )) + #expect(base != CardFaceView( + store: store, card: card, role: .board(openCard: { _ in }), + marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store), + drops: drops + )) + } +}