import XCTest /// **Performance sanity on a big board** — 8 lanes × 40 cards (`UITestLaunch.FixtureVariant.large`), /// measured on launch and on one interaction. /// /// ### Why the budgets are wall-clock assertions and not baselines /// /// `XCTMeasureOptions` and `XCTPerformanceMetric` baselines are stored *per device* in the Xcode /// project's `xcshareddata`, keyed by a machine identifier. They do not travel: a fresh clone, a /// different Mac, or CI has no baseline at all, so `measure` there records a number and passes /// unconditionally. A "performance test" that passes unconditionally is not a gate — it is a log /// line — and this repository has no CI to grow one on. /// /// So each test does both, and they do different jobs: /// /// - **The `measure` block records the metric**, so a developer reading the test report can see the /// distribution and Xcode can offer a baseline locally to whoever wants one. /// - **The explicit wall-clock assertion is the gate.** It is a real bound, checked everywhere, with /// no stored state behind it. /// /// ### The budgets, and what they are budgets for /// /// They are **generous on purpose** and they are *regression* bounds, not targets. What they have to /// catch is a change that makes the large board an order of magnitude worse — an O(n²) reflow, a /// synchronous tree walk on the main thread, a per-card watcher — and what they must never do is fail /// because a laptop was busy. The numbers below are roughly 5× the measured cost on the development /// machine, which leaves a slow or loaded machine plenty of room while still failing a tenfold /// regression. /// /// | Budget | Value | What it covers | /// | --- | --- | --- | /// | `launchBudget` | 30 s | Process start, **materializing 328 folders through `BoardWriter`**, the load, and the first frame with a lane on it | /// | `interactionBudget` | 5 s | View ▸ Show Trash on a 320-card board: the re-divide, every lane's reflow, and the column arriving | /// /// The launch budget's dominant term is the fixture *build*, not the app: writing an `index.md` per /// card takes about two seconds on the development machine (`UITestLargeFixtureBoardTests` measures /// the same work without a window). That is deliberate — the number stays honest about what a launch /// on this fixture actually costs — but it is why the budget is not tighter, and why a launch /// regression shows up here as a big move rather than a small one. /// /// ### Running these /// /// Like the rest of `KanbanUITests`: a real, unlocked display and Accessibility automation /// permission. They are also the slowest tests in the repository — each one builds the large board /// once per `measure` iteration — so they are excluded from the ordinary run and invoked by name. /// `KanbanUITests/EndToEndVerification.md` carries the command. final class LargeBoardPerformanceTests: XCTestCase { /// Launch to a board window with the large fixture on it. static let launchBudget: TimeInterval = 30 /// One board-wide interaction: View ▸ Show Trash, which re-divides every lane. static let interactionBudget: TimeInterval = 5 override func setUp() { super.setUp() continueAfterFailure = false } /// **Launch-to-board**, measured with `XCTApplicationLaunchMetric` and gated by /// `launchBudget`. /// /// `.manuallyStart`, so the measured span is the launch and the window — not the teardown of the /// previous iteration's app, which would otherwise be folded into the number. /// /// The gate is timed separately, before the measure block, for two reasons: a `measure` body runs /// five times by default and asserting inside it would report one failure per iteration, and the /// first launch is the one a user experiences (the later ones benefit from a warm dyld cache and /// a warm filesystem). @MainActor func testLargeBoardLaunchPerformance() throws { let started = Date() let first = XCUIApplication.launchedWithFixtureBoard(.large) let elapsed = Date().timeIntervalSince(started) first.terminate() XCTAssertLessThan( elapsed, Self.launchBudget, "launching onto the large fixture took \(String(format: "%.1f", elapsed))s, over the \(Self.launchBudget)s budget" ) let options = XCTMeasureOptions() options.invocationOptions = [.manuallyStart] measure(metrics: [XCTApplicationLaunchMetric()], options: options) { let app = XCUIApplication() app.launchArguments += FixtureBoard.large.launchArguments startMeasuring() app.launch() _ = app.windows[FixtureBoard.large.windowTitle].waitForExistence(timeout: XCUIApplication.uiTimeout) stopMeasuring() app.terminate() } } /// **One interaction on a full board**: View ▸ Show Trash, which 03-board-ui.md § Trash calls "a /// re-divide trigger, a lane add's behaviour exactly" — so every one of the eight lanes /// recomputes its width and relays its 40 cards. /// /// Chosen over select-all because it is *observable*: the column arriving is an element the test /// can wait for, so the span measured is the whole interaction rather than the round trip of a /// keystroke whose completion nothing announces. Select-all's cost is covered indirectly — it /// runs inside the same window, over the same 320 cards, in `SelectionGrammarTests`. /// /// The app is launched **once**, outside the measurement, and the toggle is measured over and /// back: showing and hiding are the same re-divide in two directions, and a pair per iteration is /// what leaves the board in the state the next iteration starts from. @MainActor func testLargeBoardShowTrashLatency() throws { let app = XCUIApplication.launchedWithFixtureBoard(.large) defer { app.terminate() } // The first toggle is timed on its own and is the gate — it is the one that pays for whatever // the board has not laid out yet, which is exactly the cost a user feels. let started = Date() app.clickMenuItem("Show Trash", in: "View") XCTAssertTrue( app.element(labeled: Phrase.trash).waitForExistence(timeout: XCUIApplication.uiTimeout), "the trash column never appeared on the large board" ) let elapsed = Date().timeIntervalSince(started) XCTAssertLessThan( elapsed, Self.interactionBudget, "showing the trash on the large board took \(String(format: "%.1f", elapsed))s, over the \(Self.interactionBudget)s budget" ) // And the recorded metric, over the toggle in both directions. measure(metrics: [XCTClockMetric()]) { app.clickMenuItem("Show Trash", in: "View") app.clickMenuItem("Show Trash", in: "View") } } }