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:
@@ -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
|
||||||
+7
@@ -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
|
||||||
|
---
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Card Missing Order
|
||||||
|
---
|
||||||
|
No 'order' key — required on every card.
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Lane Is Fine
|
||||||
|
---
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Board Is Fine
|
||||||
|
---
|
||||||
+5
@@ -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.
|
||||||
+7
@@ -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.
|
||||||
+5
@@ -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
@@ -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`. |
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Also Business As Usual
|
||||||
|
---
|
||||||
+5
@@ -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.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: 2048
|
||||||
|
---
|
||||||
|
An unquoted integer title coerces to its source text, "2048".
|
||||||
+7
@@ -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.
|
||||||
+7
@@ -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.
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 4096
|
||||||
|
background: {x: 1}
|
||||||
|
---
|
||||||
|
A mapping has no sensible string reading — malformed, falls back to no
|
||||||
|
color.
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 5120
|
||||||
|
background: 12345
|
||||||
|
---
|
||||||
+8
@@ -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.
|
||||||
@@ -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.
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Should Render First (folder starts with 1)
|
||||||
|
---
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Should Render Second (folder starts with 2)
|
||||||
|
---
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Should Render Third (folder starts with 3)
|
||||||
|
---
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Lane With Three Tied Cards
|
||||||
|
---
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 2048
|
||||||
|
title: Should Render Before Lane bbb... (same order, 'a' < 'b')
|
||||||
|
---
|
||||||
+5
@@ -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.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 2048
|
||||||
|
title: First Title
|
||||||
|
title: Second Title
|
||||||
|
---
|
||||||
+9
@@ -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.
|
||||||
+6
@@ -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.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Real Card
|
||||||
|
---
|
||||||
|
Body.
|
||||||
+5
@@ -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.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
not-a-real-png-just-a-placeholder-attachment
|
||||||
+8
@@ -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).
|
||||||
+17
@@ -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
|
||||||
+9
@@ -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.
|
||||||
+8
@@ -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.
|
||||||
@@ -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.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: A Card With Junk Inside It
|
||||||
|
---
|
||||||
|
Body.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
this whole subfolder is never scanned — cards are leaves
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
scratch
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
draft notes
|
||||||
@@ -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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: A Living Card
|
||||||
|
---
|
||||||
+7
@@ -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
|
||||||
|
---
|
||||||
+7
@@ -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.
|
||||||
+12
@@ -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).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user