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]) } /// 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 //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 } }