Files
lanework/KanbanTests/BoardRenderPerformanceTests.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

468 lines
23 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")
// **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")
}
/// **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")
}
}