Files
lanework/KanbanTests/UITestLaunchTests.swift
T
rzen c5edcd8528 Run the accessibility verification pass
The automated half: AccessibilityAuditTests runs performAccessibilityAudit
over all eight surfaces DESIGN/10 names — board with trash hidden and
shown, card window in Preview/Edit/raw source, welcome, template chooser,
board popover. One audit per test, .all audit types, no issue handler —
nothing waived; a future false-positive excusal must match one element on
one surface with its reason beside it. Navigation is menu-bar titles and
the arrow grammar; no accessibility identifiers added to production code.

The suite launches with --ui-test-fixture-board: the sandbox forbids
handing the app a temp-folder path (no bookmark behind it), so the flag
carries no payload and the app builds a known board inside its own
container through the ordinary BoardWriter door — three lanes, six cards,
a rich Markdown body with attachment, one card already in .trash/ — with
the registry redirected to the same scratch directory so audit runs never
pollute real recents. LaunchPlan replaces the restore Bool (welcome /
restoreBoards / uiTestFixture, fixture wins outright), decided once in
KanbanApp.init and dispatched by RestoreBootstrapView; pure and pinned by
UITestLaunchTests, and the fixture itself is materialized and read back
through BoardLoader in units — the only proof available headlessly.

The manual half: KanbanUITests/AccessibilityVerification.md is the one
document — the audit suite at the top (it needs a real display and
Accessibility permission), the per-release VoiceOver smoke script with
expected utterances quoted from AccessibilityPhrases, and the
consolidated m11 checklist from all four implementation cards.

1588 unit tests green, UI target compiles, both schemes build. The audit
run and smoke script await a real display — the manual pass is the
user's.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 10:42:14 -04:00

166 lines
8.7 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// The launch decision and the audit suite's fixture board (10-accessibility.md ▸ Verification).
///
/// Two halves, tested for two different reasons. `LaunchPlan.decide` and the flag predicate are
/// **pure**, so they are pinned here the way every other launch-time rule in this app is — without a
/// `UserDefaults` domain, a live registry, or a running app (`AppModel.shouldRestoreAtLaunch`'s own
/// argument, which this composes).
///
/// The fixture board is tested here for a blunter reason: **the UI suite that consumes it cannot be
/// run in every environment** (it needs Accessibility automation permission and an unlocked
/// display), and a fixture that quietly failed to build would turn every audit into a pass over an
/// empty screen. Loading it back through the ordinary `BoardLoader` is the one check that runs
/// everywhere and would catch that.
// MARK: - The launch plan
@Suite("The launch plan")
struct LaunchPlanTests {
/// The three-way decision, exhaustively — the two-way gate `AppModel.shouldRestoreAtLaunch`
/// already owns, plus the fixture's outright precedence over both halves of it.
///
/// The precedence matters more than it looks: a UI-test launch that also restored the
/// developer's flagged boards would open real documents, run real watchers over them, and stamp
/// real registry records — during a test run whose whole premise is that nothing outside the
/// scratch directory is touched.
@Test(
"The fixture wins outright; otherwise the restore gate decides",
arguments: [
(fixture: true, preference: true, restorables: true, expected: LaunchPlan.uiTestFixture),
(fixture: true, preference: false, restorables: false, expected: LaunchPlan.uiTestFixture),
(fixture: false, preference: true, restorables: true, expected: LaunchPlan.restoreBoards),
(fixture: false, preference: true, restorables: false, expected: LaunchPlan.welcome),
(fixture: false, preference: false, restorables: true, expected: LaunchPlan.welcome),
(fixture: false, preference: false, restorables: false, expected: LaunchPlan.welcome),
]
)
func decision(fixture: Bool, preference: Bool, restorables: Bool, expected: LaunchPlan) {
#expect(
LaunchPlan.decide(
isUITestFixtureLaunch: fixture,
restorePreference: preference,
hasRestorables: restorables
) == expected
)
}
/// Which plans need the throwaway bootstrap window: both the ones that open something, neither
/// more. Welcome is a scene the app presents directly and needs no view to run a pass for it.
@Test("Only the two opening plans present the bootstrap window")
func bootstrapPresentation() {
#expect(LaunchPlan.welcome.presentsBootstrap == false)
#expect(LaunchPlan.restoreBoards.presentsBootstrap)
#expect(LaunchPlan.uiTestFixture.presentsBootstrap)
}
}
// MARK: - The flag
@Suite("The UI-test fixture flag")
struct UITestLaunchFlagTests {
@Test("An exact occurrence anywhere in the argument list asks for the fixture")
func flagRecognized() {
#expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework", UITestLaunch.fixtureFlag]))
#expect(UITestLaunch.isFixtureLaunch(arguments: [UITestLaunch.fixtureFlag, "-NSTreatUnknownArgumentsAsOpen", "NO"]))
}
/// An ordinary launch — including the one XCUITest performs with no arguments of its own — is
/// never a fixture launch.
@Test("An absent flag is an ordinary launch")
func flagAbsent() {
#expect(UITestLaunch.isFixtureLaunch(arguments: []) == false)
#expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework"]) == false)
}
/// **Exact match, not a prefix.** A launch switch with a fuzzy edge is a launch switch that can
/// be tripped by accident, and this one redirects the registry — the one place an accident would
/// look like the user's recents list having been wiped.
@Test("A near-miss is not the flag")
func flagNotMatchedLoosely() {
#expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)s"]) == false)
#expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)=basic"]) == false)
#expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-board"]) == false)
}
/// The flag is double-dashed so `UserDefaults`' `NSArgumentDomain` — which reads `-key value`
/// pairs — never sees it as a preference. Stated as a test because the consequence of getting it
/// wrong is invisible: the app would work, and a stray defaults key would appear.
@Test("The flag cannot be read as an argument-domain preference key")
func flagIsNotAPreferenceKey() {
#expect(UITestLaunch.fixtureFlag.hasPrefix("--"))
}
}
// MARK: - The fixture board
@Suite("The audit fixture board")
struct UITestFixtureBoardTests {
/// Builds the fixture exactly as a UI-test launch does, then reads it back through the ordinary
/// loader — the app's own answer to "is this a board", so the assertion is the same one the
/// board window would make.
///
/// The scratch directory is prepared first (which is what wipes any previous run's board) and
/// removed afterwards, so this test leaves the container as it found it.
@Test("It builds, loads, and has the shape the audit suite navigates")
func fixtureLoads() throws {
UITestLaunch.prepareScratchDirectory()
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
let root = try UITestLaunch.materializeFixtureBoard()
let model = try BoardLoader.load(boardRoot: root).model
// The window title the UI suite waits on.
#expect(model.title.value == UITestLaunch.boardTitle)
#expect(root.lastPathComponent == "\(UITestLaunch.boardTitle).kanban")
// Three lanes, in the order the fixture names them — which is also the order VoiceOver reads
// them in (10-accessibility.md ▸ Logical order), so a fixture whose lanes came out shuffled
// would make the traversal-order check meaningless.
#expect(model.lanes.map(\.title.value) == UITestLaunch.laneTitles)
// The first lane is the crowded one, minus the card the fixture deleted — the masonry
// divergence the audit is most interested in needs more than one card to diverge.
#expect(model.lanes[0].cards.count == UITestLaunch.cardTitles[0].count - 1)
#expect(model.lanes[0].cards.allSatisfy { $0.title.value != UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card] })
// The trashed card is in the trash container and nowhere else — cards only, no lane entries
// (03-board-ui.md § Trash).
#expect(model.trash.count == 1)
#expect(model.trash.first?.title.value == UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card])
// The rich card: the one the card-window audits open. Its window title is what the UI suite
// waits on, its body is what Preview renders into the tree, and its attachment is what the
// card element's value and the sidebar's row are made of.
let richLane = model.lanes[UITestLaunch.richCardIndex.lane]
let richCard = try #require(richLane.cards.first)
#expect(richCard.title.value == UITestLaunch.cardTitles[UITestLaunch.richCardIndex.lane][UITestLaunch.richCardIndex.card])
#expect(richCard.body.contains("## What this card is for"))
#expect(richCard.attachments == [UITestLaunch.attachmentName])
}
/// Everything the fixture launch writes stays inside the app's own container — the sandbox
/// constraint that decided the whole design (a path handed over on the command line would not be
/// readable), stated as a test so a future "just use `/tmp`" cannot land quietly.
@Test("Everything it writes is inside the app container")
func scratchIsContained() {
let container = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).standardizedFileURL.path
#expect(UITestLaunch.scratchRoot.standardizedFileURL.path.hasPrefix(container))
#expect(UITestLaunch.registryStorageURL.standardizedFileURL.path.hasPrefix(container))
#expect(UITestLaunch.fixtureBoardURL.standardizedFileURL.path.hasPrefix(container))
}
/// The fixture registry is **not** the real one — the clause that keeps an audit run out of the
/// user's recents list.
@Test("The fixture registry is not the app's real registry")
@MainActor
func registryIsRedirected() {
#expect(UITestLaunch.registryStorageURL != BoardRegistry.defaultStorageURL)
}
}