Files
lanework/KanbanTests/BoardLoaderTests.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

1078 lines
48 KiB
Swift

import Foundation
import Testing
@testable import Kanban
// MARK: - Fixture builder
/// A tiny in-memory-driven builder for synthetic board trees under a temp directory. Smoke
/// coverage only (per 02-architecture.md § Testing) — a comprehensive golden-fixture suite
/// over real trees under `Fixtures/` is a separate, later card.
private struct BoardFixture {
let root: URL
init() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardLoaderTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root)
}
/// Writes `index.md` at `relativePath` (created if needed), with `body` after the
/// frontmatter delimiters.
@discardableResult
func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let text = "---\n\(frontmatter)---\n\(body)"
try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
return folder
}
/// Creates a folder with no `index.md` — the "interrupted two-step create" shape.
@discardableResult
func emptyFolder(_ relativePath: String) throws -> URL {
let folder = root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
return folder
}
/// A stray file, not a folder — never a level candidate regardless of its name.
func strayFile(_ relativePath: String, contents: String = "stray") throws {
let url = root.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try contents.write(to: url, atomically: true, encoding: .utf8)
}
}
/// A fresh folder name in the app's own emission spelling — lowercase v4. The loader's gate is
/// shape-only (hex, `8-4-4-4-12`, any case, any version — 01-storage-format.md § Fractal layout
/// ▸ Rules, "Name shape gates level detection"), so this is *a* valid identity rather than the
/// only kind; the case tests below cover the rest. Used wherever a test just needs some lane/card
/// identity and doesn't care about the exact value; tests that need a specific lexicographic
/// ordering use literal UUID-shaped strings instead.
private func uuidFolderName() -> String {
UUID().uuidString.lowercased()
}
private func expectFailure(
_ expectedReason: BoardLoadError.Reason,
path: String,
_ operation: () throws -> Void
) {
do {
try operation()
Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded")
} catch let error as BoardLoadError {
#expect(error.path == path)
#expect(error.reason == expectedReason)
} catch {
Issue.record("expected a BoardLoadError, got \(error)")
}
}
// MARK: - Well-formed board
struct BoardLoaderWellFormedTests {
@Test func loadsCompleteStructureWithOrderingAndTombstoneFlags() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let laneA = uuidFolderName()
let laneB = uuidFolderName()
let cardFirst = uuidFolderName()
let cardSecond = uuidFolderName()
let cardDeleted = uuidFolderName()
try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n")
try fixture.index(laneB, "schema: 1\norder: 2048\ntitle: B Lane\n")
try fixture.index(laneA, "schema: 1\norder: 1024\ntitle: A Lane\n")
try fixture.index("\(laneA)/\(cardSecond)", "schema: 1\norder: 2048\ntitle: Second\n")
try fixture.index("\(laneA)/\(cardFirst)", "schema: 1\norder: 1024\ntitle: First\n")
try fixture.index(
"\(laneA)/\(cardDeleted)",
"schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n"
)
let result = try BoardLoader.load(boardRoot: fixture.root)
let model = result.model
#expect(model.title.value == "Demo Board")
#expect(model.schema == 1)
if case let .mapping(pairs) = model.template {
#expect(pairs.count == 1)
} else {
Issue.record("expected template to be a mapping, got \(String(describing: model.template))")
}
#expect(model.lanes.map(\.id.rawValue) == [laneA, laneB])
let lane = try #require(model.lanes.first { $0.id.rawValue == laneA })
#expect(lane.cards.map(\.id.rawValue) == [cardDeleted, cardFirst, cardSecond])
#expect(lane.cards.map(\.isDeleted) == [true, false, false])
#expect(result.warnings.isEmpty)
}
/// Tombstone semantics key on the `deleted` key's *presence*, not its validity
/// (01-storage-format.md § Frontmatter, § Deletion): a `deleted` value with no sensible
/// date reading still tombstones — the user's intent to delete outranks the broken date.
@Test func malformedDeletedValueStillTombstonesLaneAndCard() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\ndeleted: yesterday\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane })
#expect(loadedLane.deleted == .malformed(raw: "yesterday"))
#expect(loadedLane.isDeleted)
let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card })
#expect(loadedCard.deleted == .malformed(raw: "yesterday"))
#expect(loadedCard.isDeleted)
}
@Test func tiesAreBrokenByFolderNameNotTitle() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
// Both UUID-shaped so both are lane candidates; chosen so their lexicographic order
// ('0' < 'f') is known ahead of time.
let laneFirst = "00000000-0000-4000-8000-000000000000"
let laneSecond = "ffffffff-ffff-4fff-8fff-ffffffffffff"
try fixture.index("", "schema: 1\n")
// Same order; titles are reversed relative to folder-name order, to catch a loader
// that accidentally wires title into the tie-break instead of the folder name.
try fixture.index(laneSecond, "schema: 1\norder: 1024\ntitle: Should Be Second\n")
try fixture.index(laneFirst, "schema: 1\norder: 1024\ntitle: Should Be First\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes.map(\.id.rawValue) == [laneFirst, laneSecond])
}
}
// MARK: - Skip rules (UUID-shaped candidates only)
struct BoardLoaderSkipTests {
@Test func indexlessUUIDFolderBelowRootIsSkippedWithMissingIndexWarning() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let orphanLane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.emptyFolder(orphanLane)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.warnings.contains(.missingIndex(path: orphanLane)))
}
@Test func indexlessUUIDCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
let orphanCard = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
try fixture.emptyFolder("\(lane)/\(orphanCard)")
let result = try BoardLoader.load(boardRoot: fixture.root)
let loadedLane = try #require(result.model.lanes.first)
#expect(loadedLane.cards.map(\.id.rawValue) == [card])
#expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(orphanCard)")))
}
}
// MARK: - Strays ignored (non-directories, hidden entries, symlinks)
struct BoardLoaderStrayTests {
@Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
try fixture.strayFile("notes.txt")
try fixture.strayFile(".DS_Store")
try fixture.strayFile("\(lane)/notes.txt")
_ = try fixture.emptyFolder(".git")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.isEmpty)
}
@Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let realLane = uuidFolderName()
try fixture.index("", "schema: 1\n")
let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n")
try FileManager.default.createSymbolicLink(
at: fixture.root.appendingPathComponent("linked-lane"),
withDestinationURL: realLaneURL
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [realLane])
#expect(result.warnings.isEmpty)
}
/// The identity predicate is shape-only — "identity-shaped name or not" (01-storage-format.md
/// § Fractal layout ▸ Rules, "Symlinks are never traversed", settled). This pins the case the
/// previous test's non-UUID name doesn't: a symlink whose *name itself* would pass
/// `isUUIDShaped` and which points at a real directory must still be excluded before that
/// name shape is ever consulted — `directoryCandidates` filters on `isSymbolicLink` ahead of
/// `isDirectory`, so a link is never mistaken for the directory it points to, UUID-shaped name
/// or not.
@Test func uuidShapedSymlinkToADirectoryIsTreatedAsStrayNotFollowed() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let realLane = uuidFolderName()
let linkedName = uuidFolderName()
try fixture.index("", "schema: 1\n")
let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n")
try FileManager.default.createSymbolicLink(
at: fixture.root.appendingPathComponent(linkedName),
withDestinationURL: realLaneURL
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [realLane])
#expect(!result.model.lanes.map(\.id.rawValue).contains(linkedName))
#expect(result.warnings.isEmpty)
}
}
// MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout ▸ Rules,
// "Name shape gates level detection")
struct BoardLoaderNonUUIDStrayTests {
/// A non-UUID-shaped folder is a stray even when its `index.md` is perfectly valid
/// lane-shaped content — the name shape gates candidacy before the file is ever read.
@Test func nonUUIDFolderWithValidLaneShapedIndexIsIgnoredWithWarningAndBoardLoads() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let realLane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(realLane, "schema: 1\norder: 1024\n")
try fixture.index("todo", "schema: 1\norder: 2048\ntitle: Hand-authored lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [realLane])
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo")))
}
/// The motivating case: before this rule, a non-UUID folder with a broken `index.md`
/// (missing required `order`) would fail-fast the whole load. Now the name shape gates it
/// out as a stray before the loader ever parses the file, so the rest of the board still
/// loads.
@Test func nonUUIDFolderWithBrokenIndexIsIgnoredWithWarningAndBoardStillLoads() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let realLane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(realLane, "schema: 1\norder: 1024\n")
// A hand-authored lane with a name that isn't identity-shaped: never a candidate, so its
// contents are never read at all.
try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [realLane])
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo")))
}
/// Same motivating case, one level down: a non-UUID card-depth folder with a broken
/// `index.md` is a stray, not a fail-fast, and doesn't stop its lane's other cards loading.
@Test func nonUUIDCardFolderWithBrokenIndexIsIgnoredWithWarningAndLaneStillLoads() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let realCard = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(realCard)", "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/scratch", "schema: 1\n") // missing 'order' too
let result = try BoardLoader.load(boardRoot: fixture.root)
let loadedLane = try #require(result.model.lanes.first)
#expect(loadedLane.cards.map(\.id.rawValue) == [realCard])
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/scratch")))
}
/// The identity predicate is **shape-only, any case** (01-storage-format.md § Fractal layout
/// ▸ Rules, settled): `uuidgen` and `UUID().uuidString` both print uppercase, so an
/// uppercase folder is an ordinary lane — never a silently skipped stray. Its `rawValue`
/// keeps the exact spelling: the app accepts liberally and never renames to canonicalize.
@Test func uppercaseUUIDFolderIsALaneWithItsSpellingPreserved() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lowercaseLane = uuidFolderName()
let uppercaseLane = UUID().uuidString // Foundation renders this uppercase.
try fixture.index("", "schema: 1\n")
try fixture.index(lowercaseLane, "schema: 1\norder: 1024\n")
try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [lowercaseLane, uppercaseLane])
#expect(result.warnings.isEmpty)
}
/// One level down, and mixed case rather than uniform: an agent's `uuidgen`-named card
/// folder loads as a card, spelling intact.
@Test func mixedCaseUUIDCardFolderLoadsAsACardWithItsSpellingPreserved() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let mixedCaseCard = "AbCdEf01-2345-6789-aBcD-EF0123456789"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(mixedCaseCard)", "schema: 1\norder: 1024\ntitle: From an agent\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
let loadedLane = try #require(result.model.lanes.first)
#expect(loadedLane.cards.map(\.id.rawValue) == [mixedCaseCard])
#expect(loadedLane.cards.first?.title.value == "From an agent")
#expect(result.warnings.isEmpty)
}
/// **Any version**, not just v4: the version nibble protects no invariant here — a v7 (or a
/// nibble no RFC ever assigned) is exactly as unique as a v4, so it is an identity, not a
/// stray. Only the shape is checked.
@Test func oddVersionAndVariantNibblesAreStillIdentities() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let v7Lane = "01912d5e-7c00-7000-8000-abcdefabcdef" // version nibble 7
let oddLane = "01912d5e-7c00-c000-f000-abcdefabcdef" // version c, variant f — no RFC's
try fixture.index("", "schema: 1\n")
try fixture.index(v7Lane, "schema: 1\norder: 1024\n")
try fixture.index(oddLane, "schema: 1\norder: 2048\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [v7Lane, oddLane])
#expect(result.warnings.isEmpty)
}
/// The shape itself is still strict: near-misses stay strays. Hex only, exact group lengths,
/// hyphens exactly where they belong.
@Test func nearMissUUIDShapesAreStillStrays() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let realLane = uuidFolderName()
let strays = [
"GGGGGGGG-0000-4000-8000-000000000000", // not hex
"00000000-0000-4000-8000-00000000000", // one digit short
"000000000-000-4000-8000-000000000000", // hyphens misplaced
"00000000-0000-4000-8000-000000000000-", // trailing hyphen
]
try fixture.index("", "schema: 1\n")
try fixture.index(realLane, "schema: 1\norder: 1024\n")
for (offset, stray) in strays.enumerated() {
try fixture.index(stray, "schema: 1\norder: \(2048 + offset)\n")
}
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [realLane])
for stray in strays {
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: stray)))
}
}
/// Reserved card children are covered "by construction": `attachments/` and `comments/` are
/// non-UUID-shaped, and the *level walk* stops at depth 2 — so neither can ever be mistaken
/// for an item, and neither may surface a warning. (`attachments/` is read for its file
/// names, which is a listing, not a descent — see `CardAttachmentListingTests` below.)
@Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index("\(lane)", "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png")
try fixture.strayFile("\(lane)/\(card)/comments/whatever.md")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.first?.cards.map(\.id.rawValue) == [card])
#expect(result.warnings.isEmpty)
}
}
// MARK: - Card attachments (01-storage-format.md § Attachments)
/// `Card.attachments` — the loader's one read *inside* a card folder. The golden-fixture suite
/// pins the everyday shapes on real committed trees (`FixtureBoardTests`); these cover what a git
/// fixture can't carry (a symlink) and what only a synthetic tree can arrange (a card folder whose
/// `attachments` is a *file*, an empty folder, Finder's numeric ordering).
struct CardAttachmentListingTests {
/// Builds a one-card board and returns that card, so each test below is one arrangement plus
/// one assertion.
private func card(in fixture: BoardFixture) throws -> Card {
let result = try BoardLoader.load(boardRoot: fixture.root)
return try #require(result.model.lanes.first?.cards.first)
}
private func boardWithOneCard(_ fixture: BoardFixture) throws -> String {
let lane = "10000000-0000-4000-8000-000000000001"
let cardID = "20000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n")
return "\(lane)/\(cardID)"
}
/// **Symlinks are not surfaced** — the same never-resolve stance the level walk takes
/// (`directoryCandidates`), so a link into another volume or a cycle can't turn a listing
/// into a traversal. The link itself stays on disk untouched; it just isn't an attachment.
@Test func symlinksInAttachmentsAreNotSurfaced() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments/real.png")
try fixture.strayFile("outside.png")
let attachments = fixture.root.appendingPathComponent("\(cardPath)/attachments", isDirectory: true)
try FileManager.default.createSymbolicLink(
at: attachments.appendingPathComponent("link-to-file.png"),
withDestinationURL: fixture.root.appendingPathComponent("outside.png")
)
try FileManager.default.createSymbolicLink(
at: attachments.appendingPathComponent("link-to-folder"),
withDestinationURL: fixture.root
)
#expect(try card(in: fixture).attachments == ["real.png"])
}
/// The four shapes in one folder — the fixture board's assertion restated synthetically, so
/// the rule is pinned even if the bundled fixture tree ever loses a file to a copy phase.
@Test func onlyTopLevelNonHiddenRegularFilesAreListed() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments/sketch.png")
try fixture.strayFile("\(cardPath)/attachments/notes.txt")
try fixture.strayFile("\(cardPath)/attachments/.DS_Store")
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
#expect(try card(in: fixture).attachments == ["notes.txt", "sketch.png"])
}
/// **Finder order** (`localizedStandardCompare`), not plain lexicographic — digits inside a
/// name *count* rather than collate, so the run `importAttachments` itself produces on a
/// collision (`shot.png` → `shot 2.png` → `shot 10.png`) sorts 2-before-10, matching the card
/// window sidebar's listing, which answers through this same enumeration. (`shot.png` trailing
/// its own numbered copies is Finder's own ordering of a space against a dot, not a quirk of
/// ours.)
@Test func namesSortInFinderOrderSoNumbersCountRatherThanCollate() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
for name in ["shot 10.png", "shot 2.png", "shot.png", "page-10.txt", "page-2.txt"] {
try fixture.strayFile("\(cardPath)/attachments/\(name)")
}
#expect(try card(in: fixture).attachments == [
"page-2.txt", "page-10.txt", "shot 2.png", "shot 10.png", "shot.png",
])
}
@Test func anEmptyAttachmentsFolderListsNothing() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.emptyFolder("\(cardPath)/attachments")
#expect(try card(in: fixture).attachments.isEmpty)
}
/// A listing that *cannot* be made degrades to `[]` — here because a hand-editor left a
/// `attachments` **file** where the folder would be. Fail-fast is reserved for structure
/// (01-storage-format.md § Malformed input); a cosmetic field must never be why a board
/// refuses to open, and the file itself is preserved verbatim like any other stray.
@Test func anAttachmentsThatIsNotADirectoryDegradesToAnEmptyListing() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments", contents: "not a folder")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.first?.cards.first?.attachments.isEmpty == true)
#expect(result.warnings.isEmpty)
}
/// `BoardWriter.listAttachments` — the card window sidebar's authoritative listing — and
/// `Card.attachments` are **one enumeration**, so a sidebar and a face looking at the same
/// card can never disagree about its files or their order.
@Test func theSnapshotsListingAndTheWritersAreTheSameAnswer() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
for name in ["shot 10.png", "shot 2.png", "shot.png", ".hidden"] {
try fixture.strayFile("\(cardPath)/attachments/\(name)")
}
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
#expect(try card(in: fixture).attachments == BoardWriter.listAttachments(ofCard: cardFolder))
}
}
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity
// comparison is UUID-value equality, never string equality")
/// `ItemID` stores the folder's exact spelling — it builds URLs — but *compares* as a UUID
/// value: two case-spellings of one UUID are one identity everywhere.
struct ItemIDValueSemanticsTests {
private static let lower = "abcdef01-2345-6789-abcd-ef0123456789"
private static let upper = "ABCDEF01-2345-6789-ABCD-EF0123456789"
private static let mixed = "AbCdEf01-2345-6789-aBcD-eF0123456789"
@Test func caseSpellingsOfOneUUIDAreEqualAndHashAlike() {
let lower = ItemID(rawValue: Self.lower)
let upper = ItemID(rawValue: Self.upper)
let mixed = ItemID(rawValue: Self.mixed)
#expect(lower == upper)
#expect(lower == mixed)
#expect(upper == mixed)
#expect(lower.hashValue == upper.hashValue)
#expect(upper.hashValue == mixed.hashValue)
}
/// Equality is by value, but the spelling is never rewritten — the app accepts liberally and
/// emits conservatively, and `rawValue` is what builds the folder's URL.
@Test func rawValueKeepsTheExactSpelling() {
#expect(ItemID(rawValue: Self.upper).rawValue == Self.upper)
#expect(ItemID(rawValue: Self.mixed).description == Self.mixed)
}
@Test func distinctUUIDsAreUnequalHoweverTheyAreSpelled() {
let one = ItemID(rawValue: "abcdef01-2345-6789-abcd-ef0123456789")
let other = ItemID(rawValue: "ABCDEF01-2345-6789-ABCD-EF012345678A")
#expect(one != other)
}
/// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits — selection
/// membership included (`ItemReferenceSet`).
@Test func aSetCollapsesTheTwoSpellingsToOneMember() {
let set: Set<ItemID> = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)]
#expect(set.count == 1)
#expect(set.contains(ItemID(rawValue: Self.mixed)))
var byID: [ItemID: String] = [:]
byID[ItemID(rawValue: Self.upper)] = "written uppercase"
#expect(byID[ItemID(rawValue: Self.lower)] == "written uppercase")
}
}
// MARK: - Board-level deleted
struct BoardLoaderBoardLevelDeletedTests {
@Test func boardLevelDeletedIsIgnoredAndWarned() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.isEmpty)
#expect(result.warnings.contains(.boardLevelDeletedIgnored))
}
}
// MARK: - Fail-fast
struct BoardLoaderFailFastTests {
@Test func rootMissingIndexThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
expectFailure(.boardRootMissingIndex, path: "index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func rootThatIsAFileThrowsNotADirectory() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let fileRoot = fixture.root.appendingPathComponent("not-a-folder")
try "hello".write(to: fileRoot, atomically: true, encoding: .utf8)
expectFailure(.notADirectory, path: ".") {
_ = try BoardLoader.load(boardRoot: fileRoot)
}
}
@Test func unreadableRootThrows() throws {
let missing = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardLoaderTests-does-not-exist-\(UUID().uuidString)")
do {
_ = try BoardLoader.load(boardRoot: missing)
Issue.record("expected a BoardLoadError but load succeeded")
} catch {
#expect(error.path == ".")
if case .unreadableRoot = error.reason {
// expected
} else {
Issue.record("expected .unreadableRoot, got \(error.reason)")
}
}
}
@Test func unparseableYAMLThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
// No closing delimiter.
try "---\nschema: 1\n".write(
to: fixture.root.appendingPathComponent("index.md"),
atomically: true,
encoding: .utf8
)
do {
_ = try BoardLoader.load(boardRoot: fixture.root)
Issue.record("expected a BoardLoadError but load succeeded")
} catch {
#expect(error.path == "index.md")
if case .unparseableYAML = error.reason {
// expected
} else {
Issue.record("expected .unparseableYAML, got \(error.reason)")
}
}
}
@Test func missingSchemaThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
try fixture.index("", "title: No Schema\n")
expectFailure(.missingSchema, path: "index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func malformedSchemaThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: not-a-number\n")
expectFailure(.malformedSchema(raw: "not-a-number"), path: "index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func schemaNewerThanAppThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 2\n")
expectFailure(.schemaNewerThanApp(found: 2), path: "index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
/// A schema newer than the app fails fast **below** the root too — the one `schema` rule the
/// optional-key ruling left alone (01-storage-format.md § Malformed input, re-ruled 2026-07-31).
@Test func schemaNewerThanAppOnALaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 2\norder: 1024\n")
expectFailure(.schemaNewerThanApp(found: 2), path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
/// A malformed `schema` below the root still refuses: the ruling made the *absent* key optional,
/// not the unreadable one — reading `schema: one` as 1 would be inventing agreement.
@Test func malformedSchemaOnACardThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: one\norder: 1024\n")
expectFailure(.malformedSchema(raw: "one"), path: "\(lane)/\(card)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
}
// MARK: - `order` and `schema` optional below the board root
/// **The append-at-end reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31): below the
/// board root a missing, null, non-numeric or non-finite `order` is no longer a fail-fast — it reads
/// as a rank past every ordered sibling, tie-broken by folder name, and the reading is coerce-tier
/// (logged, bytes preserved). `Fixtures/Valid/optional-keys.kanban` is the disk-backed golden case;
/// these are the synthetic edges.
struct BoardLoaderOptionalOrderTests {
/// The zero-read minimum the ruling exists for: a lane with one ranked card and one card whose
/// whole frontmatter is a title.
@Test func orderlessCardAppendsAfterEveryRankedSibling() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = "10000000-0000-4000-8000-000000000001"
let ranked = "20000000-0000-4000-8000-000000000001"
// Deliberately the *lower* folder name, so folder order alone would put it first.
let orderless = "10000000-0000-4000-8000-000000000009"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(ranked)", "schema: 1\norder: 4096\n")
try fixture.index("\(lane)/\(orderless)", "title: Minimum\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes[0].cards.map(\.id.rawValue) == [ranked, orderless])
#expect(model.lanes[0].cards.map(\.order) == [4096, 5120])
}
/// Two order-less siblings: folder name decides, and the ranks they read as are `append`'s own
/// ladder — which is what lets the Writer stamp them without anything moving.
@Test func twoOrderlessSiblingsSortByFolderName() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = "10000000-0000-4000-8000-000000000001"
let second = "30000000-0000-4000-8000-000000000002"
let first = "20000000-0000-4000-8000-000000000001"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(second)", "schema: 1\n")
try fixture.index("\(lane)/\(first)", "schema: 1\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes[0].cards.map(\.id.rawValue) == [first, second])
// No ranked sibling at all, so the ladder bases at 0 — the empty-container convention.
#expect(model.lanes[0].cards.map(\.order) == [1024, 2048])
}
/// The four unusable shapes are one reading. Each is a coercion carrying the text as written.
@Test func everyUnusableOrderShapeReadsAsAppendAtEnd() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = "10000000-0000-4000-8000-000000000001"
let anchor = "20000000-0000-4000-8000-000000000000"
let shapes: [(id: String, frontmatter: String, raw: String)] = [
("20000000-0000-4000-8000-000000000001", "schema: 1\n", ""),
("20000000-0000-4000-8000-000000000002", "schema: 1\norder:\n", ""),
("20000000-0000-4000-8000-000000000003", "schema: 1\norder: null\n", "null"),
("20000000-0000-4000-8000-000000000004", "schema: 1\norder: banana\n", "banana"),
("20000000-0000-4000-8000-000000000005", "schema: 1\norder: .nan\n", ".nan"),
("20000000-0000-4000-8000-000000000006", "schema: 1\norder: .inf\n", ".inf"),
]
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(anchor)", "schema: 1\norder: 2048\n")
for shape in shapes {
try fixture.index("\(lane)/\(shape.id)", shape.frontmatter)
}
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [anchor] + shapes.map(\.id))
#expect(result.model.lanes[0].cards.map(\.order) == [2048, 3072, 4096, 5120, 6144, 7168, 8192])
let coerced = Dictionary(
uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) })
for shape in shapes {
#expect(
coerced["\(lane)/\(shape.id)/index.md"] == [CoercedField(key: "order", raw: shape.raw)],
"\(shape.frontmatter) should coerce with raw '\(shape.raw)'"
)
}
}
/// The rule holds one level up: an order-less lane sits right of every ranked one.
@Test func orderlessLaneAppendsAtTheEndOfTheStrip() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let ranked = "90000000-0000-4000-8000-000000000001"
let orderless = "10000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(ranked, "schema: 1\norder: 1024\n")
try fixture.index(orderless, "schema: 1\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes.map(\.id.rawValue) == [ranked, orderless])
#expect(model.lanes.map(\.order) == [1024, 2048])
}
/// A missing `schema` below the root reads as 1 and records a coercion; the **root's** own
/// missing `schema` is still the loud rejection (`missingSchemaThrows` above).
@Test func missingSchemaBelowRootReadsAsOne() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "order: 1024\ntitle: No Schema Lane\n")
try fixture.index("\(lane)/\(card)", "order: 1024\ntitle: No Schema Card\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes[0].schema == 1)
#expect(result.model.lanes[0].cards[0].schema == 1)
let paths = Set(result.coercedFrontmatter.map(\.path))
#expect(paths == ["\(lane)/index.md", "\(lane)/\(card)/index.md"])
#expect(result.coercedFrontmatter.allSatisfy { $0.fields == [CoercedField(key: "schema", raw: "")] })
}
/// A trash entry without a rank reads like every other order-less file. `order` decides nothing
/// about where a trash row sits — `modified` does — so this is only about the rank it carries
/// back out on a restore.
@Test func orderlessTrashEntryReadsAsAppendAtEnd() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let ranked = "20000000-0000-4000-8000-000000000001"
let orderless = "10000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(ranked)", "schema: 1\nkind: card\norder: 1024\n")
try fixture.index(".trash/\(orderless)", "schema: 1\nkind: card\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
let byID = Dictionary(uniqueKeysWithValues: model.trash.map { ($0.id.rawValue, $0.order) })
#expect(byID[ranked] == 1024)
#expect(byID[orderless] == 2048)
}
}
// MARK: - Encoding strictness
/// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding
/// contract (01-storage-format.md § Fractal layout ▸ Rules) actually holds at load time: a
/// BOM'd file fails the frontmatter delimiter, a non-UTF-8 file is named as such — the same
/// strict decode `BoardWriter` uses, so a file can never load here and then refuse every write.
struct BoardLoaderEncodingTests {
@Test func bomPrefixedBoardIndexIsRejected() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
var bytes = Data([0xEF, 0xBB, 0xBF])
bytes.append(Data("---\nschema: 1\n---\nbody\n".utf8))
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
guard let loadError = error as? BoardLoadError,
case .unparseableYAML = loadError.reason else { return false }
return loadError.path == "index.md"
}
}
@Test func nonUTF8BoardIndexIsRejected() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
// "café" in ISO-8859-1 — 0xE9 is not valid UTF-8.
var bytes = Data("---\nschema: 1\ntitle: caf".utf8)
bytes.append(0xE9)
bytes.append(Data("\n---\n".utf8))
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
guard let loadError = error as? BoardLoadError,
case let .unparseableYAML(message, _) = loadError.reason else { return false }
return message == "file is not UTF-8"
}
}
}
// MARK: - The coerce tier's trace (01-storage-format.md § Frontmatter, ruled 2026-07-29)
/// **"A no-sensible-reading fallback logs"** — field, path, and raw text, carried as coerce-tier
/// entries in the integrity service's Defect stream:
///
/// > the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or a
/// > notice; no banner, no behavior change.
///
/// So these tests assert two things at once, and the second matters as much as the first: the fallback
/// is *reported*, and nothing about the board changed because of it — the field still renders its
/// default, the bytes on disk are still verbatim, and no heal is scheduled.
@Suite("BoardLoader ▸ coerce-tier fallbacks")
struct BoardLoaderCoercionTraceTests {
/// The pure half first: which lenient fields report, and which deliberately do not.
///
/// `schema` and `order` are the **refuse** tier — a malformed one fails the load loudly, so there is
/// no silent recovery to leave a trace of — and `deleted`'s rule is presence-not-validity, so
/// nothing falls back to a default there either.
@Test("The document reports its lenient fallbacks, and only those")
func theDocumentReportsItsLenientFallbacks() throws {
let document = try FrontmatterDocument.parse("""
---
schema: 1
order: 1024
title: [a, b]
width: 1.5
created: not-a-date
icon: {a: b}
deleted: also-not-a-date
---
Body.
""")
let byKey = Dictionary(uniqueKeysWithValues: document.coercedFields.map { ($0.key, $0.raw) })
#expect(Set(byKey.keys) == ["title", "width", "created", "icon"])
#expect(byKey["width"] == "1.5", "the raw text as written — what a future heuristic would read")
#expect(byKey["created"] == "not-a-date")
#expect(byKey["deleted"] == nil, "presence, not validity, decides a tombstone")
}
@Test("A clean document reports nothing")
func aCleanDocumentReportsNothing() throws {
let document = try FrontmatterDocument.parse("""
---
schema: 1
order: 1024
title: Fine
width: 2
---
""")
#expect(document.coercedFields.isEmpty)
}
/// **A scalar of the wrong type is not a fallback** — it coerces to the text the author typed
/// (`title: 2048` reads as "2048"), which is a *successful* reading and leaves no trace. Only "no
/// sensible reading exists" does.
@Test("A coerced scalar leaves no trace — it was read, not defaulted")
func aCoercedScalarLeavesNoTrace() throws {
let document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: 2048\nwidth: \"3\"\n---\n")
#expect(document.title.value == "2048")
#expect(document.width.value == 3)
#expect(document.coercedFields.isEmpty)
}
/// The loader's half: the path is attached at every level, because the rule is about fields and
/// every level has them.
@Test("The loader attaches the path, at every level")
func theLoaderAttachesThePath() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
let trashed = uuidFolderName()
try fixture.index("", "schema: 1\ntitle: [a, b]\n")
try fixture.index(lane, "schema: 1\norder: 1024\nwidth: 1.5\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nicon: {x: y}\n")
try fixture.index(".trash/\(trashed)", "schema: 1\norder: 1024\ncreated: nope\n")
let reported = try BoardLoader.load(boardRoot: fixture.root).coercedFrontmatter
let byPath = Dictionary(uniqueKeysWithValues: reported.map { ($0.path, $0.fields.map(\.key)) })
#expect(byPath["index.md"] == ["title"])
#expect(byPath["\(lane)/index.md"] == ["width"])
#expect(byPath["\(lane)/\(card)/index.md"] == ["icon"])
#expect(byPath[".trash/\(trashed)/index.md"] == ["created"])
}
/// **No behavior change** — the whole point of the tier. The fields render their defaults exactly as
/// they did before anything was reported, and the bytes are preserved verbatim.
@Test("Nothing about the board changes — defaults render, bytes stay")
func nothingChanges() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\ntitle: Board\n")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: [a, b]\nwidth: 0.5\n")
let before = try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"))
let result = try BoardLoader.load(boardRoot: fixture.root)
let loaded = try #require(result.model.lanes.first)
#expect(loaded.title.isMalformed, "the field still reads as malformed")
#expect(loaded.title.value == nil, "and renders its default — the untitled placeholder")
#expect(loaded.width.value == nil, "width falls back to 1 at the render layer, not here")
#expect(try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md")) == before,
"read-side only: the loader never writes")
#expect(result.warnings.isEmpty, "a coercion is not a stray warning")
}
/// **It is not healable work**, which is why it has no class: a class is a memo key and a
/// banner-posture row in the engine, and inventing one would arm a memo against a repair nobody
/// wrote. The other defects keep theirs.
@Test("A coerce-tier defect has no heal class, and signs per field")
func itHasNoHealClass() {
let defect = IntegrityRules.Defect.coercedFrontmatter(CoercedFrontmatter(
path: "lane/card/index.md",
fields: [CoercedField(key: "width", raw: "1.5"), CoercedField(key: "icon", raw: "{}")]
))
#expect(defect.healClass == nil)
#expect(Set(defect.signatures) == [
"coerce:lane/card/index.md:width",
"coerce:lane/card/index.md:icon",
])
}
}