The golden-path XCUITest suite, adapted to current rulings where the card body had gone stale: trash flows follow the materialized-trash grammar (no Put Back, restore is drag or cut/paste out), git flows are pro-m1 scope and skipped, and fixtures extend m11's in-container --ui-test-fixture-board mechanism (the sandbox forbids the card's --open-board path handoff) with exact-match variant flags: standard (the three-lane audit board), large (8 lanes x 40 cards for masonry/reflow), malformed (BoardWriter-built board with one card's index.md overwritten to unterminated YAML, opened through the ORDINARY path so the failure is the loader's own). EndToEndFlowTests: create card/lane, inline rename, coordinate drag across lanes, cut/paste, undo/redo of a move, delete-to-trash / show-trash / restore-by-cut-paste / Empty Trash confirm - all asserting on lane accessibility labels. FailFastLaunchTests: welcome appears, no board window ever, a welcome row carries the loader's sentence naming the file; byte-fidelity of the malformed board pinned unconditionally in KanbanTests plus an identically-refused relaunch. Performance: launch metric plus explicit wall-clock gates (30s launch / 5s Show Trash on 320 cards) since XCTest baselines don't travel. Powerbox panels (template save panel, Duplicate fallback, Open) are documented as manual in EndToEndVerification.md, not faked. The smoke test now launches on the standard fixture (it launched bare before, opening the developer's real boards); README's everyday test command scopes to -only-testing:KanbanTests. Suite compiles on both schemes (build-for-testing verified); flows await a real display + automation permission to execute - run instructions in KanbanUITests/EndToEndVerification.md. +8 unit tests; 1669 green both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
389 lines
21 KiB
Swift
389 lines
21 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 variant flags
|
|
|
|
/// The three fixture shapes and the arguments that name them (`UITestLaunch.FixtureVariant`).
|
|
///
|
|
/// The parsing is the flag's, restated one level down — exact match, double dash, no `=value` and no
|
|
/// `--flag value` pair — so the whole family has one edge rather than two, and the tie-break exists
|
|
/// so a launch naming two variants is a decided case rather than an argument-order accident.
|
|
@Suite("The UI-test fixture variants")
|
|
struct UITestFixtureVariantTests {
|
|
|
|
@Test("A bare fixture flag is the standard board", arguments: [
|
|
["--ui-test-fixture-board"],
|
|
["/path/to/Lanework", "--ui-test-fixture-board", "-NSTreatUnknownArgumentsAsOpen", "NO"],
|
|
[],
|
|
])
|
|
func standardIsTheDefault(arguments: [String]) {
|
|
#expect(UITestLaunch.variant(arguments: arguments) == .standard)
|
|
}
|
|
|
|
@Test("A variant flag names its variant", arguments: UITestLaunch.FixtureVariant.allCases)
|
|
func variantRecognized(variant: UITestLaunch.FixtureVariant) {
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, variant.flag]) == variant)
|
|
// The pairing every call site uses is base-flag-plus-variant, but the variant alone is
|
|
// enough to mark the launch synthetic — otherwise a bundle that forgot the base flag would
|
|
// get an ordinary launch over the developer's real boards.
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: [variant.flag]))
|
|
}
|
|
|
|
/// The same fuzzy-edge rule the base flag has, applied to the family: a near-miss is not a flag,
|
|
/// and a near-miss is therefore not a fixture launch either.
|
|
@Test("A near-miss is not a variant flag")
|
|
func variantNotMatchedLoosely() {
|
|
let large = UITestLaunch.FixtureVariant.large
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "\(large.flag)r"]) == .standard)
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-board=large"]) == .standard)
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-variant", "large"]) == .standard)
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-large"]) == false)
|
|
}
|
|
|
|
/// Declaration order breaks a tie, whatever order the arguments arrived in.
|
|
@Test("Two variants named at once resolve in declaration order")
|
|
func variantTieBreak() {
|
|
let flags = [UITestLaunch.FixtureVariant.malformed.flag, UITestLaunch.FixtureVariant.large.flag]
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags) == .large)
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags.reversed()) == .large)
|
|
}
|
|
|
|
/// Every variant flag is double-dashed, for the base flag's reason, and every one of them is
|
|
/// distinct from the base flag and from its siblings — the window titles below rest on the same
|
|
/// distinctness, so a duplicate would be two boards claiming one name.
|
|
@Test("The flags and titles are double-dashed and distinct")
|
|
func flagsAreWellFormed() {
|
|
let variants = UITestLaunch.FixtureVariant.allCases
|
|
#expect(variants.allSatisfy { $0.flag.hasPrefix("--") })
|
|
#expect(Set(variants.map(\.flag)).count == variants.count)
|
|
#expect(variants.allSatisfy { $0.flag != UITestLaunch.fixtureFlag })
|
|
#expect(Set(variants.map(\.boardTitle)).count == variants.count)
|
|
// The standard variant's title is the one the audit suite has always waited on.
|
|
#expect(UITestLaunch.FixtureVariant.standard.boardTitle == UITestLaunch.boardTitle)
|
|
#expect(UITestLaunch.fixtureBoardURL == UITestLaunch.fixtureBoardURL(for: .standard))
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
///
|
|
/// Every variant's board, not just the audit's: they share one scratch root by construction, and
|
|
/// this is the assertion that keeps a future variant from inventing a second home.
|
|
@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))
|
|
for variant in UITestLaunch.FixtureVariant.allCases {
|
|
#expect(UITestLaunch.fixtureBoardURL(for: variant).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)
|
|
}
|
|
}
|
|
|
|
// MARK: - The large board
|
|
|
|
/// The performance suite's board (`UITestLaunch.FixtureVariant.large`).
|
|
///
|
|
/// Tested here for the audit fixture's reason turned up a notch: **the suite that consumes it cannot
|
|
/// be run in every environment**, and a large board that quietly came out small would turn a
|
|
/// performance measurement into a measurement of something else — one that *passes*, since a smaller
|
|
/// board is a faster one. So the counts are asserted, through the ordinary loader, where they can be
|
|
/// checked anywhere.
|
|
@Suite("The large fixture board")
|
|
struct UITestLargeFixtureBoardTests {
|
|
|
|
@Test("It builds at the stated size and loads through the ordinary loader")
|
|
func largeBoardLoads() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
let root = try UITestLaunch.materializeFixtureBoard(.large)
|
|
let result = try BoardLoader.load(boardRoot: root)
|
|
let model = result.model
|
|
|
|
// The window title the performance suite waits on.
|
|
#expect(model.title.value == UITestLaunch.FixtureVariant.large.boardTitle)
|
|
#expect(root == UITestLaunch.fixtureBoardURL(for: .large))
|
|
|
|
// The size the budgets in `EndToEndVerification.md` are budgets *for*. A board that came out
|
|
// a different size makes every one of them a number about a different board.
|
|
#expect(model.lanes.count == UITestLaunch.largeLaneCount)
|
|
#expect(model.lanes.allSatisfy { $0.cards.count == UITestLaunch.largeCardsPerLane })
|
|
#expect(model.lanes.map(\.cards.count).reduce(0, +) == UITestLaunch.largeLaneCount * UITestLaunch.largeCardsPerLane)
|
|
|
|
// Lanes in board order, exactly as named — the same claim the audit fixture makes, and for
|
|
// the same reason: a shuffled board would make a lane-addressed assertion meaningless.
|
|
#expect(model.lanes.map(\.title.value) == (0 ..< UITestLaunch.largeLaneCount).map(UITestLaunch.largeLaneTitle))
|
|
|
|
// Cards in card order within each lane, and every title distinct across the whole board —
|
|
// which is what lets a UI test name one card and mean one card.
|
|
for (laneIndex, lane) in model.lanes.enumerated() {
|
|
let expected = (0 ..< UITestLaunch.largeCardsPerLane).map {
|
|
UITestLaunch.largeCardTitle(lane: laneIndex, card: $0)
|
|
}
|
|
#expect(lane.cards.map(\.title.value) == expected)
|
|
}
|
|
let titles = model.lanes.flatMap { $0.cards.compactMap(\.title.value) }
|
|
#expect(Set(titles).count == titles.count)
|
|
|
|
// Four title lengths, cycled — the masonry has different card heights to balance rather than
|
|
// a perfect grid, which is the one thing about this board that is not simply "a lot of it".
|
|
let firstLane = try #require(model.lanes.first)
|
|
#expect(Set(firstLane.cards.compactMap(\.title.value).prefix(4).map(\.count)).count == 4)
|
|
|
|
// Nothing tolerated-but-notable: this board is built through the Writer alone, so a warning
|
|
// here would mean the *builder* left a stray behind.
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(model.trash.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - The malformed board
|
|
|
|
/// The fail-fast suite's board (`UITestLaunch.FixtureVariant.malformed`) — and the two claims the UI
|
|
/// suite makes about it, pinned where they can be checked without a display.
|
|
///
|
|
/// 01-storage-format.md § Malformed input is the rule under test: a structurally broken `index.md`
|
|
/// rejects **the whole load**, loudly, naming the file — and the app never rewrites what it could not
|
|
/// read (the Repair precedent, which `BoardLoader`'s own note states as "a load is a pure function of
|
|
/// the tree and writes nothing, ever").
|
|
@Suite("The malformed fixture board")
|
|
struct UITestMalformedFixtureBoardTests {
|
|
|
|
@Test("It builds, and then fails to load — loudly, naming the offending file")
|
|
func malformedBoardFailsFast() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
// Building succeeds. That is the point: the failure under test is the *loader's*, so a
|
|
// fixture that threw on the way in would surface a different sentence entirely.
|
|
let root = try UITestLaunch.materializeFixtureBoard(.malformed)
|
|
#expect(root == UITestLaunch.fixtureBoardURL(for: .malformed))
|
|
|
|
do {
|
|
_ = try BoardLoader.load(boardRoot: root)
|
|
Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
|
|
} catch let error as BoardLoadError {
|
|
// The path is board-relative and names the *file*, which is what the welcome row's
|
|
// failure caption carries and what the UI test asserts against.
|
|
#expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
|
|
#expect(error.path.split(separator: "/").count == 3, "the offending path names <lane>/<card>/index.md")
|
|
|
|
// The reason is the one the bytes were written to produce — unparseable YAML, not a
|
|
// missing field. A future edit to `malformedIndexText` that accidentally produced a
|
|
// *valid* file with a missing key would still fail the load, and this line is what
|
|
// would notice.
|
|
if case .unparseableYAML = error.reason {} else {
|
|
Issue.record("expected unparseable YAML, got \(error.reason)")
|
|
}
|
|
|
|
// The whole sentence, which is what actually reaches the user: file first, then why.
|
|
#expect(error.description.contains(BoardLoader.indexFileName))
|
|
#expect(error.description.lowercased().contains("yaml"))
|
|
} catch {
|
|
Issue.record("expected a BoardLoadError, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// **Nothing is silently repaired.** The refused load leaves the malformed bytes exactly as they
|
|
/// were written — no rewrite, no relocation into `.trash/`, no skip-and-continue — and the intact
|
|
/// siblings are untouched too.
|
|
///
|
|
/// This is the claim the UI suite can only make opportunistically (it can read the app's
|
|
/// container when the runner can reach it), so it is made unconditionally here.
|
|
@Test("A refused load repairs nothing")
|
|
func malformedBoardIsNotRepaired() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
let root = try UITestLaunch.materializeFixtureBoard(.malformed)
|
|
let before = try Self.tree(under: root)
|
|
|
|
// Twice, because a repair that only ran on the second attempt would be the worst kind.
|
|
for _ in 0 ..< 2 {
|
|
do {
|
|
_ = try BoardLoader.load(boardRoot: root)
|
|
Issue.record("the malformed board loaded")
|
|
} catch let error as BoardLoadError {
|
|
#expect(error.path.hasSuffix(BoardLoader.indexFileName))
|
|
} catch {
|
|
Issue.record("expected a BoardLoadError, got \(error)")
|
|
}
|
|
}
|
|
|
|
#expect(try Self.tree(under: root) == before)
|
|
|
|
// And the bytes themselves are the ones the fixture wrote, marker included — the string a UI
|
|
// test searches the container for when it can reach it.
|
|
let malformed = try #require(before.first { $0.value.contains(UITestLaunch.malformationMarker) })
|
|
#expect(malformed.value == UITestLaunch.malformedIndexText)
|
|
#expect(malformed.key.hasSuffix("/\(BoardLoader.indexFileName)"))
|
|
}
|
|
|
|
/// Every `index.md` beneath `root`, keyed by its board-relative path, read as raw text. Hidden
|
|
/// entries included, so a relocation into `.trash/` would show up as a new key rather than as a
|
|
/// silence.
|
|
private static func tree(under root: URL) throws -> [String: String] {
|
|
let manager = FileManager.default
|
|
guard let walker = manager.enumerator(atPath: root.path) else { return [:] }
|
|
var files: [String: String] = [:]
|
|
for case let relative as String in walker where relative.hasSuffix(BoardLoader.indexFileName) {
|
|
let data = try Data(contentsOf: root.appendingPathComponent(relative))
|
|
files[relative] = String(decoding: data, as: UTF8.self)
|
|
}
|
|
return files
|
|
}
|
|
}
|