Write the loader fixture suite, valid and malformed

18 golden fixture boards as real on-disk trees under Fixtures/ — ten
valid/tolerated cases (rich board, interrupted create, non-UUID strays,
stray files, tombstones, tie-breaks, unknown-key order, coercion,
last-wins duplicates, board-level deleted) and eight fail-fast cases,
each pinned to its exact error reason and path; the rich board's whole
tree round-trips byte-identically. Pins two contract subtleties now
recorded in the design: tombstone flags are not inherited (renderers
walk ancestors) and last-wins resolves before order strictness. README
gains the storage-foundation feature entry. 146 tests total.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 16:20:47 -04:00
parent e6c34c0ccf
commit 7470255886
73 changed files with 900 additions and 4 deletions
+2 -2
View File
@@ -81,7 +81,7 @@ Body: lane description / WIP policy / notes.
Body: the card's content — the whole point. Body: the card's content — the whole point.
Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection. Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection.
## Enhanced schema (reserved, out of scope) ## Enhanced schema (reserved, out of scope)
@@ -108,7 +108,7 @@ Carried over unchanged — gapped fractional ranks:
## Deletion — tombstones ## Deletion — tombstones
- Deleting writes `deleted: <ISO-8601>` into frontmatter; tombstoned items (and everything beneath) are hidden from the board and surface in the **trash quasi-lane** (03-board-ui.md) — a pure view. **Tombstoned items stay exactly where they are on disk**, which is what makes restore (Put Back = removing the key) position-preserving: the item reappears at its recorded `order` among its current siblings. One acknowledged edge: a renumber rescales *visible* siblings only (tombstones are inert to ordering), so a post-renumber Put Back can land among different neighbors than the ones it was deleted between — same lane, deterministic placement, just not always the original gap. - Deleting writes `deleted: <ISO-8601>` into frontmatter; tombstoned items (and everything beneath) are hidden from the board. **Hiding beneath is the renderer's walk, not a stored flag** (pinned by the fixture suite): the snapshot flags only the item carrying `deleted:` — a live card inside a tombstoned lane stays `isDeleted == false`, and every consumer of the model (board view, trash, search, counts) must treat ancestor tombstones as hiding the subtree rather than filtering on the item flag alone and surface in the **trash quasi-lane** (03-board-ui.md) — a pure view. **Tombstoned items stay exactly where they are on disk**, which is what makes restore (Put Back = removing the key) position-preserving: the item reappears at its recorded `order` among its current siblings. One acknowledged edge: a renumber rescales *visible* siblings only (tombstones are inert to ordering), so a post-renumber Put Back can land among different neighbors than the ones it was deleted between — same lane, deterministic placement, just not always the original gap.
- Tombstones are **inert to ordering**: appends and renumbering operate on visible siblings only; a restored item re-enters at its recorded `order`, ties broken deterministically. - Tombstones are **inert to ordering**: appends and renumbering operate on visible siblings only; a restored item re-enters at its recorded `order`, ties broken deterministically.
- A folder that disappears without a tombstone (Finder deletion) is also a delete; the app reflects it (such items are gone entirely — they never enter the trash). - A folder that disappears without a tombstone (Finder deletion) is also a delete; the app reflects it (such items are gone entirely — they never enter the trash).
- A `deleted:` key at **board level** is legal per the common frontmatter table but meaningless — a board can't tombstone itself out of its own window. The loader ignores it and logs (the lenient-values philosophy: preserved verbatim, invisible in the UI). - A `deleted:` key at **board level** is legal per the common frontmatter table but meaningless — a board can't tombstone itself out of its own window. The loader ignores it and logs (the lenient-values philosophy: preserved verbatim, invisible in the UI).
@@ -0,0 +1 @@
there is no board without an index.md
@@ -0,0 +1,7 @@
---
schema: 1
order:
title: Lane With Null Order
---
The hand-editor started the 'order' key and never gave it a value — reads
as missing, not malformed.
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
schema: 1
title: Card Missing Order
---
No 'order' key — required on every card.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Lane Is Fine
---
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
schema: 1
title: Lane Missing Order
---
No 'order' key — required on every lane.
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
title: A Board Missing Its Schema
---
No 'schema' field here — the loader fails fast before looking at anything
else.
@@ -0,0 +1,7 @@
---
schema: 1
order: banana
title: Card With Non-Numeric Order
---
'order' is present but not a number — malformed, not missing; the same
loud rejection as a missing value.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Lane Is Fine
---
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
schema: 2
title: From The Future
---
This board was written by a newer version of the app than this one.
@@ -0,0 +1,5 @@
---
schema: 1
bad: [1, 2
---
Body text.
+33 -1
View File
@@ -2,4 +2,36 @@
Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook. Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook.
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`.
Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests.
## Valid/ — one board per tolerated/valid case
| Board | Case |
|---|---|
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Also the board every `index.md` in the tree is round-tripped against. |
| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). |
| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. |
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. |
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. |
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
## Malformed/ — one board per fail-fast case
Each board is minimal: one broken thing.
| Board | Case |
|---|---|
| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. |
| `missing-schema.kanban` | Board root `index.md` has no `schema` key. |
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
| `missing-order-lane.kanban` | A UUID-shaped lane `index.md` with no `order` key. |
| `missing-order-card.kanban` | A UUID-shaped card `index.md` with no `order` key. |
| `explicit-null-order.kanban` | A lane's `order:` key present with no value — reads as missing, not malformed. |
| `non-numeric-order.kanban` | A card's `order: banana` — present but not a number. |
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Also Business As Usual
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Business As Usual
---
@@ -0,0 +1,9 @@
---
schema: 1
title: Board With A Stray 'deleted' Key
deleted: 2026-01-01T00:00:00Z
---
'deleted' is legal per the common frontmatter table but meaningless at
board level — a board can't tombstone itself out of its own window. The
loader ignores it (and logs a warning) rather than acting on it; the rest
of the board loads completely normally.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: 2048
---
An unquoted integer title coerces to its source text, "2048".
@@ -0,0 +1,7 @@
---
schema: 1
order: 2048
title: [a, b]
---
A sequence has no sensible string reading — malformed, falls back to the
untitled placeholder.
@@ -0,0 +1,7 @@
---
schema: 1
order: 3072
iconColor: 42
---
An integer iconColor coerces to its source text, "42" — not a real color,
but the value round-trips rather than being rejected.
@@ -0,0 +1,7 @@
---
schema: 1
order: 4096
background: {x: 1}
---
A mapping has no sensible string reading — malformed, falls back to no
color.
@@ -0,0 +1,5 @@
---
schema: 1
order: 5120
background: 12345
---
@@ -0,0 +1,8 @@
---
schema: 1
order: 6144
title: Deleted With An Unusable Timestamp
deleted: definitely-not-a-date
---
The user's intent to delete outranks the broken date — this card still
tombstones, its deletion date just unknown.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: Width Coerces From A Quoted Number
width: "3"
---
@@ -0,0 +1,8 @@
---
schema: 1
order: 2048
title: Width Is Unusable — Not An Integer
width: 1.5
---
A malformed width is preserved on disk and simply not used — never
rejected, never rewritten.
+8
View File
@@ -0,0 +1,8 @@
---
schema: 1
title: Coercion And Lenient-Field Fallback
---
Schema-owned display fields coerce where a sensible reading exists (a
wrong-type scalar reads as its source text) and fall back to their default
where none does (a sequence/mapping, a non-integer width). A present but
unusable 'deleted' timestamp still tombstones — presence outranks validity.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Should Render First (folder starts with 1)
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Should Render Second (folder starts with 2)
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Should Render Third (folder starts with 3)
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Lane With Three Tied Cards
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 2048
title: Should Render Before Lane bbb... (same order, 'a' < 'b')
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 2048
title: Should Render After Lane aaa... (same order, 'b' > 'a')
---
@@ -0,0 +1,7 @@
---
schema: 1
title: Duplicate Order Values, Tie-Broken By Folder Name
---
Two siblings that share an 'order' render in a deterministic order —
ascending 'order' first, folder name (lexicographic) as the tiebreak.
This board exercises the tiebreak at both lane level and card level.
@@ -0,0 +1,6 @@
---
schema: 1
order: 2048
title: First Title
title: Second Title
---
@@ -0,0 +1,9 @@
---
schema: 1
order: 1024
order: 4096
title: Dup Order Lane
---
Duplicate keys aren't special-cased by field — 'order' is a strict,
schema-owned field and still resolves via last-wins at the document layer,
before the loader ever validates it. This is NOT a fail-fast case.
@@ -0,0 +1,7 @@
---
schema: 1
title: Draft Name
title: Final Name
---
A hand-edit that types 'title' twice — strict YAML would reject the file
outright. The engine reads the last occurrence and keeps both on disk.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: A Real Card
---
Body.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: A Real Lane
---
@@ -0,0 +1,6 @@
---
schema: 1
title: Interrupted Two-Step Create
---
The app creates a folder first and writes index.md a moment later — a crash
or a killed app between those two steps must not brick the board.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: Real Card
---
Body.
@@ -0,0 +1,5 @@
---
schema: 1
order: 9999
title: This looks like a card but isn't named like one
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Real Lane
---
@@ -0,0 +1,7 @@
---
schema: 1
title: Non-UUID Strays At Every Depth
---
Name shape gates level detection — a folder that isn't UUIDv4-shaped is a
stray, whether or not it holds an index.md, and whether it sits at lane
depth or card depth.
@@ -0,0 +1,6 @@
---
schema: 1
order: 9999
title: This looks like a lane but isn't named like one
---
Never rendered — 'todo-notes' isn't UUIDv4-shaped.
@@ -0,0 +1,8 @@
---
schema: 1
author: rzen
created: 2026-07-21T08:30:00Z
---
Comments are enhanced-schema and out of scope for this version — this
folder is here only to prove it's preserved verbatim and never descended
into (cards are leaves; the loader never scans past index.md).
@@ -0,0 +1,17 @@
---
schema: 1
title: "Design the fixture taxonomy"
order: 1024
created: 2026-07-20T10:15:00Z
modified: 2026-07-26T20:07:30Z
modified-by: claude
source: DESIGN/01-storage-format.md # inline comment on a plain scalar
project: lanework
sphere: work
labels: [m1-storage-read]
---
Enumerate every fail-fast and tolerated case from the storage format and
build one golden fixture board per case.
- attachments/ holds a sketch
- comments/ holds a placeholder future-comment folder
@@ -0,0 +1,9 @@
---
schema: 1
title: Wire up the loader's stray tolerance
order: 2048
background: coral
icon: flag.fill
iconColor: orange
---
Plain, unquoted title this time — mixing styles on purpose.
@@ -0,0 +1,10 @@
---
schema: 1
order: 1024
title: Doing
width: 2
background: '#3478F6'
icon: hammer.fill
iconColor: blue
---
WIP limit: 3 cards. Pull from Backlog only when there's room.
@@ -0,0 +1,8 @@
---
schema: 1
order: 1024
title: 'Ship v1'
created: 2026-06-01T12:00:00Z
modified: 2026-07-15T09:00:00Z
---
Shipped. 🎉
@@ -0,0 +1,7 @@
---
schema: 1
order: 2048
title: Done
background: green
---
Completed work lives here until someone clears it out.
+22
View File
@@ -0,0 +1,22 @@
---
schema: 1
# board settings, hand-written
title: "Rich Demo Board"
created: 2026-07-01T09:00:00Z
modified: 2026-07-26T16:41:38Z
modified-by: claude
background: "#1E1E1E"
icon: rectangle.stack.fill
iconColor: purple
project: lanework # agent overlay
sphere: work
labels: [feature, docs]
template: {order: 3}
# trailing note before close
---
A demo board exercising the full breadth of the storage contract: several
lanes, several cards, body text, styling, and unknown/reserved frontmatter
keys living alongside the schema-owned ones.
Nothing here is special-cased — it's an ordinary board.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: A Card With Junk Inside It
---
Body.
@@ -0,0 +1 @@
this whole subfolder is never scanned — cards are leaves
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: A Lane With Junk Beside It
---
@@ -0,0 +1 @@
board readme
@@ -0,0 +1,7 @@
---
schema: 1
title: Stray Files Everywhere
---
Stray files at board, lane, and card level are ignored — preserved
verbatim, never rendered, never warned about (only directories are level
candidates at all).
@@ -0,0 +1 @@
log line
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: A Living Card
---
@@ -0,0 +1,7 @@
---
schema: 1
order: 2048
title: A Tombstoned Card
deleted: 2026-07-10T08:00:00Z
---
Still on disk, at its recorded order, just hidden from the board.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Still Here
---
@@ -0,0 +1,7 @@
---
schema: 1
order: 1024
title: Not Itself Deleted
---
My lane is tombstoned but I am not — isDeleted keys only on my own
'deleted' field, never inherited from an ancestor.
@@ -0,0 +1,10 @@
---
schema: 1
order: 2048
title: A Tombstoned Lane
deleted: 2026-07-11T09:30:00Z
---
Everything beneath a tombstoned lane is hidden from the board too — but
that's a rendering rule, not a loader rule: the loader still reports this
lane's card structurally, un-flagged, since the card's own 'deleted' key
is absent.
@@ -0,0 +1,7 @@
---
schema: 1
title: Tombstoned Lane And Card Still On Disk
---
Deletion writes 'deleted:' into frontmatter; the item never leaves disk.
The loader keeps tombstoned items in the snapshot, flagged — hiding them
from the board is a rendering concern, not a loading one.
@@ -0,0 +1,12 @@
---
schema: 1
labels: [bug, urgent]
order: 1024
assignees: [bob, carol]
title: Card With Unknowns
due: 2026-08-15
remote: {system: github, id: 42}
agent-scratch: notes here
---
Card body describing the bug. Unknown/reserved keys are interleaved with
schema-owned ones on purpose, to prove ordering survives the interleave.
@@ -0,0 +1,9 @@
---
schema: 1
order: 1024
remote-state: syncing
title: Lane With Unknowns
assignees: [alice]
due: 2026-08-01
custom: xyz
---
@@ -0,0 +1,12 @@
---
schema: 1
project: lanework
title: Unknown Key Ordering
sphere: work
template: {order: 5}
labels: [x, y, z]
custom-note: keep me
---
Unknown keys and their relative order are preserved verbatim, on every
level — this is load-bearing for agent overlays (project:, sphere:,
custom tags).
+431
View File
@@ -0,0 +1,431 @@
import Foundation
import Testing
@testable import Kanban
// Golden fixture-board suite (01-storage-format.md, 02-architecture.md § Testing): real
// on-disk folder trees under `Fixtures/`, one board per tolerated/valid case and one per
// fail-fast case, asserted against `BoardLoader`. Complements `BoardLoaderTests.swift`'s
// synthetic smoke coverage this suite is the comprehensive, hand-authored, disk-backed
// counterpart.
// MARK: - Bundle resource resolution
/// A tiny anchor class purely so `Bundle(for:)` can find the test bundle there is no
/// `Bundle.module` in an xcodeproj target (that's an SPM-only convenience).
private final class FixtureBundleAnchor {}
/// The `Fixtures/` folder reference, copied into the test bundle's resources verbatim
/// (`project.yml`'s `KanbanTests` target). Real directories on disk, not synthesized strings.
private func fixturesRoot() -> URL {
guard let resources = Bundle(for: FixtureBundleAnchor.self).resourceURL else {
fatalError("test bundle has no resourceURL")
}
return resources.appendingPathComponent("Fixtures", isDirectory: true)
}
private func fixtureBoard(_ relativePath: String) -> URL {
fixturesRoot().appendingPathComponent(relativePath, isDirectory: true)
}
private func loadFixture(_ relativePath: String) throws -> LoadResult {
try BoardLoader.load(boardRoot: fixtureBoard(relativePath))
}
/// Every `index.md` beneath `root`, found by walking the real tree used by the round-trip
/// assertions, which don't want to hardcode which files exist.
private func allIndexMdFiles(under root: URL) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
var results: [URL] = []
for case let url as URL in enumerator where url.lastPathComponent == "index.md" {
results.append(url)
}
return results
}
private func iso8601(_ text: String) -> Date {
guard let date = ISO8601DateFormatter().date(from: text) else {
fatalError("bad test fixture: '\(text)' is not ISO-8601")
}
return date
}
private func expectFixtureFailure(
_ relativePath: String,
path: String,
reasonDescription: String,
_ matches: (BoardLoadError.Reason) -> Bool
) {
do {
_ = try loadFixture(relativePath)
Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded")
} catch let error as BoardLoadError {
#expect(error.path == path, "\(relativePath): wrong path in error")
#expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
} catch {
Issue.record("\(relativePath): expected a BoardLoadError, got \(error)")
}
}
// MARK: - Valid/rich-board.kanban
private enum RichBoard {
static let laneDoing = "10000000-0000-4000-8000-000000000001"
static let laneDone = "20000000-0000-4000-8000-000000000002"
static let cardTaxonomy = "30000000-0000-4000-8000-000000000003"
static let cardSecond = "40000000-0000-4000-8000-000000000004"
static let cardShip = "50000000-0000-4000-8000-000000000005"
}
struct FixtureRichBoardTests {
@Test func loadsFullShapeWithStylingAndNoWarnings() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let model = result.model
#expect(model.title.value == "Rich Demo Board")
#expect(model.background.value == "#1E1E1E")
#expect(model.icon.value == "rectangle.stack.fill")
#expect(model.iconColor.value == "purple")
#expect(model.modifiedBy.value == "claude")
if case let .mapping(pairs) = model.template {
#expect(pairs.count == 1)
} else {
Issue.record("expected board template to be a mapping, got \(String(describing: model.template))")
}
#expect(model.lanes.map(\.id.rawValue) == [RichBoard.laneDoing, RichBoard.laneDone])
#expect(result.warnings.isEmpty)
let doing = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
#expect(doing.title.value == "Doing")
#expect(doing.width.value == 2)
#expect(doing.background.value == "#3478F6")
#expect(doing.cards.map(\.id.rawValue) == [RichBoard.cardTaxonomy, RichBoard.cardSecond])
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
#expect(taxonomy.title.value == "Design the fixture taxonomy")
#expect(taxonomy.modifiedBy.value == "claude")
#expect(taxonomy.body.contains("attachments/ holds a sketch"))
let done = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
#expect(done.cards.map(\.id.rawValue) == [RichBoard.cardShip])
#expect(done.cards[0].title.value == "Ship v1")
}
@Test func boardUnknownAndReservedKeysPreserveOrder() throws {
let result = try loadFixture("Valid/rich-board.kanban")
// schema-owned keys (schema, title, created, modified, modified-by, background, icon,
// iconColor) are filtered out; only the agent-overlay and reserved keys remain, in the
// order they were written.
#expect(result.model.document.unknownFields.map(\.key) == ["project", "sphere", "labels", "template"])
}
/// The whole-tree round-trip guarantee (01-storage-format.md § Fractal layout: "the app
/// never reformats a body it didn't change"): every `index.md` under the rich board parses
/// and re-serializes to its original bytes, untouched.
@Test func everyIndexMdInTheTreeRoundTripsByteIdentically() throws {
let root = fixtureBoard("Valid/rich-board.kanban")
let files = try allIndexMdFiles(under: root)
#expect(files.count == 7) // board + 2 lanes + 3 cards + the one comment folder's index.md
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
let document = try FrontmatterDocument.parse(text)
#expect(document.serialized() == text, "\(file.path) did not round-trip byte-identically")
}
}
}
// MARK: - Valid/interrupted-create.kanban
struct FixtureInterruptedCreateTests {
@Test func indexlessFoldersAreSkippedNotFailed() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let laneInterrupted = "20000000-0000-4000-8000-000000000002"
let card = "30000000-0000-4000-8000-000000000003"
let cardInterrupted = "40000000-0000-4000-8000-000000000004"
let result = try loadFixture("Valid/interrupted-create.kanban")
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.count == 2)
#expect(result.warnings.contains(.missingIndex(path: laneInterrupted)))
#expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(cardInterrupted)")))
}
}
// MARK: - Valid/non-uuid-strays.kanban
struct FixtureNonUUIDStraysTests {
@Test func nonUUIDFoldersAreStraysAtEveryDepthRegardlessOfIndex() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/non-uuid-strays.kanban")
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.count == 4)
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo-notes")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "scratch")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/draft")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/wip")))
}
}
// MARK: - Valid/stray-files.kanban
struct FixtureStrayFilesTests {
@Test func strayFilesEverywhereProduceNoWarningsAndDontAffectTheModel() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/stray-files.kanban")
#expect(result.warnings.isEmpty)
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
}
// MARK: - Valid/tombstones.kanban
struct FixtureTombstonesTests {
@Test func tombstonedLaneAndCardStayInTheSnapshotFlagged() throws {
let laneLive = "10000000-0000-4000-8000-000000000001"
let laneDead = "20000000-0000-4000-8000-000000000002"
let cardLive = "30000000-0000-4000-8000-000000000003"
let cardDead = "40000000-0000-4000-8000-000000000004"
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/tombstones.kanban")
#expect(result.warnings.isEmpty)
#expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead])
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
#expect(live.isDeleted == false)
#expect(live.cards.map(\.id.rawValue) == [cardLive, cardDead])
#expect(live.cards.map(\.isDeleted) == [false, true])
let dead = try #require(result.model.lanes.first { $0.id.rawValue == laneDead })
#expect(dead.isDeleted == true)
#expect(dead.cards.map(\.id.rawValue) == [cardUnderDeadLane])
// A tombstoned lane doesn't propagate deletion onto its children's own flag the
// loader is structural, not recursive; hiding an ancestor's tombstoned subtree is a
// rendering concern, not a load-time one.
#expect(dead.cards[0].isDeleted == false)
}
}
// MARK: - Valid/duplicate-order-tie-break.kanban
struct FixtureDuplicateOrderTieBreakTests {
@Test func tiedLanesAndTiedCardsBreakByFolderNameAscending() throws {
let laneA = "10000000-0000-4000-8000-000000000001"
let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
let laneBBB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
let cardX = "10000000-0000-4000-8000-000000000001"
let cardY = "20000000-0000-4000-8000-000000000002"
let cardZ = "30000000-0000-4000-8000-000000000003"
let result = try loadFixture("Valid/duplicate-order-tie-break.kanban")
// laneA (order 1024) first; laneAAA and laneBBB tie at 2048, broken 'a' < 'b'.
#expect(result.model.lanes.map(\.id.rawValue) == [laneA, laneAAA, laneBBB])
let lane = try #require(result.model.lanes.first { $0.id.rawValue == laneA })
#expect(lane.cards.map(\.id.rawValue) == [cardX, cardY, cardZ])
#expect(Set(lane.cards.map(\.order)) == [1024])
}
}
// MARK: - Valid/unknown-key-order.kanban
struct FixtureUnknownKeyOrderTests {
@Test func unknownKeysPreserveDocumentOrderAtEveryLevel() throws {
let result = try loadFixture("Valid/unknown-key-order.kanban")
let model = result.model
#expect(model.document.unknownFields.map(\.key) == ["project", "sphere", "template", "labels", "custom-note"])
let lane = try #require(model.lanes.first)
#expect(lane.document.unknownFields.map(\.key) == ["remote-state", "assignees", "due", "custom"])
let card = try #require(lane.cards.first)
#expect(card.document.unknownFields.map(\.key) == ["labels", "assignees", "due", "remote", "agent-scratch"])
}
@Test func everyIndexMdRoundTripsByteIdentically() throws {
let root = fixtureBoard("Valid/unknown-key-order.kanban")
for file in try allIndexMdFiles(under: root) {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Valid/coercion.kanban
struct FixtureCoercionTests {
@Test func wrongTypeScalarsCoerceOrFallBackToDefaultPerField() throws {
let lane1 = "10000000-0000-4000-8000-000000000001"
let lane2 = "20000000-0000-4000-8000-000000000002"
let cardTitleInt = "30000000-0000-4000-8000-000000000003"
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
let cardBackgroundInt = "70000000-0000-4000-8000-000000000007"
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
let result = try loadFixture("Valid/coercion.kanban")
let model = result.model
let laneWidthCoerces = try #require(model.lanes.first { $0.id.rawValue == lane1 })
#expect(laneWidthCoerces.width == .valid(3)) // width: "3" (quoted string) coerces
let laneWidthMalformed = try #require(model.lanes.first { $0.id.rawValue == lane2 })
#expect(laneWidthMalformed.width == .malformed(raw: "1.5")) // non-integer, no sensible width
func card(_ id: String) throws -> Card {
try #require(laneWidthCoerces.cards.first { $0.id.rawValue == id })
}
#expect(try card(cardTitleInt).title == .valid("2048"))
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
#expect(try card(cardBackgroundInt).background == .valid("12345"))
let deletedBad = try card(cardDeletedBad)
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
// Presence outranks validity: an unusable timestamp still tombstones.
#expect(deletedBad.isDeleted == true)
}
}
// MARK: - Valid/duplicate-top-level-keys.kanban
struct FixtureDuplicateTopLevelKeysTests {
@Test func lastOccurrenceWinsAtBoardLaneAndCardLevel() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/duplicate-top-level-keys.kanban")
#expect(result.warnings.isEmpty) // NOT a fail-fast case, per the newer design-doc rule
#expect(result.model.title == .valid("Final Name"))
let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane })
#expect(loadedLane.order == 4096) // duplicated 'order' a strict field still last-wins
let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card })
#expect(loadedCard.title == .valid("Second Title"))
}
/// Earlier occurrences of a duplicated key are invisible to every read but still preserved
/// verbatim on disk (01-storage-format.md § Frontmatter) proven by round-tripping every
/// file in this board, not just asserting the winning value.
@Test func earlierOccurrencesSurviveOnDiskViaRoundTrip() throws {
let root = fixtureBoard("Valid/duplicate-top-level-keys.kanban")
let files = try allIndexMdFiles(under: root)
#expect(files.count == 3)
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Valid/board-level-deleted.kanban
struct FixtureBoardLevelDeletedTests {
@Test func boardLevelDeletedIsIgnoredButRestOfBoardLoadsNormally() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/board-level-deleted.kanban")
#expect(result.warnings.contains(.boardLevelDeletedIgnored))
#expect(result.model.deleted == .valid(iso8601("2026-01-01T00:00:00Z")))
// Meaningless at board level, but never blanks the board the rest loads as usual.
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
}
// MARK: - Malformed/*.kanban fail-fast cases
struct FixtureMalformedTests {
@Test func unparseableYAML() {
expectFixtureFailure("Malformed/unparseable-yaml.kanban", path: "index.md", reasonDescription: "unparseableYAML") {
if case .unparseableYAML = $0 { true } else { false }
}
}
@Test func missingSchema() {
expectFixtureFailure("Malformed/missing-schema.kanban", path: "index.md", reasonDescription: "missingSchema") {
$0 == .missingSchema
}
}
@Test func schemaNewerThanApp() {
expectFixtureFailure(
"Malformed/schema-newer-than-app.kanban", path: "index.md", reasonDescription: "schemaNewerThanApp(2)"
) {
$0 == .schemaNewerThanApp(found: 2)
}
}
@Test func missingOrderOnLane() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/missing-order-lane.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func missingOrderOnCard() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/missing-order-card.kanban", path: "\(lane)/\(card)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
/// Explicit null reads as missing (01-storage-format.md § Malformed input): `order:` with
/// nothing after it fails the same way a missing key does, not as `.malformedOrder`.
@Test func explicitNullOrderReadsAsMissing() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/explicit-null-order.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func presentButNonNumericOrder() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/non-numeric-order.kanban",
path: "\(lane)/\(card)/index.md",
reasonDescription: "malformedOrder(banana)"
) {
$0 == .malformedOrder(raw: "banana")
}
}
@Test func boardRootMissingIndex() {
expectFixtureFailure(
"Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex"
) {
$0 == .boardRootMissingIndex
}
}
}
+3 -1
View File
@@ -8,7 +8,9 @@ The defining consequence: anything that can read and write files is a first-clas
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/). Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
*Nothing user-facing has landed yet — the project is at the scaffold stage.* *No UI yet — the storage foundation is in place:*
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards.
## Development ## Development