Files
lanework/KanbanTests/BoardRenderPerformanceTests.swift
T
rzen 8aefaf23ce The empty provider was never load-bearing — the dragless layer frees the rubber band
Measured on real events 2026-08-07, correcting the 2026-08-06 hosted
finding: a bare count-1 tap on LaneView's empty-space layer fires in
~1-3 ms with no drag source at all — the hold that made the empty
.onDrag look necessary was the sterile NSApp.postEvent stream
over-disambiguating. And the provider was actively harmful: even an
empty drag source claims the mouse-drag at threshold, starving the
marquee's simultaneous DragGesture after one sample — the band froze
and the mouseUp never arrived. The layer goes dragless; drags from
empty space belong wholly to MarqueeControl. PointerClick's and the
layer's comments retell the corrected story. Alongside: openCard is
typed @MainActor throughout, which makes the closure Sendable and
lets CardFaceRole carry it under CardFaceView's nonisolated ==.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 12:55:54 -04:00

460 lines
22 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
/// **The board strip's render cost, measured on a real hosted board** (`BoardRenderMetrics`,
/// RENDER-INSTRUMENTATION.md).
///
/// Ported from the pathfinder's `BoardRenderPerformanceTests`, and the same instrument: a whole
/// `BoardView` in an off-screen `NSWindow`, driven exactly the way `FolderWatcher` drives it — one
/// `handleWatcherEvent(.treeChanged(.foreign))` per step — with `BoardRenderMetrics` read around
/// each. What is asserted is not a timing (a timing is a fact about this machine) but the two
/// **invariants the 2026-07-31 drag-performance work is a claim about**:
///
/// 1. **A reload that landed value-equal re-runs zero bodies.** Since 988a724 an equal walk skips
/// the snapshot assignment entirely (`landedReloads` moves, `snapshotGeneration` does not), so
/// this holds by construction rather than by a gate — and pinning it here is what makes the
/// *construction* checkable: a future edit that assigned an equal model back would break this
/// test rather than quietly costing a whole-board render pass on every `.git` touch.
/// 2. **A one-card edit re-renders a handful of bodies, not the board.** This is the equatable
/// gates' invariant (84f909a; `ViewEquatableTests` pins the comparison list, this pins the
/// effect), and the one that a gate silently coming undone would break.
///
/// ...and the counter-invariant that keeps the gates honest: **selecting a card must still repaint
/// it**. A gate that suppressed that would be a broken board, not a fast one.
///
/// ### What the first run found (2026-08-01)
///
/// Invariant 1 holds exactly. Invariant 2 holds for **cards** and fails for **containers**: a
/// one-card edit costs 1 card body out of 180, and one lane body per lane on the board — 6 of 6, and
/// 12 of 12 when the board is doubled. The cause is not the gate coming undone; it is
/// `LaneView.headerInk` reading `store.snapshot.background`, which under Observation's
/// whole-property tracking subscribes every lane body to the entire snapshot.
/// `theLaneCostFollowsTheBoard` pins both halves — the comparison is right, and it is never asked —
/// and the container budget in `aOneCardEditIsNotAWholeBoardRebuild` is written to the number the
/// tree actually produces, with the reason, rather than to the pathfinder's 4.
///
/// ### What is hosted
///
/// The **whole `BoardView`**, in a 1600×1000 off-screen window, with a real `AppModel` in the
/// environment (its registry file and clipboard staging redirected into the test's own temp folder,
/// `AppModelTests`' idiom). Not a lane-strip stand-in: the gates exist because `BoardView`'s body
/// reads the drag session and therefore re-runs for reasons that have nothing to do with any one
/// lane, and a harness that hosted `LaneView` directly would be asserting about a parent that does
/// not exist. The scaffolding turned out to be four values — the window closure, a
/// `TrashConfirmations`, an `openCard` closure and a `BoardSearchPresentation` — all of which a test
/// can supply honestly.
///
/// Boards are real loads off real temp trees, `ViewEquatableTests`' reason.
// MARK: - Fixture
/// The board's shape. Wide enough that "the whole board rebuilt" and "one card repainted" are
/// unmistakably different numbers, small enough that hosting it stays a unit test.
private let laneCount = 6
private let cardsPerLane = 30
/// Ids are derived rather than drawn from `Ident`, because this fixture needs 180 of them.
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(lanes laneCount: Int = laneCount, cards cardsPerLane: Int = cardsPerLane)
throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.board(title: "Perf 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
/// `BoardWindowHost`'s one relevant job, reproduced: read the app-wide zoom level and inject it as
/// the strip's ruler (03-board-ui.md ▸ Layout — zoom).
///
/// A wrapper rather than a `.environment(\.boardZoom, …)` on the hosted root, because the modifier's
/// argument is evaluated once when the root value is built and an `NSHostingView`'s root is a stored
/// value. Reading `appModel.zoom` inside a `body` is what makes a level change re-run this view and
/// therefore re-publish the environment — which is exactly the propagation path the real window uses,
/// and exactly what the zoom invariant below is a claim about.
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)
}
}
/// Everything the hosted board needs to stay alive for the length of a test — an `NSHostingView`
/// whose window is released the moment nothing holds it would stop rendering mid-measurement.
@MainActor
private final class HostedBoard {
let store: BoardStore
let appModel: AppModel
let window: NSWindow
let view: NSView
private let scratch: URL
/// The preferences domain the model's app-wide state persists into — redirected for
/// `registryStorageURL`'s reason. The zoom level lives here, and a suite that used `.standard`
/// would leave the developer's own boards zoomed.
private let preferencesDomain: String
init(store: BoardStore, scratch: URL) {
self.store = store
self.scratch = scratch
preferencesDomain = "dev.rzen.indie.Kanban.render-perf.\(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
// `orderBack` rather than `makeKeyAndOrderFront`: the board must lay out and render, and it
// must not steal focus from whatever is running the suite.
window.orderBack(nil)
settle()
}
deinit {
window.orderOut(nil)
window.contentView = nil
try? FileManager.default.removeItem(at: scratch)
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
}
/// Pumps the main run loop until SwiftUI has flushed its pending updates and laid the tree out.
///
/// Several short spins rather than one long one, the pathfinder's harness note: an update
/// scheduled *by* the previous flush needs another turn before it runs. `layoutSubtreeIfNeeded`
/// and `displayIfNeeded` are what actually force the bodies — a hosting view with no display
/// pass pending evaluates nothing, and the measurement would read zero for the wrong reason.
func settle(turns: Int = 6) {
for _ in 0..<turns {
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
view.layoutSubtreeIfNeeded()
window.displayIfNeeded()
}
}
/// One reload through the store's one inbound door, then a settle — exactly what `FolderWatcher`
/// causes when an agent or an editor writes into the tree.
func reload() async {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
settle()
}
}
@MainActor
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
let scratch = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardRenderPerf-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
}
/// The counters, snapshotted — reading them into a value keeps a `#expect` from racing a later body.
private struct Cost {
var strips: Int
var cards: Int
var containers: Int
var measures: Int
var cacheHits: Int
var sizeThatFits: Int
var places: Int
@MainActor
init() {
strips = BoardRenderMetrics.stripBodyEvaluations
cards = BoardRenderMetrics.cardBodyEvaluations
containers = BoardRenderMetrics.containerBodyEvaluations
measures = BoardRenderMetrics.masonryMeasurements
cacheHits = BoardRenderMetrics.masonryCacheHits
sizeThatFits = BoardRenderMetrics.masonrySizeThatFitsCalls
places = BoardRenderMetrics.masonryPlaceCalls
}
var summary: String {
"\(strips) strip bodies, \(containers) container bodies, \(cards) card bodies, "
+ "\(measures) measures (+\(cacheHits) cached) over "
+ "\(sizeThatFits) sizeThatFits / \(places) place"
}
}
// MARK: - The invariants
@MainActor
@Suite("The board strip's render cost", .serialized)
struct BoardRenderPerformanceTests {
@Test("The counters see a real hosted board — the harness itself, before anything is asserted on it")
func theHarnessActuallyRenders() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
BoardRenderMetrics.reset()
let board = try host(fixture)
let first = Cost()
withExtendedLifetime(board) {}
// A zero here would make every other assertion in this file vacuous — the invariants below
// are all "≤", and a harness that rendered nothing satisfies them perfectly.
#expect(first.cards >= laneCount * cardsPerLane,
"the first paint drew \(first.cards) of \(laneCount * cardsPerLane) card faces")
#expect(first.containers >= laneCount)
#expect(first.measures > 0, "the masonry measured nothing")
print("── first paint — \(first.summary)")
}
@Test("A reload that landed value-equal re-runs zero bodies")
func aValueEqualReloadCostsNothing() async throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let generationBefore = board.store.snapshotGeneration
let landedBefore = board.store.landedReloads
BoardRenderMetrics.reset()
await board.reload()
let idle = Cost()
print("── reload, nothing changed — \(idle.summary)")
// The walk happened and found the board it already had (988a724).
#expect(board.store.landedReloads == landedBefore + 1, "the reload did not land")
#expect(board.store.snapshotGeneration == generationBefore,
"a value-equal reload moved the snapshot generation")
#expect(idle.cards == 0, "an unchanged reload re-rendered \(idle.cards) card bodies")
#expect(idle.containers == 0, "an unchanged reload re-rendered \(idle.containers) container bodies")
}
@Test("A one-card edit re-renders a handful of bodies, not the board")
func aOneCardEditIsNotAWholeBoardRebuild() async throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let total = laneCount * cardsPerLane
// A foreign edit to exactly one card, the way an external editor makes one per keystroke.
try fixture.card(
cardName(3, 17), in: laneName(3), order: "\(18 * 1024)",
title: "Card 3-17 edited", body: "Body text for card 3-17, now edited."
)
BoardRenderMetrics.reset()
await board.reload()
let edit = Cost()
print("── reload, ONE card edited — \(edit.summary)")
#expect(board.store.snapshotGeneration > 0, "the edit never landed")
// **The card gate's invariant, at the pathfinder's budget exactly.** Loose enough to absorb
// SwiftUI evaluating a body more than once per update (the first paint runs each face three
// times), and two orders of magnitude under the \(total) an un-gated tree costs — which is
// what this measured before `CardFaceView.==`.
#expect(edit.cards <= 8, "a one-card external edit re-rendered \(edit.cards) of \(total) card faces")
// **The container budget is `laneCount`, not the pathfinder's 4 — and that is a diagnosed
// finding, not a slack threshold.** Every lane's body re-runs on every model-changing reload,
// and `LaneView.==` never gets a say, because `LaneView.body` reads `store.snapshot`:
//
// LaneView.body → header → .boardTextInk(headerInk) → headerInk
// → BoardTextInk.scheme(forBoardBackground: store.snapshot.background, …)
//
// Observation tracks whole *properties*, so reading `.background` off `store.snapshot`
// subscribes that body to the entire snapshot. A reload that changes one card anywhere
// assigns `store.snapshot` and invalidates every lane on the board **directly** — and a
// direct invalidation is precisely what `.equatable()` has no say over (`LaneView.==`'s own
// doc comment says so; the gate still does its job on every *parent-driven* pass, which is
// what `aValueEqualReloadCostsNothing` measures at 0 containers for a strip pass that did
// happen).
//
// `theLaneCostFollowsTheBoard` below pins both halves of that diagnosis. Fixing it means
// resolving the board's ink once in `BoardView` and passing it down as a compared parameter,
// the way `slotWidth` and `columns` already are — out of scope for an instrumentation card,
// and the budget here is written to the number the tree actually produces so the *card* gate
// stays assertable in the meantime.
#expect(edit.containers <= laneCount,
"a one-card external edit re-rendered \(edit.containers) bodies for \(laneCount) containers")
}
@Test("Every lane re-runs on a one-card edit, and not because its gate compared unequal")
func theLaneCostFollowsTheBoard() async throws {
let wide = laneCount * 2
let fixture = try makeFixture(lanes: wide, cards: 15)
defer { fixture.tearDown() }
let board = try host(fixture)
// An untouched sibling's value, either side of an edit to a different lane.
let siblingBefore = try #require(board.store.snapshot.lanes.first { $0.id == ItemID(rawValue: laneName(5)) })
try fixture.card(
cardName(3, 7), in: laneName(3), order: "\(8 * 1024)",
title: "Card 3-7 edited", body: "Body text for card 3-7, now edited."
)
BoardRenderMetrics.reset()
await board.reload()
let edit = Cost()
let siblingAfter = try #require(board.store.snapshot.lanes.first { $0.id == ItemID(rawValue: laneName(5)) })
print("── reload, ONE card edited, \(wide) lanes — \(edit.summary)")
// **Half one of the diagnosis: the gate's comparison is right.** An untouched sibling lane is
// the same `Lane` value across the reload, and every other member `LaneView.==` compares —
// `columns`, `slotWidth`, the store, the band, the drop context — is a window-lived constant
// here. So the gate would suppress, if it were ever asked.
#expect(siblingBefore == siblingAfter, "an untouched lane came back from the reload unequal")
// **Half two: it is never asked.** Doubling the lane count doubles the container cost, which
// is the signature of "every lane once" rather than "the edited lane several times". The
// cause is `LaneView.headerInk`'s `store.snapshot` read — see
// `aOneCardEditIsNotAWholeBoardRebuild` for the chain.
//
// Deliberately `>=`, as a tripwire in *both* directions: this failing because the number went
// **down** means the snapshot read has been hoisted out of `LaneView` and the container
// budget above should come down to the pathfinder's 4.
#expect(edit.containers >= wide,
"a one-card edit cost \(edit.containers) container bodies on a \(wide)-lane board — if that is below \(wide), the headerInk finding is fixed")
// The card gate is unaffected by any of it, on a board twice as wide.
#expect(edit.cards <= 8, "a one-card edit re-rendered \(edit.cards) of \(wide * 15) card faces")
}
@Test("Selecting a card still repaints it — the gate never went too far")
func selectionStillRepaints() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
BoardRenderMetrics.reset()
board.store.select([ItemID(rawValue: cardName(3, 17))], in: .board)
board.settle()
let selected = Cost()
print("── select one card — \(selected.summary)")
// Selection changes this face's *styling* (03-board-ui.md § Card face) — something has to
// 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.
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
}
/// **The zoom feature's load-bearing render claim** (03-board-ui.md ▸ Layout — zoom).
///
/// `CardFaceView` is `.equatable()` and its `==` compares nothing that moves with the zoom level:
/// the card, its role, its store, the marquee and the drop context — all identical across a
/// ⌘+. The level reaches the faces *only* because it travels in the environment, which
/// `CardFaceView`'s own note says the gate deliberately does not compare ("SwiftUI invalidates on
/// those itself").
///
/// That makes this the one assertion standing between the feature and a board that zooms its
/// lanes while every card face stays 13pt — a failure that would look like a rendering glitch and
/// actually be an architecture decision quietly coming undone. A zero here is the whole bug.
@Test("A zoom change repaints the card faces, through the equality gate")
func zoomRepaintsTheCardFaces() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
BoardRenderMetrics.reset()
board.appModel.zoom.step(.in)
board.settle()
let zoomed = Cost()
print("── zoom in one rung — \(zoomed.summary)")
#expect(board.appModel.zoom.level != BoardZoom.actualSize, "the level did not actually move")
#expect(zoomed.cards > 0, "a zoom change repainted no card faces — the equality gate swallowed it")
#expect(zoomed.containers > 0, "a zoom change repainted no lanes")
#expect(zoomed.strips >= 1, "the strip did not re-run for a zoom change")
// The masonry's own cache is keyed on column width, which moves with the card spacing, so a
// level change must also cost a re-measure rather than replay stale heights.
#expect(zoomed.measures > 0, "the masonry replayed heights measured on the old ruler")
}
/// The counter-invariant: Actual Size costs what it always did. A board nobody zooms must not pay
/// for the feature existing — the steady state after a reload is still the numbers the two
/// invariants above pin, with the ruler sitting on the system's own body size.
@Test("At Actual Size the strip renders on the system's own ruler")
func actualSizeIsTheUntouchedBoard() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
#expect(board.appModel.zoom.isActualSize, "a fresh domain must open unzoomed")
#expect(board.appModel.zoom.context.bodyPointSize == BoardMetrics.bodyPointSize)
BoardRenderMetrics.reset()
board.appModel.zoom.step(.actualSize)
board.settle()
let resettled = Cost()
print("── Actual Size when already there — \(resettled.summary)")
// `BoardZoomStore.setLevel` refuses an unchanged level outright — `@Observable` notifies on
// every set, equal or not, so without that guard re-asserting the level the board is already
// at would re-run the whole strip to draw exactly what it was drawing.
#expect(resettled.cards == 0, "a no-op Actual Size repainted \(resettled.cards) card faces")
#expect(resettled.containers == 0, "a no-op Actual Size repainted \(resettled.containers) lanes")
}
}