Files
lanework/KanbanUITests/LargeBoardPerformanceTests.swift
T
rzen f34707e17e Build the end-to-end UI test pass
The golden-path XCUITest suite, adapted to current rulings where the
card body had gone stale: trash flows follow the materialized-trash
grammar (no Put Back, restore is drag or cut/paste out), git flows are
pro-m1 scope and skipped, and fixtures extend m11's in-container
--ui-test-fixture-board mechanism (the sandbox forbids the card's
--open-board path handoff) with exact-match variant flags: standard
(the three-lane audit board), large (8 lanes x 40 cards for
masonry/reflow), malformed (BoardWriter-built board with one card's
index.md overwritten to unterminated YAML, opened through the ORDINARY
path so the failure is the loader's own).

EndToEndFlowTests: create card/lane, inline rename, coordinate drag
across lanes, cut/paste, undo/redo of a move, delete-to-trash /
show-trash / restore-by-cut-paste / Empty Trash confirm - all asserting
on lane accessibility labels. FailFastLaunchTests: welcome appears, no
board window ever, a welcome row carries the loader's sentence naming
the file; byte-fidelity of the malformed board pinned unconditionally
in KanbanTests plus an identically-refused relaunch. Performance:
launch metric plus explicit wall-clock gates (30s launch / 5s Show
Trash on 320 cards) since XCTest baselines don't travel. Powerbox
panels (template save panel, Duplicate fallback, Open) are documented
as manual in EndToEndVerification.md, not faked.

The smoke test now launches on the standard fixture (it launched bare
before, opening the developer's real boards); README's everyday test
command scopes to -only-testing:KanbanTests.

Suite compiles on both schemes (build-for-testing verified); flows
await a real display + automation permission to execute - run
instructions in KanbanUITests/EndToEndVerification.md. +8 unit tests;
1669 green both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 13:40:18 -04:00

136 lines
6.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}
}