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.. 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.. 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") } }