Files
lanework/KanbanTests/MarqueeRenderCostTests.swift
T
rzen 9c857ae0cc 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.
2026-08-07 15:10:35 -04:00

286 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}