Files
lanework/Kanban/App/UITestLaunch.swift
rzen bec75e4282 Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 18:35:07 -04:00

636 lines
34 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
// 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`). 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. 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 Application
/// Support, 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: an audit run's
/// launch sweep would otherwise collect the developer's own staged copy. 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()
```
![A diagram of the board's accessibility tree](attachments/tree.png)
"""
/// 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 rich card's comment thread** — three comments, because the pane's accessibility audit
/// needs one of each shape 10-accessibility.md's comments row can take (▸ Comments: "each comment
/// is one flattened element — author, date, edited state, body").
///
/// In order: an ordinary comment (author line, date, body), an **authorless** one (the date alone
/// carries the line — "missing renders unattributed", and no placeholder stands in for a name),
/// and one that has been **edited** (its author line ends "· edited", which is `modified`
/// differing from `created` and no extra field). Between them they cover every branch of
/// `CommentAuthorLine.text(author:timestamp:isEdited:)` that a written file can produce.
static let commentBodies = [
"""
The audit's specimen thread. This one is ordinary: a name, a date, and a paragraph of \
Markdown with some *emphasis* in it.
""",
"""
This one has no `author` key at all, so it renders unattributed — a date and a body, and no \
placeholder standing in for a name.
""",
"""
And this one has been edited since it was posted, so its author line carries the edited marker.
"""
]
/// Which comment gets its `author` key removed, by index into `commentBodies`.
static let authorlessCommentIndex = 1
/// Which comment is edited after posting, by index into `commentBodies`, and what it is edited to.
static let editedCommentIndex = 2
static let editedCommentBody = """
And this one has been edited since it was posted, so its author line carries the edited \
marker — this sentence is the edit.
"""
/// 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)
try seedCommentThread(into: richCard, cardTitle: cardTitles[richCardIndex.lane][richCardIndex.card])
// 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: the `modified` stamp that positions it, its `order`
// riding along untouched.
let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
try BoardWriter.deleteCardToTrash(at: doomed, inBoard: root)
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
}
/// Builds the rich card's thread **the way the composer does** — a draft saved, then posted, once
/// per body — so the fixture's `comments/` is a folder the app made: minted identities, the
/// `kind: comment` field table, `created`/`modified` restamped at the post.
///
/// Two of the three then need a shape no gesture in the app produces, and each is applied
/// afterwards, narrowly:
///
/// - **The edit** is an ordinary Writer call (`editComment`), which is exactly what an inline edit
/// session's save does — so the edited marker in the fixture is the real mechanism, not a
/// hand-set field.
/// - **The two frontmatter amendments** are raw writes, for the malformed variant's reason (see
/// this type's note). Neither shape has a door in the app: it always writes the account's full
/// name, and it cannot post a comment an hour ago. Both shapes are ordinary on disk, though —
/// agents and tracker sync write comments with no `author` at all (01-storage-format.md ▸
/// Enhanced schema), and every comment that has ever been edited was posted before it. Both go
/// through `FrontmatterDocument`, so the rest of each file is byte-identical to what the Writer
/// produced.
///
/// The backdating is not decoration: `created` and `modified` serialize to the second, and a
/// comment posted and edited inside one second would render as **not** edited — the marker is
/// `modified` differing from `created` and no extra field (`Comment.isEdited`).
private static func seedCommentThread(into cardFolder: URL, cardTitle: String) throws {
var posted: [ItemID] = []
for body in commentBodies {
try BoardWriter.saveCommentDraft(inCard: cardFolder, body: body, cardTitle: cardTitle)
posted.append(try BoardWriter.postComment(inCard: cardFolder, cardTitle: cardTitle).id)
}
try BoardWriter.editComment(
at: CommentThread.commentFolder(posted[editedCommentIndex], inCard: cardFolder),
body: editedCommentBody,
cardTitle: cardTitle
)
try amendComment(posted[authorlessCommentIndex], inCard: cardFolder) { document in
document.remove(FrontmatterKeys.author)
}
try amendComment(posted[editedCommentIndex], inCard: cardFolder) { document in
document.set(FrontmatterKeys.created, to: .date(Date().addingTimeInterval(-3600)))
}
}
/// One comment's frontmatter, amended in place — the fixture's narrow way around the Writer, kept
/// to one function so both amendments share its round trip and neither invents a second one.
private static func amendComment(
_ id: ItemID,
inCard cardFolder: URL,
_ amend: (inout FrontmatterDocument) -> Void
) throws {
let indexURL = CommentThread
.commentFolder(id, inCard: cardFolder)
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
var document = try FrontmatterDocument.parse(String(decoding: try Data(contentsOf: indexURL), as: UTF8.self))
amend(&document)
try Data(document.serialized().utf8).write(to: indexURL, options: .atomic)
}
/// 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)
}
}