Files
lanework/KanbanTests/BoardRenderPerformanceTests.swift
T
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:02 -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: (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")
}
}