Proposal changes stop rebuilding the whole board — lanes and card faces gate on their values
LaneView and CardFaceView become Equatable and are instantiated through .equatable(): the strip's body re-runs on every drop-proposal change, and without the gates that rebuilt every lane and every card face on every cursor move of a drag. The == compares value inputs and window-lived collaborator identities; the closures BoardView rebuilds each pass are deliberately excluded (BoardDropContext.isEquivalent / MarqueeControl. isEquivalent / CardFaceRole.isEquivalent own that judgment). Observation reads inside the bodies still self-invalidate — the lane under the drag keeps re-running; the other lanes stop. Ports the pathfinder's CardView/ColumnView gating pattern (drag-perf suspect #2, card cbb6e476). Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -110,6 +110,23 @@ struct BoardDropContext {
|
|||||||
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
|
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
|
||||||
let standard: @MainActor () -> CGFloat
|
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
|
// MARK: The cursor
|
||||||
|
|
||||||
/// The physical cursor in the window's SwiftUI global space.
|
/// The physical cursor in the window's SwiftUI global space.
|
||||||
|
|||||||
@@ -461,6 +461,10 @@ struct BoardView: View {
|
|||||||
marquee: marqueeControl,
|
marquee: marqueeControl,
|
||||||
openCard: openCard
|
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: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(width: slotWidth, alignment: .topLeading)
|
.frame(width: slotWidth, alignment: .topLeading)
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ enum CardFaceRole {
|
|||||||
case .trash: .trash
|
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
|
// 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
|
/// 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's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||||||
/// face).
|
/// 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 store: BoardStore
|
||||||
let card: Card
|
let card: Card
|
||||||
@@ -140,6 +168,30 @@ struct CardFaceView: View {
|
|||||||
/// fallback is for.
|
/// fallback is for.
|
||||||
@State private var measuredWidth: CGFloat = 0
|
@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
|
/// **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
|
/// 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
|
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
|
||||||
|
|||||||
@@ -32,7 +32,15 @@ import SwiftUI
|
|||||||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
|
/// 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
|
/// a click, and viewing an attachment's media is the card window's job, not the face's
|
||||||
/// (03-board-ui.md § Card face).
|
/// (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 store: BoardStore
|
||||||
let lane: Lane
|
let lane: Lane
|
||||||
@@ -109,6 +117,34 @@ struct LaneView: View {
|
|||||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||||
@State private var autoScroller = DragAutoScroller()
|
@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 {
|
var body: some View {
|
||||||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
// `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.
|
// 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,
|
marquee: marquee,
|
||||||
drops: drops
|
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):
|
case let .placeholder(phase):
|
||||||
NewCardStubView(store: store, phase: phase, openCard: openCard)
|
NewCardStubView(store: store, phase: phase, openCard: openCard)
|
||||||
case let .shadow(_, height):
|
case let .shadow(_, height):
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ struct MarqueeControl {
|
|||||||
let registry: MarqueeTargetRegistry
|
let registry: MarqueeTargetRegistry
|
||||||
let store: BoardStore
|
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 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
|
/// - **The begin guard is geometric**: a drag whose start lands inside a registered frame is
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ struct TrashLaneView: View {
|
|||||||
marquee: marquee,
|
marquee: marquee,
|
||||||
drops: drops
|
drops: drops
|
||||||
)
|
)
|
||||||
|
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
|
||||||
|
.equatable()
|
||||||
case let .entry(.lane(lane)):
|
case let .entry(.lane(lane)):
|
||||||
// The opaque unit's row — its own view, because "no styling accents" and
|
// The opaque unit's row — its own view, because "no styling accents" and
|
||||||
// "never expandable" are exactly what a card face is not
|
// "never expandable" are exactly what a card face is not
|
||||||
|
|||||||
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user