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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user