Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):
- AppGroup namespace: container resolution with per-edition fallback
when unprovisioned, shared UserDefaults suite, edition identity, and
a unit-test-host redirect (the test host IS the app — its launch
sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
openNow keyed by bundle id; hand-written Codable keeps legacy keys
decoding (adopted in memory as the running edition's slots, upgraded
on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
first click runs an open panel pre-anchored at the recorded path,
prompt "Grant"; recordOpen mints this edition's slot onto the
matched shared record (path fallback only after identity fails and
only against records holding no grant of ours, so re-granting never
forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
registry when the sibling edition wrote it, so one edition's save
never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
popover gains BoardEditionPresence ("Also open in Lanework Pro"),
pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
claims doomed trees by atomic rename into .sweeping/ then deletes,
so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
re-ruling; scalars (quick-style recents, window size) move to the
shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
family group). No pathfinder 1.x migrator: 1.x predates the
registry; state starts fresh in the group container.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
558 lines
30 KiB
Swift
558 lines
30 KiB
Swift
import Foundation
|
||
import os
|
||
|
||
// MARK: - The launch plan
|
||
|
||
/// What the app does with its first run-loop turn — decided once, in `KanbanApp.init()`, and never
|
||
/// re-derived (02-architecture.md § Launch and window lifecycle).
|
||
///
|
||
/// It exists because there are now **three** answers to "which window appears at launch", not two:
|
||
/// welcome, the registry's restoration pass, and — for the accessibility audit suite
|
||
/// (10-accessibility.md ▸ Verification) — a synthetic board the app builds for itself. A `Bool` held
|
||
/// two of them; a third would have meant two booleans and an implicit precedence between them, which
|
||
/// is exactly the shape that grows a launch bug nobody can reproduce.
|
||
///
|
||
/// The decision is a pure function of three facts, so it is provable without a `UserDefaults` domain,
|
||
/// a live registry, or a running app — `AppModel.shouldRestoreAtLaunch`'s own reason for being pulled
|
||
/// out of `App.init` in the first place, applied one level up.
|
||
enum LaunchPlan: Equatable, Sendable {
|
||
|
||
/// Nothing to restore and no fixture asked for: the welcome window, which is the ordinary
|
||
/// first-launch and tidied-away case.
|
||
case welcome
|
||
|
||
/// The registry's flagged boards reopen (`BoardRegistry.restorables()`).
|
||
case restoreBoards
|
||
|
||
/// The UI-test fixture board is built and opened. See `UITestLaunch`.
|
||
case uiTestFixture
|
||
|
||
/// **The fixture wins outright**, and that is the whole precedence rule: a UI-test launch must
|
||
/// never reopen the developer's own boards, both because the audit needs a board whose contents
|
||
/// the test knows and because a test run has no business touching real documents.
|
||
static func decide(
|
||
isUITestFixtureLaunch: Bool,
|
||
restorePreference: Bool,
|
||
hasRestorables: Bool
|
||
) -> LaunchPlan {
|
||
if isUITestFixtureLaunch { return .uiTestFixture }
|
||
return AppModel.shouldRestoreAtLaunch(
|
||
preference: restorePreference,
|
||
hasRestorables: hasRestorables
|
||
) ? .restoreBoards : .welcome
|
||
}
|
||
|
||
/// Whether the throwaway bootstrap window is presented at launch — everything except the plain
|
||
/// welcome case, since both other plans have to open windows and only a view can do that
|
||
/// (`RestoreBootstrapView`'s own reason for wearing a window).
|
||
var presentsBootstrap: Bool {
|
||
self != .welcome
|
||
}
|
||
}
|
||
|
||
// MARK: - UITestLaunch
|
||
|
||
/// **The UI suites' boards**, and the launch arguments that ask for them — the accessibility audit's
|
||
/// fixture (10-accessibility.md ▸ Verification: "Xcode's accessibility audit … runs in UI tests over
|
||
/// every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome,
|
||
/// template chooser, board popover") and, since the end-to-end pass, two more shapes that the audit
|
||
/// never needed: a **large** board for reflow and launch cost, and a **malformed** one whose only job
|
||
/// is to fail to load.
|
||
///
|
||
/// ### Why the app builds the board instead of being handed one
|
||
///
|
||
/// The obvious shape — the UI test writes a board to a temp folder and passes its path — **cannot
|
||
/// work here, and the reason is the sandbox**. `Kanban.entitlements` grants
|
||
/// `files.user-selected.read-write` and nothing else, so a path arriving on the command line is a
|
||
/// path the app may not read: there is no open panel behind it and no bookmark for it. The app can
|
||
/// only reach files it was granted, files it ships, and its own container.
|
||
///
|
||
/// So the flag carries no payload and the app builds the board **inside its own container**
|
||
/// (`NSTemporaryDirectory()`, which sandboxes to `…/Containers/dev.rzen.indie.Kanban/Data/tmp`),
|
||
/// through the ordinary `BoardWriter` — the app's single write door (02-architecture.md § Layering).
|
||
/// Nothing here knows the storage format: it calls `createBoard`, `createLane`, `createCard`,
|
||
/// `writeBody`, `importAttachments` and `deleteCardToTrash` exactly as the board window does, so the
|
||
/// fixture is a board the app made, not a board a test file *believes* is well-formed. A format
|
||
/// change that broke this would break the app first.
|
||
///
|
||
/// **The one deliberate exception is the malformed variant**, which builds its board through the very
|
||
/// same door and then overwrites exactly one card's `index.md` with raw bytes. That write is the
|
||
/// point of the variant — there is no Writer call that produces an unparseable file, and there should
|
||
/// not be one — and it happens *last*, so everything around the broken file is still a board the app
|
||
/// made.
|
||
///
|
||
/// ### It is inert without the flag
|
||
///
|
||
/// Every entry point below is reached only from `LaunchPlan.uiTestFixture`, and that case is reached
|
||
/// only when `--ui-test-fixture-board` is on the command line. No document open, no URL scheme and no
|
||
/// menu item can produce it; a shipped app never runs a line of this. It is compiled into the release
|
||
/// binary anyway rather than hidden behind `#if DEBUG`, because the thing being audited must be the
|
||
/// app that ships — an accessibility pass over a differently-compiled binary is a pass over a
|
||
/// different app.
|
||
///
|
||
/// ### What the flag also switches off
|
||
///
|
||
/// **The registry and the clipboard's staging store move into the scratch directory** with the board.
|
||
/// Without that, every audit run would stamp a temp folder into the user's real recents list
|
||
/// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a
|
||
/// directory that no longer exists — and its launch sweep would collect the user's real staged copy
|
||
/// (`ClipboardStore.defaultStagingRoot`). Both of those homes are now the **shared App Group
|
||
/// container** (12-editions.md ▸ Distribution, ruled 2026-07-29), so each of those side effects would
|
||
/// land on the sibling edition as well as this one. Tying them to the same flag rather than to separate
|
||
/// arguments is deliberate: they are one decision — "this launch is synthetic" — and a second argument
|
||
/// is a second chance to apply only half of it.
|
||
///
|
||
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
|
||
/// three app-wide scalars (`AppPreferences`) into the real domain — the group's shared suite since the
|
||
/// same ruling. They are a window size, a restore toggle this launch never consults, and the
|
||
/// quick-style recents list — no documents, nothing destructive, and redirecting a defaults domain from
|
||
/// inside the process is not something the platform actually supports. It is stated rather than
|
||
/// fixed.
|
||
enum UITestLaunch {
|
||
|
||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")
|
||
|
||
// MARK: - The flag
|
||
|
||
/// The launch argument every UI suite passes (`KanbanUITests/UITestSupport.swift`), on its own or
|
||
/// beside a variant flag. It means "this launch is synthetic" and nothing narrower — which board
|
||
/// is `FixtureVariant`'s to say.
|
||
///
|
||
/// `--`-prefixed on purpose: a single-dash `-key value` pair is swallowed by `UserDefaults`'
|
||
/// `NSArgumentDomain` and would silently become a preference, which is precisely the kind of
|
||
/// side effect a test-only switch must not have.
|
||
static let fixtureFlag = "--ui-test-fixture-board"
|
||
|
||
/// Whether `arguments` asks for a fixture board — **pure**, so the rule is pinned by
|
||
/// `UITestLaunchTests` rather than by launching an app and looking.
|
||
///
|
||
/// Exact match, not a prefix: `--ui-test-fixture-boards-elsewhere` is not this flag, and a
|
||
/// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge. That is also
|
||
/// why the variants below wear *sibling* flags rather than a `=value` suffix or a
|
||
/// `--flag value` pair — a suffix would demand exactly the prefix matching this rules out, and a
|
||
/// pair is the shape `NSArgumentDomain` swallows.
|
||
///
|
||
/// **A variant flag on its own is enough**, which is belt over braces rather than a second
|
||
/// spelling: every call site passes `fixtureFlag` too (it is what "this launch is synthetic"
|
||
/// means), but a test bundle that passed only `--ui-test-fixture-large` must not get a launch
|
||
/// that reopens the developer's real boards into a scratch registry's blind spot.
|
||
static func isFixtureLaunch(arguments: [String]) -> Bool {
|
||
arguments.contains(fixtureFlag)
|
||
|| FixtureVariant.allCases.contains { arguments.contains($0.flag) }
|
||
}
|
||
|
||
/// The running process's answer to the same question.
|
||
static var isFixtureLaunch: Bool {
|
||
isFixtureLaunch(arguments: ProcessInfo.processInfo.arguments)
|
||
}
|
||
|
||
// MARK: - The variants
|
||
|
||
/// **Which fixture board a launch asks for.** Three shapes, because the three suites that consume
|
||
/// them are asking three different questions:
|
||
///
|
||
/// - `standard` — the audit's board (three lanes, six cards, a rich card, a trashed card). It is
|
||
/// also the end-to-end suite's board: every golden flow is expressed against a shape small
|
||
/// enough to state in a sentence, so an assertion about a lane's card count is readable.
|
||
/// - `large` — many lanes × many cards, for the masonry, the reflow, and the launch-cost
|
||
/// measurements. Nothing about it is *interesting*; the point is that there is a lot of it.
|
||
/// - `malformed` — a well-formed board with exactly one unparseable card `index.md`, for the
|
||
/// fail-fast pass (01-storage-format.md § Malformed input). It is the only variant whose
|
||
/// *successful* materialization is expected to produce a *failed* load.
|
||
///
|
||
/// The raw value is the flag's tail, so the flag and the case can never drift; the flag is
|
||
/// double-dashed for `fixtureFlag`'s reason and exact-matched for its reason too.
|
||
enum FixtureVariant: String, CaseIterable, Sendable {
|
||
case standard
|
||
case large
|
||
case malformed
|
||
|
||
/// The launch argument naming this variant. Paired with `fixtureFlag` at every call site.
|
||
var flag: String { "--ui-test-fixture-\(rawValue)" }
|
||
|
||
/// The board's title — and, through `fixtureBoardURL(for:)`, its folder name and its window
|
||
/// title, so a test can wait on `app.windows["Audit Board"]` and its neighbours.
|
||
///
|
||
/// Distinct per variant on purpose: a suite that waited on the wrong title would otherwise
|
||
/// pass against the wrong board, and the malformed variant's whole assertion is that *no*
|
||
/// window by its name ever appears.
|
||
var boardTitle: String {
|
||
switch self {
|
||
case .standard: UITestLaunch.boardTitle
|
||
case .large: "Large Board"
|
||
case .malformed: "Malformed Board"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The variant `arguments` asks for — `standard` when none is named, which is what the audit
|
||
/// suite's bare `--ui-test-fixture-board` has always meant.
|
||
///
|
||
/// **Declaration order breaks a tie**, so a launch naming two variants is deterministic rather
|
||
/// than dependent on argument order. Nothing produces that today; stating the rule is cheaper
|
||
/// than discovering it.
|
||
static func variant(arguments: [String]) -> FixtureVariant {
|
||
FixtureVariant.allCases.first { arguments.contains($0.flag) } ?? .standard
|
||
}
|
||
|
||
/// The running process's answer to the same question.
|
||
static var variant: FixtureVariant {
|
||
variant(arguments: ProcessInfo.processInfo.arguments)
|
||
}
|
||
|
||
// MARK: - The scratch directory
|
||
|
||
/// Everything a fixture launch writes, under one removable root inside the app's container.
|
||
///
|
||
/// One folder rather than two loose paths so `prepareScratchDirectory()` can promise a clean
|
||
/// start with a single `removeItem` — a fixture board and a registry that disagreed about which
|
||
/// run they belonged to would be worse than either being stale.
|
||
static var scratchRoot: URL {
|
||
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
|
||
}
|
||
|
||
/// Where the fixture launch's registry lives — beside the board rather than in the shared App
|
||
/// Group container, which is the whole point (see the type's note).
|
||
static var registryStorageURL: URL {
|
||
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
|
||
}
|
||
|
||
/// Where the fixture launch's clipboard snapshots live, on the registry's terms and now for a
|
||
/// sharper reason: the real staging root moved into the **shared** App Group container
|
||
/// (12-editions.md ▸ Both editions installed), so an audit run's launch sweep would otherwise
|
||
/// collect the developer's own staged copy — and the sibling edition's, since there is only one
|
||
/// store now. Redirected by the same flag, because it is the same one decision.
|
||
static var clipboardStagingRoot: URL {
|
||
scratchRoot.appendingPathComponent("Clipboard", isDirectory: true)
|
||
}
|
||
|
||
/// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
|
||
/// ordinary create path is a document, and the audit should be looking at the shape a user's
|
||
/// board actually has (01-storage-format.md § Document packaging).
|
||
///
|
||
/// One folder per variant, all under the one scratch root: the root is wiped per launch anyway,
|
||
/// so the separation buys nothing at runtime — it buys a *name*, which is what a suite waits on.
|
||
static func fixtureBoardURL(for variant: FixtureVariant) -> URL {
|
||
scratchRoot.appendingPathComponent("\(variant.boardTitle).kanban", isDirectory: true)
|
||
}
|
||
|
||
/// The audit fixture's folder — `fixtureBoardURL(for: .standard)`, kept as a name because that
|
||
/// board is the one every caller predating the variants meant.
|
||
static var fixtureBoardURL: URL {
|
||
fixtureBoardURL(for: .standard)
|
||
}
|
||
|
||
/// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience.
|
||
/// Called once, from `KanbanApp.init()`, **before** the model reads its registry — which is also why
|
||
/// the return value is discardable: that caller now names both redirected homes explicitly
|
||
/// (`registryStorageURL`, `clipboardStagingRoot`) rather than taking one of them from here.
|
||
///
|
||
/// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
|
||
/// only meaningful against a board whose contents the test knows — a previous run's leftovers
|
||
/// (a card the test deleted, a lane it renamed) would make the next run's tree something nobody
|
||
/// wrote down. Removal failures are logged and swallowed: the create below will fail loudly and
|
||
/// visibly on welcome if the directory is genuinely unusable, and there is no launch this early
|
||
/// that an alert could belong to.
|
||
@discardableResult
|
||
static func prepareScratchDirectory() -> URL {
|
||
let root = scratchRoot
|
||
do {
|
||
try FileManager.default.removeItem(at: root)
|
||
} catch CocoaError.fileNoSuchFile {
|
||
// The ordinary first-run case, not a failure.
|
||
} catch {
|
||
logger.error("could not clear the UI-test scratch directory: \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
do {
|
||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||
} catch {
|
||
logger.error("could not create the UI-test scratch directory: \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
return registryStorageURL
|
||
}
|
||
|
||
// MARK: - The standard board's shape
|
||
|
||
/// The standard board's title — and, through `fixtureBoardURL`, its folder name and its window
|
||
/// title, so a test can wait on `app.windows["Audit Board"]`.
|
||
static let boardTitle = "Audit Board"
|
||
|
||
/// The lane titles, in board order. Three because the tree the audit walks should have more than
|
||
/// one container to walk *between*, and because the smoke script's cut-and-paste step needs a
|
||
/// destination lane that is not the source.
|
||
static let laneTitles = ["To Do", "Doing", "Done"]
|
||
|
||
/// The card titles, per lane, in card order.
|
||
///
|
||
/// **The first lane is deliberately the crowded one.** Two of the audit's riskiest claims are
|
||
/// about a lane wide enough to lay its cards out in interior masonry columns — that VoiceOver
|
||
/// reads them by `order` and not column-major (10-accessibility.md ▸ Logical order), and that the
|
||
/// `accessibilitySortPriority`-inside-a-`Layout` mechanism holding that up actually survives — and
|
||
/// neither is observable in a lane with one card in it.
|
||
static let cardTitles: [[String]] = [
|
||
["Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit"],
|
||
["Write the smoke script"],
|
||
["Wire the launch fixture"],
|
||
]
|
||
|
||
/// The card that gets the rich body, named by `(lane, card)` index — the one the card-window
|
||
/// audits open.
|
||
///
|
||
/// It carries the structures 10-accessibility.md makes claims about ("Preview renders to the
|
||
/// accessibility tree as structured text — headings navigable by rotor, lists and tables read as
|
||
/// such; task-list checkboxes are real accessible checkboxes"), so the Preview audit has
|
||
/// something to audit and the Edit and raw-source audits open a document with real content in it
|
||
/// rather than an empty text view.
|
||
static let richCardIndex = (lane: 1, card: 0)
|
||
|
||
/// The rich card's body. Every element in it is one 10-accessibility.md names: two heading
|
||
/// levels for the rotor, a bulleted list, a task list (live checkboxes), a table, a fenced code
|
||
/// block, a link, and an image with alt text ("body images use Markdown alt text when present,
|
||
/// else the filename" — the file need not exist for the alt text to be the thing under audit).
|
||
static let richCardBody = """
|
||
## What this card is for
|
||
|
||
It is the accessibility audit's specimen: every structure 10-accessibility.md makes a claim \
|
||
about appears once, so Preview has something to render into the tree.
|
||
|
||
### Structures
|
||
|
||
- A bulleted list item
|
||
- A second one, with a [link](https://example.com) in it
|
||
|
||
- [ ] An unchecked task
|
||
- [x] A checked task
|
||
|
||
| Surface | Audited |
|
||
| --- | --- |
|
||
| Board | Yes |
|
||
| Card window | Yes |
|
||
|
||
```swift
|
||
let audit = try app.performAccessibilityAudit()
|
||
```
|
||
|
||

|
||
"""
|
||
|
||
/// The attachment the rich card carries, so the card face shows a paperclip, the card element's
|
||
/// value says "1 attachment" (`AccessibilityPhrases.cardValue`), and the card window's sidebar
|
||
/// has a real attachment row to audit.
|
||
static let attachmentName = "notes.txt"
|
||
|
||
private static let attachmentBody = """
|
||
The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
|
||
"""
|
||
|
||
/// The card that is deleted into `.trash/`, named by `(lane, card)` index.
|
||
///
|
||
/// A trash with something in it is the only way the trash-shown audit reaches the elements
|
||
/// 10-accessibility.md specifies for it — ordinary card elements whose context menu offers Delete
|
||
/// and Reveal in Finder and never Open. An empty column audits its own label and stops there.
|
||
static let trashedCardIndex = (lane: 0, card: 1)
|
||
|
||
// MARK: - The large board's shape
|
||
|
||
/// The large board's lanes and cards — **8 × 40**, which is 320 cards.
|
||
///
|
||
/// The numbers are a budget, not a maximum. They are large enough that the two things the large
|
||
/// board exists to exercise actually happen — a lane wide enough to lay out in several interior
|
||
/// masonry columns has plenty to lay out, and the board's reflow, scrolling and select-all all
|
||
/// have real work to do — and small enough that materializing it (a folder and an `index.md` per
|
||
/// card, through the ordinary Writer) stays in the seconds a UI test can afford. Raising them is
|
||
/// a decision about how long every performance run takes; they are stated here so that decision
|
||
/// is made in one place.
|
||
static let largeLaneCount = 8
|
||
static let largeCardsPerLane = 40
|
||
|
||
static func largeLaneTitle(_ index: Int) -> String {
|
||
"Lane \(index + 1)"
|
||
}
|
||
|
||
/// A large-board card's title — unique across the whole board, and **deliberately of four
|
||
/// different lengths**.
|
||
///
|
||
/// A wall of identical one-line cards would lay out as a perfect grid, which is exactly the case
|
||
/// masonry has nothing to do. Cycling the length gives the layout genuinely different card
|
||
/// heights to balance, so a reflow measured against this board is measuring the work the real
|
||
/// algorithm does.
|
||
static func largeCardTitle(lane laneIndex: Int, card cardIndex: Int) -> String {
|
||
let base = "Card \(laneIndex + 1)-\(cardIndex + 1)"
|
||
let tail = String(repeating: " with a longer title that wraps", count: cardIndex % 4)
|
||
return base + tail
|
||
}
|
||
|
||
// MARK: - The malformed board's shape
|
||
|
||
/// The malformed board's lanes and cards — small, because nothing about this variant is about
|
||
/// size. Two lanes so the tree has a shape at all, and two cards in the first so the broken one
|
||
/// has an intact sibling the loader walked past on its way to it.
|
||
static let malformedLaneTitles = ["Intact", "Also intact"]
|
||
|
||
static let malformedCardTitles: [[String]] = [
|
||
["A good card", "The malformed card"],
|
||
["Another good card"],
|
||
]
|
||
|
||
/// Which card gets the raw overwrite, named by `(lane, card)` index.
|
||
static let malformedCardIndex = (lane: 0, card: 1)
|
||
|
||
/// A string appearing only in the malformed file, so a test can *find* that file on disk without
|
||
/// knowing the UUID the Writer minted for it — and then assert its bytes are exactly what were
|
||
/// written, which is the "nothing was silently repaired" half of fail-fast.
|
||
static let malformationMarker = "lanework-ui-test-malformed-fixture"
|
||
|
||
/// The bytes written over the doomed card's `index.md`.
|
||
///
|
||
/// **The frontmatter opens a flow sequence and never closes it**, so the YAML between the
|
||
/// delimiters cannot parse — `BoardLoader` rejects the whole board with `.unparseableYAML` naming
|
||
/// this file, which is the failure the fail-fast pass is about. Everything else in the file is
|
||
/// well-formed on purpose: the delimiters are there, the body is ordinary text, and the two
|
||
/// required fields are present in spirit. The one thing wrong with it is the one thing under
|
||
/// test.
|
||
static let malformedIndexText = """
|
||
---
|
||
schema: 1
|
||
title: The malformed card
|
||
order: [1024
|
||
---
|
||
|
||
\(malformationMarker): this card's frontmatter opens a flow sequence and never closes it.
|
||
The loader must reject the whole board rather than repair, skip, or rewrite anything — and
|
||
these bytes must still be here, unchanged, after the app has refused to open the board.
|
||
"""
|
||
|
||
// MARK: - Materialization
|
||
|
||
/// Builds the fixture board `variant` asks for and answers its URL — every write through
|
||
/// `BoardWriter`, in the order a user would have produced them, with the malformed variant's one
|
||
/// raw overwrite as the documented exception (see this type's note).
|
||
///
|
||
/// Called from `RestoreBootstrapView` rather than from `KanbanApp.init()`: it is filesystem work,
|
||
/// and the launch path already has a place for filesystem work that has to happen before the
|
||
/// first real window (that view's whole reason for existing). A throw surfaces as a launch
|
||
/// failure on welcome — the same treatment a board that fails to restore gets — so a broken
|
||
/// fixture is visible rather than a suite that quietly audits an empty screen.
|
||
///
|
||
/// **The malformed variant does not throw here.** Building it succeeds; *loading* it is what
|
||
/// fails, one layer up, through the ordinary board-window path — which is the whole point, since
|
||
/// a fixture that failed to build would surface a message about the fixture rather than the
|
||
/// loader's own sentence about the offending file.
|
||
static func materializeFixtureBoard(_ variant: FixtureVariant = .standard) throws -> URL {
|
||
switch variant {
|
||
case .standard: try materializeStandardBoard()
|
||
case .large: try materializeLargeBoard()
|
||
case .malformed: try materializeMalformedBoard()
|
||
}
|
||
}
|
||
|
||
/// The audit suite's board (see `boardTitle` and the constants above it).
|
||
private static func materializeStandardBoard() throws -> URL {
|
||
let root = fixtureBoardURL(for: .standard)
|
||
try BoardWriter.createBoard(at: root, title: boardTitle)
|
||
|
||
var laneURLs: [URL] = []
|
||
for title in laneTitles {
|
||
let id = try BoardWriter.createLane(inBoard: root, title: title)
|
||
laneURLs.append(root.appendingPathComponent(id.rawValue, isDirectory: true))
|
||
}
|
||
|
||
var cardURLs: [[URL]] = []
|
||
for (laneIndex, titles) in cardTitles.enumerated() {
|
||
var lane: [URL] = []
|
||
for title in titles {
|
||
let id = try BoardWriter.createCard(inLane: laneURLs[laneIndex], title: title)
|
||
lane.append(laneURLs[laneIndex].appendingPathComponent(id.rawValue, isDirectory: true))
|
||
}
|
||
cardURLs.append(lane)
|
||
}
|
||
|
||
let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
|
||
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
|
||
try importFixtureAttachment(into: richCard)
|
||
|
||
// The delete goes last so the trashed card's identity is one the lanes above have already
|
||
// finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly
|
||
// what a user's ⌘⌫ would have put there, stamps and `order` included.
|
||
let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
|
||
try BoardWriter.deleteCardToTrash(
|
||
at: doomed,
|
||
inBoard: root,
|
||
order: Ranks.append(toVisible: [] as [Double])
|
||
)
|
||
|
||
return root
|
||
}
|
||
|
||
/// The performance suite's board — `largeLaneCount` lanes of `largeCardsPerLane` cards, built
|
||
/// through the same three Writer calls the standard board uses and nothing else.
|
||
///
|
||
/// No rich body, no attachment, no trashed card: every one of those is a *feature* the audit
|
||
/// wanted a specimen of, and this board is not about features. What it is about is quantity, and
|
||
/// quantity is the only thing that differs.
|
||
private static func materializeLargeBoard() throws -> URL {
|
||
let root = fixtureBoardURL(for: .large)
|
||
try BoardWriter.createBoard(at: root, title: FixtureVariant.large.boardTitle)
|
||
|
||
for laneIndex in 0 ..< largeLaneCount {
|
||
let laneID = try BoardWriter.createLane(inBoard: root, title: largeLaneTitle(laneIndex))
|
||
let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||
for cardIndex in 0 ..< largeCardsPerLane {
|
||
_ = try BoardWriter.createCard(
|
||
inLane: laneURL,
|
||
title: largeCardTitle(lane: laneIndex, card: cardIndex)
|
||
)
|
||
}
|
||
}
|
||
|
||
return root
|
||
}
|
||
|
||
/// The fail-fast suite's board: an ordinary small board, with one card's `index.md` overwritten
|
||
/// by `malformedIndexText` **after** every Writer call has finished.
|
||
///
|
||
/// The order is the whole design. Building first means the board around the broken file is one
|
||
/// the app made — right frontmatter, right ranks, right stamps — so the load that follows fails
|
||
/// for exactly one reason and the loader's sentence names exactly one file. Overwriting first, or
|
||
/// hand-writing the tree, would have produced a board whose *many* problems the loader would
|
||
/// report whichever it reached first.
|
||
///
|
||
/// The write is `Data.write`, not `BoardWriter.atomicReplace`: the Writer refuses to produce a
|
||
/// file its own loader would reject, which is a guarantee worth keeping rather than a door worth
|
||
/// opening. This is the one place in the app that goes around it, and it is unreachable without
|
||
/// the launch flag.
|
||
private static func materializeMalformedBoard() throws -> URL {
|
||
let root = fixtureBoardURL(for: .malformed)
|
||
try BoardWriter.createBoard(at: root, title: FixtureVariant.malformed.boardTitle)
|
||
|
||
var cardURLs: [[URL]] = []
|
||
for (laneIndex, laneTitle) in malformedLaneTitles.enumerated() {
|
||
let laneID = try BoardWriter.createLane(inBoard: root, title: laneTitle)
|
||
let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||
var lane: [URL] = []
|
||
for title in malformedCardTitles[laneIndex] {
|
||
let cardID = try BoardWriter.createCard(inLane: laneURL, title: title)
|
||
lane.append(laneURL.appendingPathComponent(cardID.rawValue, isDirectory: true))
|
||
}
|
||
cardURLs.append(lane)
|
||
}
|
||
|
||
let doomed = cardURLs[malformedCardIndex.lane][malformedCardIndex.card]
|
||
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
|
||
try Data(malformedIndexText.utf8).write(to: doomed, options: .atomic)
|
||
|
||
return root
|
||
}
|
||
|
||
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop
|
||
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
|
||
/// folder rather than a hand-placed file the loader would have to normalize.
|
||
private static func importFixtureAttachment(into cardFolder: URL) throws {
|
||
let source = scratchRoot.appendingPathComponent(attachmentName, isDirectory: false)
|
||
try attachmentBody.write(to: source, atomically: true, encoding: .utf8)
|
||
_ = try BoardWriter.importAttachments([source], intoCard: cardFolder)
|
||
try? FileManager.default.removeItem(at: source)
|
||
}
|
||
}
|