Files
lanework/KanbanTests/UITestLaunchTests.swift
rzen ba1726fa77 The loader collects every fail-fast defect and honors per-open skips
Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 09:12:49 -04:00

423 lines
23 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
)
}
}
// 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])
}
/// The rich card's **comment thread** — what the comments-pane audit is an audit *of*
/// (10-accessibility.md ▸ Comments).
///
/// The three shapes are the point: an ordinary comment, an unattributed one, and an edited one.
/// Each drives a different branch of the author line a flattened comment element wears
/// (`CommentAuthorLine`), and a fixture that quietly lost one of them would leave the audit
/// passing over a thread of three identical rows.
@Test("The rich card carries a thread the pane's audit can read")
func fixtureSeedsAThread() throws {
UITestLaunch.prepareScratchDirectory()
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
let root = try UITestLaunch.materializeFixtureBoard()
let model = try BoardLoader.load(boardRoot: root).model
let lane = model.lanes[UITestLaunch.richCardIndex.lane]
let card = try #require(lane.cards.first)
let cardFolder = ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
let thread = CommentThread.load(inCard: cardFolder, path: "\(lane.id.rawValue)/\(card.id.rawValue)")
#expect(thread.comments.count == UITestLaunch.commentBodies.count)
#expect(thread.strays.isEmpty)
// Posting restamps `created`, so the thread's chronological order is the order they were made.
#expect(thread.comments.map(\.body) != [])
let unattributed = try #require(
thread.comments.first { $0.body.contains("no `author` key") }
)
#expect(unattributed.author.value == nil)
#expect(!unattributed.isEdited)
let edited = try #require(thread.comments.first { $0.body == UITestLaunch.editedCommentBody })
#expect(edited.isEdited, "the edited marker is `modified` differing from `created`, and no extra field")
#expect(edited.author.value != nil)
// The board walk never loads any of it — the pane reads its own thread (01 ▸ Enhanced schema).
#expect(!card.body.contains("specimen thread"))
}
/// 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 failure as BoardLoadFailure {
// One broken file, so one defect: the fixture breaks a single card's index, and the
// aggregate is what the welcome row reads its caption off.
#expect(failure.defects.count == 1)
let error = failure.primary
// 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(failure.description.contains(BoardLoader.indexFileName))
#expect(failure.description.lowercased().contains("yaml"))
} catch {
Issue.record("expected a BoardLoadFailure, 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 failure as BoardLoadFailure {
#expect(failure.primary.path.hasSuffix(BoardLoader.indexFileName))
} catch {
Issue.record("expected a BoardLoadFailure, 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
}
}