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
352 lines
16 KiB
Swift
352 lines
16 KiB
Swift
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
|
||
)
|
||
}
|
||
}
|