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. It /// is the only variant whose board window is *expected* never to appear. 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`**, which has no window to wait for: that variant is launched with /// `launched(with:)` and the caller waits for welcome 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 ) } }