Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board

A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on
the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body
read store.selection in three places — isSelected, the drag replica's count, and
the context menu's styleTarget — and Observation invalidates every reader of the
property, past the equatable gate entirely. The band overlay stayed cheap, which
is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind.

Now LaneView and TrashLaneView hoist one selection read per body and hand each
face isSelected/selectedCount as compared parameters; StyleMenuItems takes its
target as a deferred closure; TrashLaneRowView gains the same treatment plus the
Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A
growing band costs the selection's own running size; the real board's crossing
fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a
separate lane-level finding recorded in RENDER-INSTRUMENTATION.md.

Also: select() gains defaultsSoleMember — the marquee's explicit nils never
avoided the sole-member default, so a one-card band acquired a selectionHead and
could scroll the lane out from under its own drag.

MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a
growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
This commit is contained in:
2026-08-07 15:10:35 -04:00
parent 5779da2b6c
commit 9c857ae0cc
14 changed files with 795 additions and 79 deletions
+14 -6
View File
@@ -389,12 +389,20 @@ struct BoardRenderPerformanceTests {
// run. The other half of the gate: too strict a `==` would show up here as a zero.
#expect(selected.cards > 0, "selecting a card repainted nothing")
// What it actually costs is **every face on the board**, and that is the design rather than a
// defect: `CardFaceView.body` reads `store.selection` (`isSelected`), so a selection change
// invalidates all of them directly the Observation half the gates explicitly do not cover.
// Recorded here as a number rather than asserted as a budget: narrowing it would mean each
// face taking its own selected-ness as a compared parameter, which is a design change and not
// this card's. See RENDER-INSTRUMENTATION.md What the first run found.
// **And it costs a handful of faces, not the board.** This used to be "every face on the
// board" `CardFaceView.body` read `store.selection` for its own `isSelected`, so under
// Observation's property-level tracking one click invalidated all \(laneCount * cardsPerLane)
// of them *directly*, past the gate entirely (RENDER-INSTRUMENTATION.md Selection is
// O(board) in card bodies). Selected-ness is a compared parameter now the lane hoists the
// selection once and hands each face its answer so what re-runs is the lane bodies that
// were subscribed anyway plus the faces whose flag actually flipped.
//
// The budget is the one-card-edit budget above and it is loose for the same reason: SwiftUI
// evaluates a body more than once per update, so a single flipped face is worth several
// counts. What it rules out is the old shape, which was two orders of magnitude over this.
#expect(selected.cards <= 8,
"selecting one card re-rendered \(selected.cards) of \(laneCount * cardsPerLane) card faces")
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
}
+285
View File
@@ -0,0 +1,285 @@
import AppKit
import Foundation
import SwiftUI
import Testing
@testable import Kanban
/// **What a marquee drag sample costs the renderer** the regression suite for the selection fix
/// (2026-08-07), grown out of the investigation that found it.
///
/// `MarqueeControl.gesture`'s `onChanged` calls `store.select(ids, )` on EVERY mouse sample,
/// whether or not the swept set changed, and `TransientBoardState.select` writes its `@Observable`
/// properties unconditionally Observation notifies on every set, equal or not. So a band drag is a
/// stream of selection changes at pointer rate, and whatever one selection change costs the board,
/// the band pays it sixty times a second.
///
/// The investigation measured that cost at **180 card bodies and ~85 ms per sample** on the 6×30
/// fixture below (515 bodies, ~233 ms on a real 515-card board, debug): every face on the board,
/// every sample, because `CardFaceView.body` read `store.selection` for its own `isSelected` and
/// Observation tracks whole properties. `.equatable()` could not help a direct Observation
/// invalidation never consults the gate (RENDER-INSTRUMENTATION.md Selection is O(board) in card
/// bodies).
///
/// The fix made selected-ness a **compared parameter**: `LaneView` and `TrashLaneView` hoist one
/// selection read per body and hand each face `isSelected`/`selectedCount`, so a selection change
/// re-runs the lane bodies that were subscribed anyway plus the faces whose flag actually flipped.
/// These tests pin that shape a growing band pays per *crossing*, not per card on the board and
/// the redundant streams pin the other half: a sample that changes nothing costs nothing.
///
/// The prints stay: a budget says whether the shape held, and the numbers beside it say by how much.
// MARK: - Fixture (BoardRenderPerformanceTests' shape)
private let laneCount = 6
private let cardsPerLane = 30
private func laneName(_ lane: Int) -> String {
String(format: "1%07d-1111-4111-8111-111111111111", lane)
}
private func cardName(_ lane: Int, _ card: Int) -> String {
String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
}
@MainActor
private func makeFixture() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.board(title: "Marquee Cost Board")
for lane in 0..<laneCount {
try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
for card in 0..<cardsPerLane {
try fixture.card(
cardName(lane, card),
in: laneName(lane),
order: "\((card + 1) * 1024)",
title: "Card \(lane)-\(card)",
body: "Body text for card \(lane)-\(card)."
)
}
}
return fixture
}
// MARK: - Hosting (BoardRenderPerformanceTests' harness)
private struct ZoomedBoard: View {
let store: BoardStore
let window: @MainActor () -> NSWindow?
let confirmations: TrashConfirmations
let openCard: @MainActor (ItemID) -> Void
let search: BoardSearchPresentation
@Environment(AppModel.self) private var appModel
var body: some View {
BoardView(
store: store,
window: window,
confirmations: confirmations,
openCard: openCard,
search: search
)
.environment(\.boardZoom, appModel.zoom.context)
}
}
@MainActor
private final class HostedBoard {
let store: BoardStore
let appModel: AppModel
let window: NSWindow
let view: NSView
private let scratch: URL
private let preferencesDomain: String
init(store: BoardStore, scratch: URL) {
self.store = store
self.scratch = scratch
preferencesDomain = "dev.rzen.indie.Kanban.marquee-cost.\(UUID().uuidString)"
appModel = AppModel(
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
preferences: UserDefaults(suiteName: preferencesDomain)!
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
styleMask: [.titled], backing: .buffered, defer: false
)
self.window = window
let root = ZoomedBoard(
store: store,
window: { [weak window] in window },
confirmations: TrashConfirmations(),
openCard: { _ in },
search: BoardSearchPresentation()
)
.environment(appModel)
let hosting = NSHostingView(rootView: root)
hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
view = hosting
window.contentView = hosting
window.orderBack(nil)
settle()
}
deinit {
window.orderOut(nil)
window.contentView = nil
try? FileManager.default.removeItem(at: scratch)
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
}
func settle(turns: Int = 6) {
for _ in 0..<turns {
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
view.layoutSubtreeIfNeeded()
window.displayIfNeeded()
}
}
/// One drag-sample's worth of settling: a single runloop turn and display pass, which is what
/// the real event stream gets between two mouseDragged deliveries.
func settleOnce() {
RunLoop.main.run(until: Date().addingTimeInterval(0.001))
view.layoutSubtreeIfNeeded()
window.displayIfNeeded()
}
}
@MainActor
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
let scratch = FileManager.default.temporaryDirectory
.appendingPathComponent("MarqueeCost-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
}
// MARK: - The measurements
@MainActor
@Suite("Marquee drag-sample render cost", .serialized)
struct MarqueeRenderCostTests {
@Test("A stream of redundant selects — the marquee's steady state between card crossings")
func redundantSelectStream() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let store = board.store
// The band is sweeping ten cards and the cursor is moving inside the same footprint
// every sample recomputes the same set, exactly as MarqueeControl.gesture does today.
let swept = Set((0..<10).map { ItemID(rawValue: cardName(1, $0)) })
// First select: the legitimate change. Not measured here.
store.select(swept, in: .board, anchor: nil, head: nil)
board.settle()
let samples = 30
BoardRenderMetrics.reset()
let t0 = CACurrentMediaTime()
for _ in 0..<samples {
store.select(swept, in: .board, anchor: nil, head: nil)
board.settleOnce()
}
let elapsed = (CACurrentMediaTime() - t0) * 1000
let cards = BoardRenderMetrics.cardBodyEvaluations
let containers = BoardRenderMetrics.containerBodyEvaluations
let strips = BoardRenderMetrics.stripBodyEvaluations
print(String(
format: "── %d redundant selects — %d card bodies, %d containers, %d strips, %.1f ms total (%.2f ms/sample)",
samples, cards, containers, strips, elapsed, elapsed / Double(samples)
))
// **Zero, measured and the fix must not regress it.** A redundant select assigns the same
// value, so every face's `isSelected`/`selectedCount` comes back identical and the gate
// suppresses the lot; the lane bodies re-run (they are subscribed to the selection) and stop
// there. This was already 0 before the change, for a different reason the faces were
// invalidated directly but SwiftUI found their output unchanged so it is a tripwire on the
// steady state rather than a new win.
#expect(cards == 0, "a stream of redundant selects re-ran \(cards) card bodies")
}
@Test("A stream of growing selects — the band crossing one card per sample")
func growingSelectStream() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let store = board.store
let samples = 20
var swept: Set<ItemID> = [ItemID(rawValue: cardName(1, 0))]
store.select(swept, in: .board, anchor: nil, head: nil)
board.settle()
BoardRenderMetrics.reset()
let t0 = CACurrentMediaTime()
for i in 1...samples {
swept.insert(ItemID(rawValue: cardName(1, i % cardsPerLane)))
store.select(swept, in: .board, anchor: nil, head: nil)
board.settleOnce()
}
let elapsed = (CACurrentMediaTime() - t0) * 1000
let cards = BoardRenderMetrics.cardBodyEvaluations
print(String(
format: "── %d growing selects — %d card bodies, %.1f ms total (%.2f ms/sample)",
samples, cards, elapsed, elapsed / Double(samples)
))
// Something has to repaint: each sample adds one card to the band, and that card's face has
// to grow an accent ring.
#expect(cards > 0, "a growing band repainted nothing")
// **The regression pin, and what it is a budget *of*.** A sample re-runs the faces whose
// parameters moved, and a growing band moves two things: the newcomer's `isSelected`, and
// `selectedCount` for everyone already in the band the drag replica's fan and count badge
// are drawn from it, so a card that now travels with five others is genuinely a different
// face than one that travelled with four (`CardFaceView.dragReplica`). So the floor is the
// **selection's own running size, summed over the stream** 2 members after the first
// sample, \(samples + 1) after the last and not one flip per sample.
//
// That is the shape the fix bought: the cost follows what the user has selected, not what the
// board holds. Before it, every sample re-ran all \(laneCount * cardsPerLane) faces on the
// board whatever the band had swept 3,600 bodies over this stream, an order of magnitude
// over the budget below and independent of the selection entirely.
//
// The ×3 is `BoardRenderPerformanceTests`' slack rationale: SwiftUI evaluates a body more
// than once per update, so a changed face is worth more than one count. Measured at exactly
// the floor today.
let selectionWork = (2...(samples + 1)).reduce(0, +)
#expect(cards <= selectionWork * 3,
"\(samples) growing selects cost \(cards) card bodies — floor \(selectionWork), budget \(selectionWork * 3)")
}
@Test("A stream of redundant clears — the band sweeping empty space")
func redundantClearStream() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let store = board.store
store.clearSelection()
board.settle()
let samples = 30
BoardRenderMetrics.reset()
let t0 = CACurrentMediaTime()
for _ in 0..<samples {
store.clearSelection()
board.settleOnce()
}
let elapsed = (CACurrentMediaTime() - t0) * 1000
let cards = BoardRenderMetrics.cardBodyEvaluations
print(String(
format: "── %d redundant clears — %d card bodies, %.1f ms total (%.2f ms/sample)",
samples, cards, elapsed, elapsed / Double(samples)
))
// Zero, measured `redundantSelectStream`'s rule over an empty selection, which is the band
// sweeping the gutter between two lanes.
#expect(cards == 0, "a stream of redundant clears re-ran \(cards) card bodies")
}
}
@@ -441,6 +441,42 @@ struct TransientBoardStateTests {
#expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d"))
}
// MARK: The cursors' sole-member default
@Test("The sole-member default is opt-out — a band that sweeps one card acquires no cursors")
func theSoleMemberDefaultIsOptOut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// **The default, unchanged**: a one-item selection made by any ordinary route is a legitimate
// range origin and a legitimate place to arrow from, so `nil` takes the sole member.
store.transient.select([card1], in: .board)
#expect(store.transient.selectionAnchor == card1)
#expect(store.transient.selectionHead == card1)
// **The opt-out**: a rubber band names no click to range from and no item to arrow from
// whatever it happens to enclose, and passing `nil` cannot say so `nil` is what *asks* for
// the default. `MarqueeControl.gesture` passes the flag, and this is what it buys: a band
// narrowed onto exactly one card leaves both cursors empty, so a -click after it acts plain
// and `LaneView.cardStack`'s scroll-to has no head to fire on mid-drag.
store.transient.select([card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
#expect(store.transient.selection.ids == [card2], "the selection itself still lands")
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
// The flag defaults nothing away: an explicit cursor is still taken verbatim.
store.transient.select([card2], in: .board, anchor: card2, head: card2, defaultsSoleMember: false)
#expect(store.transient.selectionAnchor == card2)
#expect(store.transient.selectionHead == card2)
// And the store's funnel forwards it the marquee calls `BoardStore.select`, not the
// transient's directly.
store.select([card1], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
}
// MARK: The last-active lane
@Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it")
+241 -24
View File
@@ -3,9 +3,9 @@ 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`.
/// 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
@@ -15,11 +15,21 @@ import Testing
/// 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 the board stops repainting an edited card must make its lane a different value, and a
/// newly selected card must make its face one.
///
/// 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.
/// **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
@@ -251,18 +261,58 @@ struct CardFaceViewEquatableTests {
card: card,
role: .board(openCard: { _ in }),
marquee: marquee,
drops: makeDrops(store: store, session: session, registry: registry)
drops: makeDrops(store: store, session: session, registry: registry),
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)
drops: makeDrops(store: store, session: session, registry: registry),
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, 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, 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, isSelected: true, selectedCount: 1
)
#expect(alone != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, isSelected: true, selectedCount: 5
))
}
@Test("An edited card is unequal — the gate never withholds a repaint")
func anEditedCardIsADifference() throws {
let fixture = try makeFixture()
@@ -281,18 +331,18 @@ struct CardFaceViewEquatableTests {
#expect(before != after)
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops)
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops))
marquee: marquee, drops: drops, 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)
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops))
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
}
@Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
@@ -310,19 +360,21 @@ struct CardFaceViewEquatableTests {
let confirmations = TrashConfirmations()
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops)
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
marquee: marquee, drops: drops)
marquee: marquee, drops: drops, 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))
marquee: marquee, drops: drops,
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))
marquee: marquee, drops: drops,
isSelected: false, selectedCount: 1))
}
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
@@ -339,31 +391,196 @@ struct CardFaceViewEquatableTests {
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)
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops))
marquee: marquee, drops: drops,
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)
drops: makeDrops(store: store, session: DragSession(), registry: registry),
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())
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
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)
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
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
drops: drops, 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
drops: drops, 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)
))
}
}