Drag instrumentation — render counters, hot-path signposts, and the invariants that prove the gates

BoardRenderMetrics (DEBUG-only, the pathfinder's counter bag plus a
strip-body discriminator that tells a failed gate from a direct
Observation invalidation) counted at BoardView/LaneView/CardFaceView/
TrashLaneView bodies and MasonryLayout's callbacks. DragSignposts wraps
dropUpdated, retargetCards, commitDrop, and the commit-to-covering-
snapshot release pause; input latency reports honestly against
NSApp.currentEvent's mach base or labels itself base=none — no event
timestamp rides the drop path. BoardRenderPerformanceTests hosts the
real BoardView off-screen: a value-equal reload runs zero lane and zero
card bodies, a one-card edit repaints one card of 180. The lane-body
budget is <= laneCount with the headerInk chain documented and a
two-way tripwire that fails when the fix lands. Methodology in
RENDER-INSTRUMENTATION.md. Release build proves it all compiles out.

Drag-perf card a450ad09.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 20:04:01 -04:00
parent 409f430813
commit 0218ae4c21
11 changed files with 861 additions and 0 deletions
@@ -0,0 +1,362 @@
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
/// 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
init(store: BoardStore, scratch: URL) {
self.store = store
self.scratch = scratch
appModel = AppModel(
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true)
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
styleMask: [.titled], backing: .buffered, defer: false
)
self.window = window
let root = BoardView(
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)
}
/// 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")
}
}