The popover/sheet split lands: a board-scoped, titled, sectioned sheet on the board window hosts everything setup-shaped, opened from the popover's Board Settings… row and the new Board ▸ Board Settings… menu row. The three existing setup controls relocate — add-git (whose noteFormVisible lines now make the sheet the form-anchored failure surface), branch creation (a standing field; create-and-switch runs the identical settle sequence), and the commit-identity fields (the 2s visibility-scoped poll rides with them). The popover keeps the daily face and its postures; its Pro/mode-none section becomes header + door (.addGit renamed .noRepository). Availability is derived from the section inventory (Pro + mode none or git), so pro-m2's sections can't drift from the doors; the sheet's fields join the caret-chord disable set. The audit suite pins what the free fixture can reach; the sheet's own audit is manual until a tier override is ruled. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
246 lines
13 KiB
Swift
246 lines
13 KiB
Swift
import XCTest
|
|
|
|
/// **The automated half of 10-accessibility.md ▸ Verification**, whose first clause is the reason
|
|
/// this file is a set of tests and not a checklist:
|
|
///
|
|
/// > **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`)
|
|
/// > runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit,
|
|
/// > raw source), welcome, template chooser, board popover, board settings sheet.
|
|
///
|
|
/// One test per surface, one audit call each. `performAccessibilityAudit` audits **the app's
|
|
/// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the
|
|
/// surface on screen, then let the audit look at whatever is there.
|
|
///
|
|
/// ### The one surface this suite cannot reach, and why it is not fixable here
|
|
///
|
|
/// The **board settings sheet** is Pro-only (03-board-ui.md ▸ Board settings sheet;
|
|
/// `BoardSettingsAvailability`), and the fixture launch has no tier control: `AppModel.currentTier`
|
|
/// reads `ProEntitlement`, which reads StoreKit, and there is deliberately **no launch argument that
|
|
/// grants Pro**. Adding one would be worse than the gap it closed — `UITestLaunch` is compiled into
|
|
/// the shipping binary on purpose ("the thing being audited must be the app that ships"), so a
|
|
/// `--ui-test-pro` flag would be a subscription bypass anyone could type into Terminal. So the sheet's
|
|
/// own audit is **deferred to the manual VoiceOver pass** (`AccessibilityVerification.md`), and what
|
|
/// is automated here instead is the posture that *is* reachable: the free tier's, where the row exists
|
|
/// and is disabled. See `testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture`.
|
|
///
|
|
/// ### No waiving
|
|
///
|
|
/// The audit's issue handler is where a false positive would be excused, and every test here passes
|
|
/// `nil` — no handler, nothing excused. That is the design's own posture ("violations are test
|
|
/// failures, not warnings"), and it is worth keeping literal: a handler that swallowed one issue type
|
|
/// app-wide would also swallow the next real one of that type, on a surface nobody was thinking about
|
|
/// when the waiver was written. Should a genuine platform false positive ever need excusing, it goes
|
|
/// in as a closure that matches **that one element on that one surface** and carries the reason in a
|
|
/// comment beside it — never a bare `return true`.
|
|
///
|
|
/// ### `.all`, not a narrowed set
|
|
///
|
|
/// `XCUIAccessibilityAuditType.all` is the default and stays the default. The narrower types
|
|
/// (`.contrast`, `.elementDetection`, `.hitRegion`, `.sufficientElementDescription`, `.textClipped`,
|
|
/// `.trait`) each map onto a rule 10-accessibility.md states — contrast is its ≥ 4.5:1 clause,
|
|
/// sufficient-description is its labels, trait is its selection and heading traits — so scoping any
|
|
/// of them out would be scoping out a design rule. They are named here only so a future narrowing has
|
|
/// to argue with this paragraph first.
|
|
///
|
|
/// ### The fixture board
|
|
///
|
|
/// Every test launches with `UITestLaunch.fixtureFlag`, which makes the app build a known board
|
|
/// inside its own container and open it (see `UITestLaunch` for why the board cannot simply be
|
|
/// handed over on the command line — the sandbox). The board is three lanes, six cards, one card
|
|
/// with a rich Markdown body and an attachment, and one card already in `.trash/`. That is the
|
|
/// `standard` fixture variant; the launch helpers and the other two shapes live in
|
|
/// `UITestSupport.swift`.
|
|
///
|
|
/// ### Running these
|
|
///
|
|
/// They drive the real app through the real menu bar, so the machine running them must have granted
|
|
/// the test runner Accessibility control (System Settings ▸ Privacy & Security ▸ Accessibility) and
|
|
/// must not be locked or headless. A run that cannot get automation permission fails on the first
|
|
/// `click()`, not on an accessibility defect — see `KanbanUITests/AccessibilityVerification.md`,
|
|
/// which puts this suite at the top of the manual pass for exactly that reason.
|
|
///
|
|
/// ### Reading a failure
|
|
///
|
|
/// The navigation waits are deliberately loud, and they are also the part most likely to need
|
|
/// adjusting: a window is identified by its **title** (`app.windows["…"]`, which is what
|
|
/// `navigationTitle` produces) and an on-board element by its **label**. Both are specified —
|
|
/// 11-command-nexus.md fixes the menu titles, `AccessibilityPhrases` fixes the labels — but neither
|
|
/// says which `XCUIElement.ElementType` SwiftUI will choose, and a hidden-title-bar window (welcome)
|
|
/// is the one place a title might not surface at all. So: a failure on `performAccessibilityAudit`
|
|
/// is an accessibility defect and is what this suite is for; a failure on a `waitForExistence` above
|
|
/// it is a navigation problem in *this file*, and the audit never ran.
|
|
final class AccessibilityAuditTests: XCTestCase {
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
// A failed navigation step makes every later step in that test meaningless — and an audit
|
|
// that ran against the wrong surface would report a *pass*, which is worse than a failure.
|
|
continueAfterFailure = false
|
|
}
|
|
|
|
// MARK: - The board window
|
|
|
|
/// The board as it opens: lanes, cards, the toolbar, the search field — trash hidden, which is
|
|
/// the board's default state (03-board-ui.md § Trash).
|
|
@MainActor
|
|
func testBoardWindowWithTrashHidden() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
/// The same board with View ▸ Show Trash on — "when shown, it is the last container, labeled as
|
|
/// Trash with its count" (10-accessibility.md ▸ Trash lane), holding the fixture's one trashed
|
|
/// card so the column's own card elements are audited and not just its header.
|
|
@MainActor
|
|
func testBoardWindowWithTrashShown() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
app.clickMenuItem("Show Trash", in: "View")
|
|
XCTAssertTrue(
|
|
app.element(labeled: "Trash").waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"the trash column did not appear"
|
|
)
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
// MARK: - The card window
|
|
|
|
/// Preview mode — the card window's default. The fixture's rich card carries the structures 10
|
|
/// makes claims about (headings, lists, a task list, a table, a code block, a link, an image with
|
|
/// alt text), so this is the audit of "Preview renders to the accessibility tree as structured
|
|
/// text" rather than of an empty body.
|
|
@MainActor
|
|
func testCardWindowPreviewMode() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
try app.openRichCardWindow()
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
/// Edit mode — View ▸ Edit Body (⌘E), "an ordinary accessible text editor" (10 ▸ Card window).
|
|
@MainActor
|
|
func testCardWindowEditMode() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
try app.openRichCardWindow()
|
|
app.clickMenuItem("Edit Body", in: "View")
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
/// The raw-source outlet — View ▸ Raw Source (⌥⌘E), the whole `index.md` as text.
|
|
///
|
|
/// Entered from Preview rather than from Edit, because the two are mutually exclusive by design
|
|
/// ("Edit Body disables while Raw Source is active" — 05-card-window.md ▸ Raw source outlet) and
|
|
/// stacking them would be auditing a state the app does not have.
|
|
@MainActor
|
|
func testCardWindowRawSourceMode() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
try app.openRichCardWindow()
|
|
app.clickMenuItem("Raw Source", in: "View")
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
/// **The comments pane**, over the fixture's seeded thread (10-accessibility.md ▸ Comments: the
|
|
/// labeled container, the flattened comment elements with their three custom actions, the labeled
|
|
/// composer and the Tab-reachable sort control).
|
|
///
|
|
/// The pane is on screen already — View ▸ Show Comments is one persisted app-wide bit and its
|
|
/// shipped default is on (05-card-window.md ▸ The comments column) — so this test's navigation is
|
|
/// the card window's, plus a wait on the container's own label to prove the pane rendered rather
|
|
/// than auditing a body column that happened to be alone.
|
|
///
|
|
/// The thread is three comments, one of them unattributed and one edited (`UITestLaunch`), which
|
|
/// is what makes this an audit of the *rows* rather than of an empty invitation.
|
|
@MainActor
|
|
func testCardWindowCommentsPane() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
try app.openRichCardWindow()
|
|
XCTAssertTrue(
|
|
app.element(labeled: Phrase.comments(3)).waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"the comments pane did not appear"
|
|
)
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
// MARK: - Welcome, the template chooser, the board popover
|
|
|
|
/// The welcome window, reached by its own Window-menu row — and reached *after* the fixture board
|
|
/// has opened, so its recents list has a row in it. An empty welcome would audit the empty state
|
|
/// and miss the rows 10 specifies ("⟨name⟩, ⟨location⟩, N lanes, M cards").
|
|
@MainActor
|
|
func testWelcomeWindow() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
app.clickMenuItem("Welcome to Lanework", in: "Window")
|
|
XCTAssertTrue(
|
|
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"the welcome window did not appear"
|
|
)
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
/// The template chooser — File ▸ New Board… (⌥⌘N), the ten bundled templates as elements
|
|
/// "labeled by title", their mini previews hidden (10 ▸ Template chooser).
|
|
@MainActor
|
|
func testTemplateChooser() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
app.clickMenuItem("New Board…", in: "File")
|
|
XCTAssertTrue(
|
|
app.windows["New Board"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"the template chooser did not appear"
|
|
)
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
/// The board popover — File ▸ Board Info (⌘I): "labeled controls throughout", with the git slot's
|
|
/// information readable as text and never by colour or shape alone (10 ▸ Board popover).
|
|
@MainActor
|
|
func testBoardInfoPopover() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
app.clickMenuItem("Board Info", in: "File")
|
|
XCTAssertTrue(
|
|
app.popovers.firstMatch.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"the board popover did not appear"
|
|
)
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
|
|
// MARK: - The board settings sheet
|
|
|
|
/// **The reachable half of the settings sheet's audit** — the free tier's, which is the tier the
|
|
/// fixture launch runs in (see this file's header for why there is no Pro fixture and why adding
|
|
/// one would be a subscription bypass).
|
|
///
|
|
/// What it pins is the design's deliberate asymmetry between the sheet's two doors
|
|
/// (`BoardSettingsAvailability`): the **menu row stays visible and disabled** where the sheet
|
|
/// cannot exist — standard menu validation, and 11-command-nexus.md's "a command absent here
|
|
/// doesn't exist" read in the other direction — while the popover's row is *absent* there instead.
|
|
/// A row that vanished from the menu on the free tier would be the app hiding its own inventory;
|
|
/// one that was enabled would present an empty sheet.
|
|
///
|
|
/// The audit call is the board's, taken with the Board menu closed again: the sheet is what this
|
|
/// test cannot open, so auditing the surface that *is* on screen keeps the test honest about what
|
|
/// it checked.
|
|
@MainActor
|
|
func testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture() throws {
|
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
|
|
|
let bar = app.menuBars.firstMatch
|
|
let boardMenu = bar.menuBarItems["Board"]
|
|
XCTAssertTrue(
|
|
boardMenu.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"the Board menu is missing from the menu bar"
|
|
)
|
|
boardMenu.click()
|
|
|
|
let row = bar.menuItems["Board Settings…"]
|
|
XCTAssertTrue(
|
|
row.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
|
"Board ▸ Board Settings… is missing — the row ships whether or not this board can open it"
|
|
)
|
|
XCTAssertFalse(
|
|
row.isEnabled,
|
|
"the free tier has no board settings sheet, so the row must be disabled rather than open an empty one"
|
|
)
|
|
app.typeKey(.escape, modifierFlags: [])
|
|
|
|
try app.performAccessibilityAudit()
|
|
}
|
|
}
|