Files
rzen 31fee00c73 The decision surface — a refused open becomes a live repair, in place
Phase 3 of the decision surface, completing the card (01 ▸ Malformed
input, settled 2026-07-31). An attended open's fail-fast walk transforms
the loading window's content into one aggregated surface — never a
sheet, never a chain: defects grouped by class, each class stated once
with its files listed (Reveal in Finder + Open in Editor per row), a
class-level default preselected, per-item override behind a disclosure.
Only honest choices: YAML and malformed-schema get Editor + Re-check
(Skip below the root); newer-than-app gets Skip alone and blocks the
board at the root; the two root repairs — minted index, schema: 1 stamp
— are defaults. Repair and Open applies fixes in one store-less write
bracket and re-walks: clean proceeds, remainder re-aggregates into the
same surface. Cancel and ⌘W retire to welcome's row; restored opens
never see the surface at all (OpenOrigin rides the PendingOpen carrier).

Skips are per-open consent that rides the session — the store retains
the skip set and every reload passes it — and the opened board posts a
warning-tone notice naming what was left out, each item's Reveal riding
the banner strip's new reveal control. On Pro boards the repair bracket
binds its own EchoLedger, heal-marks everything, and the store adopts it
before the committer starts, so repairs land as one separate commit
authored Lanework Integrity — pinned end to end. Also fixed en route: a
retired loading window left its close interception installed and
returned false from windowShouldClose forever, blocking quit.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 10:52:02 -04:00

352 lines
16 KiB
Swift
Raw Permalink 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
// MARK: - The fixture boards
/// **The three boards the app can build for a UI test**, mirrored from
/// `UITestLaunch.FixtureVariant`.
///
/// It is a *mirror* and not an import because a UI test bundle does not link the app it drives: the
/// two ends of a launch argument are always a pair of literals, and this is the one place this end of
/// the pair is spelled. The app end is `UITestLaunch.FixtureVariant.flag` / `.boardTitle`; a change
/// to either without the matching change here shows up as a launch that opens welcome and a wait that
/// times out, which is the loudest failure available for a string pair.
enum FixtureBoard {
/// The audit board — three lanes ("To Do", "Doing", "Done"), six cards, one with a rich Markdown
/// body and an attachment, one already in `.trash/`. The end-to-end flows use it too: every one
/// of them asserts on a lane's spoken card count, and a shape small enough to state in a sentence
/// is a shape those assertions can be read against.
case standard
/// 8 lanes × 40 cards, for the performance pass. Nothing in it is interesting; there is simply a
/// lot of it.
case large
/// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. Its
/// window is the only one that never holds a board: the open is attended, so the refusal
/// transforms the loading content into the decision surface (`FailFastLaunchTests`).
case malformed
/// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this
/// launch is synthetic" means (it is what redirects the registry into the scratch directory), the
/// second is which board.
var launchArguments: [String] {
switch self {
case .standard: ["--ui-test-fixture-board", "--ui-test-fixture-standard"]
case .large: ["--ui-test-fixture-board", "--ui-test-fixture-large"]
case .malformed: ["--ui-test-fixture-board", "--ui-test-fixture-malformed"]
}
}
/// The board's title, which is also its window title (`BoardWindowHost.windowTitle`) and its
/// folder name.
var windowTitle: String {
switch self {
case .standard: "Audit Board"
case .large: "Large Board"
case .malformed: "Malformed Board"
}
}
}
// MARK: - The standard board's shape
/// The audit/end-to-end board as the tests address it: lane titles, card titles, and the labels
/// those produce.
///
/// Mirrored from `UITestLaunch` for `FixtureBoard`'s reason, and stated as *names* rather than
/// scattered string literals so that a test reads as a sentence about a board rather than as a
/// sentence about strings.
enum StandardBoard {
static let toDo = "To Do"
static let doing = "Doing"
static let done = "Done"
/// The first lane's cards, in card order — minus `trashed`, which the fixture deleted on the way
/// out, so the lane renders three of these four.
static let firstLaneCards = [
"Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit",
]
/// The card the fixture deleted into `.trash/` — the trash column's one occupant at launch.
static let trashed = "Check the trash grammar"
/// The sole card of "Doing" — the rich one, and the destination every cross-lane flow moves into.
static let richCard = "Write the smoke script"
/// The sole card of "Done".
static let doneCard = "Wire the launch fixture"
}
// MARK: - Phrases
/// The spoken vocabulary the board's elements wear, mirrored from `AccessibilityPhrases`.
///
/// The board's containers are addressed by these labels and nothing else — a lane's element *is*
/// "⟨title⟩, lane, N cards" — which makes a lane's card count assertable without any identifier of
/// our own. That is deliberate: the count is the one thing every one of these flows changes, and
/// asserting on the label asserts the visible badge, the spoken count and the layout all at once,
/// because 10-accessibility.md makes them one number.
enum Phrase {
/// "3 cards", "1 card" — the app's one plural folding for a card count.
static func cards(_ count: Int) -> String {
"\(count) card\(count == 1 ? "" : "s")"
}
/// A lane container's label: "⟨title⟩, lane, N cards".
static func lane(_ title: String, cards count: Int) -> String {
"\(title), lane, \(cards(count))"
}
/// The trash container's label. Its *value* is `cards(count)`; the label is the bare word.
static let trash = "Trash"
/// The untitled placeholder — what a lane created by File ▸ New Lane wears until it is renamed.
static let untitled = "Untitled"
/// The inline title editor's placeholders (`NewCardStubView`, `LaneView`, `CardFaceView`).
static let cardTitlePrompt = "Card title"
/// The comments pane's container label — "Comments, N" (10-accessibility.md ▸ Comments).
static func comments(_ count: Int) -> String {
"Comments, \(count)"
}
}
// MARK: - Driving the app
extension XCUIApplication {
/// One timeout for the whole suite. Generous, because a cold launch here also builds a board on
/// disk, and because a UI test that is slow is not a UI test that is wrong.
static let uiTimeout: TimeInterval = 30
/// The first element anywhere in the app carrying `label`, whatever type it turned out to be.
///
/// SwiftUI decides for itself which `XCUIElement.ElementType` a flattened accessibility element
/// lands as — a card face and a lane container are both "one element" by design and neither is
/// promised to be a `staticText` — so the waits in this file match on the label, which *is*
/// specified (`AccessibilityPhrases`), rather than on a type that is not.
@MainActor
func element(labeled label: String) -> XCUIElement {
descendants(matching: .any)
.matching(NSPredicate(format: "label == %@", label))
.firstMatch
}
/// The first element anywhere in the app whose label *contains* `fragment`.
///
/// Used only where the whole label is not the test's to predict — a welcome row combines a board
/// name, a location and a caption into one element, and the caption is the part under test.
@MainActor
func element(labelContaining fragment: String) -> XCUIElement {
descendants(matching: .any)
.matching(NSPredicate(format: "label CONTAINS %@", fragment))
.firstMatch
}
/// Launches the app on `board` — the audit fixture unless told otherwise — and waits for its
/// window.
///
/// **Not for `.malformed`**, whose window never holds a board: since the decision surface (01
/// § Malformed input, settled 2026-07-31) that variant's window does appear — carrying the
/// surface rather than lanes — so the wait below would pass while saying nothing. That variant is
/// launched with `launched(with:)` and the caller waits for the surface instead.
@MainActor
static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication {
let app = launched(with: board)
// Waiting on *this* window rather than on `windows.firstMatch` is what makes a failed fixture
// build a failure here instead of an audit that quietly passed over the welcome window.
XCTAssertTrue(
app.windows[board.windowTitle].waitForExistence(timeout: uiTimeout),
"the \(board.windowTitle) window did not open — check the app's launch log for a fixture build failure"
)
return app
}
/// Launches the app on `board` and returns as soon as it is frontmost — no window assertion, so
/// the caller decides what should have appeared.
@MainActor
static func launched(with board: FixtureBoard) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments += board.launchArguments
app.launch()
XCTAssertTrue(
app.wait(for: .runningForeground, timeout: uiTimeout),
"the app did not reach the foreground"
)
return app
}
/// Clicks a menu-bar row by its title.
///
/// **By title, because the titles are API** (04-interactions.md ▸ Configurable bindings: menu
/// items are remapped by title through `NSUserKeyEquivalents`, so the strings in
/// `KanbanApp.menuCommands` are already a published contract). Driving the menus rather than
/// typing the chords also means these tests exercise the same path a keyboard user takes, and a
/// row that silently disabled itself fails here as an un-hittable element rather than as a
/// keystroke that went nowhere.
@MainActor
func clickMenuItem(_ title: String, in menu: String) {
let bar = menuBars.firstMatch
let menuBarItem = bar.menuBarItems[menu]
XCTAssertTrue(
menuBarItem.waitForExistence(timeout: Self.uiTimeout),
"the \(menu) menu is missing from the menu bar"
)
menuBarItem.click()
let item = bar.menuItems[title]
XCTAssertTrue(
item.waitForExistence(timeout: Self.uiTimeout),
"\(menu)\(title) is missing"
)
XCTAssertTrue(item.isEnabled, "\(menu)\(title) is disabled")
item.click()
}
/// Whether a menu row is currently enabled — opened, read, and closed again with Escape.
///
/// Its own helper because *asking* is a different act from *clicking*: the template-chooser flow
/// needs to know that Choose is live without pressing it, since pressing it raises a save panel
/// this suite cannot drive.
@MainActor
func menuItemIsEnabled(_ title: String, in menu: String) -> Bool {
let bar = menuBars.firstMatch
let menuBarItem = bar.menuBarItems[menu]
guard menuBarItem.waitForExistence(timeout: Self.uiTimeout) else { return false }
menuBarItem.click()
let item = bar.menuItems[title]
let enabled = item.waitForExistence(timeout: Self.uiTimeout) && item.isEnabled
typeKey(.escape, modifierFlags: [])
return enabled
}
/// Selects the fixture's rich card and opens its window.
///
/// **The selection is seeded with one arrow press**, which is the app's own documented rule:
/// "an empty selection seeds at the first lane's first card" (04-interactions.md ▸ Grammar,
/// `BoardView.seed`). From there the walk to the rich card is ordinary arrow navigation — the
/// same keys a user without a pointer would press — so this helper needs to know no identifiers
/// and no geometry, only the fixture's shape.
///
/// The rich card is the sole card of lane 2 ("Doing"), so: one press to seed into lane 1's first
/// card, then one `→` to step into lane 2. `→` steps to the nearest card in that direction, and
/// with one card in the lane there is only one it can be.
@MainActor
func openRichCardWindow() throws {
typeKey(.downArrow, modifierFlags: [])
typeKey(.rightArrow, modifierFlags: [])
clickMenuItem("Open Card", in: "Board")
XCTAssertTrue(
windows[StandardBoard.richCard].waitForExistence(timeout: Self.uiTimeout),
"the card window did not open"
)
}
// MARK: - Board assertions
/// Waits for a lane to be rendering exactly `count` cards, by its label.
///
/// **This is how every flow in the end-to-end suite asserts that a move landed.** A card's own
/// element carries only its title, which does not change when it changes lanes; the lane's does,
/// because the label carries the count. So "the card moved from To Do to Doing" is checkable as
/// two lane labels and needs no identifier, no geometry and no reading of the disk.
@MainActor
@discardableResult
func awaitLane(_ title: String, cards count: Int, _ message: String = "") -> Bool {
let label = Phrase.lane(title, cards: count)
let found = element(labeled: label).waitForExistence(timeout: Self.uiTimeout)
XCTAssertTrue(found, "expected a lane reading \"\(label)\"\(message.isEmpty ? "" : " — \(message)")")
return found
}
/// Waits for the trash column to be rendering exactly `count` cards.
///
/// The trash's *label* is the bare word (it never states a count — 03-board-ui.md § Trash keeps
/// the header's title stable), so the count lives in its value and this matches on the pair.
@MainActor
@discardableResult
func awaitTrash(cards count: Int) -> Bool {
let predicate = NSPredicate(
format: "label == %@ AND value == %@", Phrase.trash, Phrase.cards(count)
)
let element = descendants(matching: .any).matching(predicate).firstMatch
let found = element.waitForExistence(timeout: Self.uiTimeout)
XCTAssertTrue(found, "expected the trash column to hold \(Phrase.cards(count))")
return found
}
/// Clicks a card by its title — the ordinary single click, which selects it
/// (`BoardStore.click`).
@MainActor
func selectCard(_ title: String) {
let card = element(labeled: title)
XCTAssertTrue(card.waitForExistence(timeout: Self.uiTimeout), "the card \"\(title)\" is not on the board")
card.click()
}
/// Waits for an inline title editor and answers it — the shared body of every flow that types a
/// title (`InlineTitleField`).
///
/// The editor is born focused, so the typing goes to the app rather than to a located element;
/// what is located is only the *field*, and only so that a flow which never opened one fails here
/// rather than typing its title into the board's keyboard grammar.
///
/// **Matched by placeholder first, then by "the only text field on screen".** The board window's
/// search field is an `NSSearchField` and so is not a `textField` at all, which leaves the inline
/// editor as the sole candidate; the placeholder match is the precise one and the fallback is
/// what keeps a SwiftUI change to how prompts reach the accessibility tree from failing every
/// flow at once.
@MainActor
func typeIntoInlineEditor(_ text: String, prompt: String, replacingExisting: Bool = false) {
let byPlaceholder = textFields
.matching(NSPredicate(format: "placeholderValue == %@", prompt))
.firstMatch
if !byPlaceholder.waitForExistence(timeout: 5) {
XCTAssertTrue(
textFields.firstMatch.waitForExistence(timeout: Self.uiTimeout),
"the inline title editor (\(prompt)) did not appear"
)
}
if replacingExisting {
// The rename editor opens seeded with the current title; Select All inside a focused
// field is the field editor's, not the board's (the board's own Select All disables
// while an inline editor is open — 04-interactions.md ▸ Grammar).
typeKey("a", modifierFlags: .command)
}
typeText(text)
typeKey(.return, modifierFlags: [])
}
/// Drags one card onto another — the pointer path (04-interactions.md ▸ Drag and drop), which is
/// a **real AppKit dragging session**: `CardFaceView` starts it from `.onDrag` with an
/// `NSItemProvider`, and the lane strip answers with `onDrop` delegates.
///
/// **This is the most environment-sensitive gesture in the suite**, and deliberately the slowest.
/// The press has to outlast the click-versus-drag threshold before the session arms, the movement
/// has to be slow enough that the drop delegates see intermediate positions (the reflow is
/// computed from them), and the hold at the end has to outlast the drop animation. A failure here
/// is worth re-running once before it is believed.
@MainActor
func dragCard(_ title: String, onto destinationTitle: String) {
let source = element(labeled: title)
let destination = element(labeled: destinationTitle)
XCTAssertTrue(source.waitForExistence(timeout: Self.uiTimeout), "the card \"\(title)\" is not on the board")
XCTAssertTrue(
destination.waitForExistence(timeout: Self.uiTimeout),
"the drop target \"\(destinationTitle)\" is not on the board"
)
let centre = CGVector(dx: 0.5, dy: 0.5)
source.coordinate(withNormalizedOffset: centre).press(
forDuration: 1,
thenDragTo: destination.coordinate(withNormalizedOffset: centre),
withVelocity: .slow,
thenHoldForDuration: 1
)
}
}