import AppKit import Foundation import SwiftUI import Testing @testable import Kanban /// **What a marquee drag sample costs the renderer** — the regression suite for the selection fix /// (2026-08-07), grown out of the investigation that found it. /// /// `MarqueeControl.gesture`'s `onChanged` calls `store.select(ids, …)` on EVERY mouse sample, /// whether or not the swept set changed, and `TransientBoardState.select` writes its `@Observable` /// properties unconditionally — Observation notifies on every set, equal or not. So a band drag is a /// stream of selection changes at pointer rate, and whatever one selection change costs the board, /// the band pays it sixty times a second. /// /// The investigation measured that cost at **180 card bodies and ~85 ms per sample** on the 6×30 /// fixture below (515 bodies, ~233 ms on a real 515-card board, debug): every face on the board, /// every sample, because `CardFaceView.body` read `store.selection` for its own `isSelected` and /// Observation tracks whole properties. `.equatable()` could not help — a direct Observation /// invalidation never consults the gate (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card /// bodies). /// /// The fix made selected-ness a **compared parameter**: `LaneView` and `TrashLaneView` hoist one /// selection read per body and hand each face `isSelected`/`selectedCount`, so a selection change /// re-runs the lane bodies that were subscribed anyway plus the faces whose flag actually flipped. /// These tests pin that shape — a growing band pays per *crossing*, not per card on the board — and /// the redundant streams pin the other half: a sample that changes nothing costs nothing. /// /// The prints stay: a budget says whether the shape held, and the numbers beside it say by how much. // MARK: - Fixture (BoardRenderPerformanceTests' shape) private let laneCount = 6 private let cardsPerLane = 30 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() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.board(title: "Marquee Cost 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) } } @MainActor private final class HostedBoard { let store: BoardStore let appModel: AppModel let window: NSWindow let view: NSView private let scratch: URL private let preferencesDomain: String init(store: BoardStore, scratch: URL) { self.store = store self.scratch = scratch preferencesDomain = "dev.rzen.indie.Kanban.marquee-cost.\(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 window.orderBack(nil) settle() } deinit { window.orderOut(nil) window.contentView = nil try? FileManager.default.removeItem(at: scratch) UserDefaults.standard.removePersistentDomain(forName: preferencesDomain) } func settle(turns: Int = 6) { for _ in 0.. HostedBoard { let scratch = FileManager.default.temporaryDirectory .appendingPathComponent("MarqueeCost-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch) } // MARK: - The measurements @MainActor @Suite("Marquee drag-sample render cost", .serialized) struct MarqueeRenderCostTests { @Test("A stream of redundant selects — the marquee's steady state between card crossings") func redundantSelectStream() throws { let fixture = try makeFixture() defer { fixture.tearDown() } let board = try host(fixture) let store = board.store // The band is sweeping ten cards and the cursor is moving inside the same footprint — // every sample recomputes the same set, exactly as MarqueeControl.gesture does today. let swept = Set((0..<10).map { ItemID(rawValue: cardName(1, $0)) }) // First select: the legitimate change. Not measured here. store.select(swept, in: .board, anchor: nil, head: nil) board.settle() let samples = 30 BoardRenderMetrics.reset() let t0 = CACurrentMediaTime() for _ in 0.. = [ItemID(rawValue: cardName(1, 0))] store.select(swept, in: .board, anchor: nil, head: nil) board.settle() BoardRenderMetrics.reset() let t0 = CACurrentMediaTime() for i in 1...samples { swept.insert(ItemID(rawValue: cardName(1, i % cardsPerLane))) store.select(swept, in: .board, anchor: nil, head: nil) board.settleOnce() } let elapsed = (CACurrentMediaTime() - t0) * 1000 let cards = BoardRenderMetrics.cardBodyEvaluations print(String( format: "── %d growing selects — %d card bodies, %.1f ms total (%.2f ms/sample)", samples, cards, elapsed, elapsed / Double(samples) )) // Something has to repaint: each sample adds one card to the band, and that card's face has // to grow an accent ring. #expect(cards > 0, "a growing band repainted nothing") // **The regression pin, and what it is a budget *of*.** A sample re-runs the faces whose // parameters moved, and a growing band moves two things: the newcomer's `isSelected`, and // `selectedCount` for everyone already in the band — the drag replica's fan and count badge // are drawn from it, so a card that now travels with five others is genuinely a different // face than one that travelled with four (`CardFaceView.dragReplica`). So the floor is the // **selection's own running size, summed over the stream** — 2 members after the first // sample, \(samples + 1) after the last — and not one flip per sample. // // That is the shape the fix bought: the cost follows what the user has selected, not what the // board holds. Before it, every sample re-ran all \(laneCount * cardsPerLane) faces on the // board whatever the band had swept — 3,600 bodies over this stream, an order of magnitude // over the budget below and independent of the selection entirely. // // The ×3 is `BoardRenderPerformanceTests`' slack rationale: SwiftUI evaluates a body more // than once per update, so a changed face is worth more than one count. Measured at exactly // the floor today. let selectionWork = (2...(samples + 1)).reduce(0, +) #expect(cards <= selectionWork * 3, "\(samples) growing selects cost \(cards) card bodies — floor \(selectionWork), budget \(selectionWork * 3)") } @Test("A stream of redundant clears — the band sweeping empty space") func redundantClearStream() throws { let fixture = try makeFixture() defer { fixture.tearDown() } let board = try host(fixture) let store = board.store store.clearSelection() board.settle() let samples = 30 BoardRenderMetrics.reset() let t0 = CACurrentMediaTime() for _ in 0..