The loader gains a ParseMemo — the previous walk's parsed documents keyed by root-relative path, trusted on the git-index heuristic (mtime + size, no hashing) and passed as an input so the loader stays stateless. A hit skips exactly one file read; schema, order, coercions, dedupe, and every directory listing run fresh, so memoized and cold walks are output- identical (golden-corpus equivalence suite). Entries record only past the schema gate, so a defect can never be answered from the memo. The store skips the snapshot assignment wholesale when the fresh model is value-equal — no @Observable churn, no render pass, no snapshotGeneration bump — and a new landedReloads counter carries walk-completion for the three consumers whose subject is the walk, not the snapshot: the card window's comment thread, the comment search index, and the auto-committer's covering gate (which now counts a completed walk as covering even when nothing changed). Warnings and defects move on their own equality; failed reloads bump neither counter. An injectable ParseCounter makes the single-file-echo claim a test. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
892 lines
43 KiB
Swift
892 lines
43 KiB
Swift
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 failure as BoardLoadFailure {
|
|
// Each `Malformed/` board is minimal — "one broken thing" — so the aggregate holds exactly
|
|
// one defect, and asserting the count is what keeps that authoring rule true.
|
|
#expect(failure.defects.count == 1, "\(relativePath): expected one defect, got \(failure.defects)")
|
|
let error = failure.primary
|
|
#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 BoardLoadFailure, 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")
|
|
}
|
|
|
|
/// `attachments/` is **flat** (01-storage-format.md § Attachments): the card's listing is its
|
|
/// top-level regular files and nothing else. This card's folder holds all four shapes on real
|
|
/// disk — two ordinary files, a hidden one, and a subfolder with a file in it — so the rule is
|
|
/// asserted against a filesystem rather than against a mock.
|
|
///
|
|
/// The excluded three are excluded for three different reasons and only one of them is stated
|
|
/// in the design doc: subfolders are "tolerated, preserved verbatim … and not surfaced"; the
|
|
/// hidden file is the loader's uniform `.skipsHiddenFiles` stance (a `.DS_Store` is not
|
|
/// anyone's attachment); symlinks are the loader's never-resolve stance, covered in
|
|
/// `BoardLoaderTests` because git cannot carry that shape into a fixture reliably.
|
|
@Test func aCardListsOnlyTheTopLevelFilesOfItsAttachmentsFolder() throws {
|
|
let result = try loadFixture("Valid/rich-board.kanban")
|
|
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
|
|
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
|
|
|
|
#expect(taxonomy.attachments == ["notes.txt", "sketch.png"])
|
|
}
|
|
|
|
/// The overwhelmingly common shape: no `attachments/` folder at all. It reads as an empty
|
|
/// listing, never as a warning or a failure — nothing has been attached yet is an ordinary
|
|
/// state, and it is what makes the face's paperclip indicator absent by default.
|
|
@Test func cardsWithoutAnAttachmentsFolderListNothing() throws {
|
|
let result = try loadFixture("Valid/rich-board.kanban")
|
|
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
|
|
let second = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardSecond })
|
|
let done = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
|
|
|
|
#expect(second.attachments.isEmpty)
|
|
#expect(done.cards.map(\.attachments) == [[]])
|
|
#expect(result.warnings.isEmpty)
|
|
}
|
|
|
|
@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])
|
|
}
|
|
|
|
/// The board's card-level `scratch.md` is the **one** stray this fixture holds that is not
|
|
/// tolerated: the loose-file carve-out (01-storage-format.md § Fractal layout ▸ Rules, settled
|
|
/// 2026-07-28) says a regular file beside a card's `index.md` belongs in `attachments/`. It is
|
|
/// reported on its own channel — never as a `warning`, which is the *tolerance* vocabulary —
|
|
/// and the board-level and lane-level strays around it stay exactly as tolerated as they were.
|
|
///
|
|
/// **Detection does not mutate**: this is the loader, over a fixture that lives in git, and the
|
|
/// assertion that the file is still there afterwards is the read-only claim stated on the one
|
|
/// tree where a stray write would be visible in `git status`.
|
|
@Test func aCardLevelLooseFileIsReportedForRelocationWithoutBeingTouched() 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.looseCardFiles == [
|
|
LooseCardFiles(
|
|
laneID: ItemID(rawValue: lane),
|
|
cardID: ItemID(rawValue: card),
|
|
title: result.model.lanes[0].cards[0].title.value,
|
|
fileNames: ["scratch.md"]
|
|
),
|
|
])
|
|
|
|
let scratch = fixtureBoard("Valid/stray-files.kanban")
|
|
.appendingPathComponent("\(lane)/\(card)/scratch.md")
|
|
#expect(FileManager.default.fileExists(atPath: scratch.path))
|
|
#expect(!FileManager.default.fileExists(
|
|
atPath: fixtureBoard("Valid/stray-files.kanban")
|
|
.appendingPathComponent("\(lane)/\(card)/attachments").path
|
|
))
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
// The lane's key is the tolerate tier's since 2026-07-29 — ignored, logged, and the lane
|
|
// loads live (01 § Deletion, lane clause).
|
|
#expect(result.warnings == [.laneLevelDeletedIgnored(path: laneDead)])
|
|
#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"
|
|
// Distinct from `laneA` on purpose: a lane and one of its own cards sharing a UUID is a
|
|
// duplicate id board-wide, which the loader's dedupe withholds (01-storage-format.md
|
|
// § Fractal layout ▸ Rules). It sorts before `cardY` either way, which is all this fixture
|
|
// ever needed of it.
|
|
let cardX = "10000000-0000-4000-8000-00000000000a"
|
|
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: - Valid/optional-keys.kanban
|
|
|
|
/// **The optional-key ruling's golden board** (01-storage-format.md § Frontmatter and § Ordering,
|
|
/// re-ruled 2026-07-31): below the board root `order` and `schema` are optional, a missing or
|
|
/// unusable `order` reads as append-at-end, and a missing `schema` reads as 1. Every shape that used
|
|
/// to have its own board under `Malformed/` lives here instead, as a coercion case.
|
|
private enum OptionalKeys {
|
|
static let rankedLane = "10000000-0000-4000-8000-000000000001"
|
|
static let schemalessLane = "40000000-0000-4000-8000-000000000002"
|
|
static let orderlessLane = "30000000-0000-4000-8000-000000000003"
|
|
|
|
static let rankedCard = "20000000-0000-4000-8000-000000000001"
|
|
static let minimumCard = "20000000-0000-4000-8000-000000000002"
|
|
static let nullOrderCard = "20000000-0000-4000-8000-000000000003"
|
|
static let nonNumericCard = "20000000-0000-4000-8000-000000000004"
|
|
static let nonFiniteCard = "20000000-0000-4000-8000-000000000005"
|
|
}
|
|
|
|
struct FixtureOptionalKeysTests {
|
|
|
|
/// The strip: ranked lanes first in ascending order, then the order-less one — and the
|
|
/// `schema`-less lane is an ordinary ranked lane, since only its `schema` was absent.
|
|
@Test func orderlessLaneSortsAfterEveryRankedOne() throws {
|
|
let model = try loadFixture("Valid/optional-keys.kanban").model
|
|
#expect(model.lanes.map(\.id.rawValue) == [
|
|
OptionalKeys.rankedLane, OptionalKeys.schemalessLane, OptionalKeys.orderlessLane,
|
|
])
|
|
#expect(model.lanes.map(\.order) == [1024, 2048, 3072])
|
|
}
|
|
|
|
/// Four order-less cards behind one ranked one, in folder-name order — the tie-break the ruling
|
|
/// states the reading in, and the accepted cost it names ("two order-less siblings sort by UUID
|
|
/// rather than by intent until touched").
|
|
@Test func orderlessCardsAppendInFolderNameOrder() throws {
|
|
let model = try loadFixture("Valid/optional-keys.kanban").model
|
|
let lane = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.rankedLane })
|
|
#expect(lane.cards.map(\.id.rawValue) == [
|
|
OptionalKeys.rankedCard,
|
|
OptionalKeys.minimumCard,
|
|
OptionalKeys.nullOrderCard,
|
|
OptionalKeys.nonNumericCard,
|
|
OptionalKeys.nonFiniteCard,
|
|
])
|
|
// `append`'s own arithmetic, which is what makes the reading stampable verbatim.
|
|
#expect(lane.cards.map(\.order) == [1024, 2048, 3072, 4096, 5120])
|
|
}
|
|
|
|
/// A missing `schema` below the root reads as 1 — at both levels.
|
|
@Test func missingSchemaBelowTheRootReadsAsOne() throws {
|
|
let model = try loadFixture("Valid/optional-keys.kanban").model
|
|
let lane = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.schemalessLane })
|
|
#expect(lane.schema == 1)
|
|
|
|
let ranked = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.rankedLane })
|
|
let minimum = try #require(ranked.cards.first { $0.id.rawValue == OptionalKeys.minimumCard })
|
|
#expect(minimum.schema == 1)
|
|
#expect(minimum.title == .valid("Minimum Agent Card"))
|
|
}
|
|
|
|
/// Every reading leaves a coerce-tier trace: field, path, and the text as written — an absent
|
|
/// key having none to record (01-storage-format.md § Frontmatter, the family posture).
|
|
@Test func everyReadingIsRecordedAsACoercion() throws {
|
|
let result = try loadFixture("Valid/optional-keys.kanban")
|
|
let byPath = Dictionary(
|
|
uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) })
|
|
|
|
func fields(_ path: String) throws -> [CoercedField] {
|
|
try #require(byPath[path], "no coercion recorded for \(path)")
|
|
}
|
|
|
|
#expect(try fields("\(OptionalKeys.orderlessLane)/index.md") == [CoercedField(key: "order", raw: "")])
|
|
#expect(try fields("\(OptionalKeys.schemalessLane)/index.md") == [CoercedField(key: "schema", raw: "")])
|
|
|
|
let lane = OptionalKeys.rankedLane
|
|
#expect(try fields("\(lane)/\(OptionalKeys.minimumCard)/index.md") == [
|
|
CoercedField(key: "schema", raw: ""), CoercedField(key: "order", raw: ""),
|
|
])
|
|
#expect(try fields("\(lane)/\(OptionalKeys.nullOrderCard)/index.md")
|
|
== [CoercedField(key: "order", raw: "")])
|
|
#expect(try fields("\(lane)/\(OptionalKeys.nonNumericCard)/index.md")
|
|
== [CoercedField(key: "order", raw: "banana")])
|
|
#expect(try fields("\(lane)/\(OptionalKeys.nonFiniteCard)/index.md")
|
|
== [CoercedField(key: "order", raw: ".nan")])
|
|
|
|
// Coerce-tier means read-side only: nothing here is work, so nothing carries a heal class.
|
|
#expect(result.defects.allSatisfy { $0.healClass == nil })
|
|
}
|
|
|
|
/// The whole board loads clean — no warnings, no fail-fast, and every file byte-identical after
|
|
/// a parse/serialize round-trip: the bytes are preserved verbatim, which is the coerce tier's
|
|
/// other half.
|
|
@Test func loadsWithoutWarningsAndRoundTrips() throws {
|
|
let result = try loadFixture("Valid/optional-keys.kanban")
|
|
#expect(result.warnings.isEmpty)
|
|
|
|
for file in try allIndexMdFiles(under: fixtureBoard("Valid/optional-keys.kanban")) {
|
|
let text = try String(contentsOf: file, encoding: .utf8)
|
|
#expect(try FrontmatterDocument.parse(text).serialized() == text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
}
|
|
|
|
/// **The board root's own `schema` is still required** (re-ruled 2026-07-31 — the
|
|
/// this-really-is-a-board gate). Its below-the-root twin is `FixtureOptionalKeysTests`, where
|
|
/// the same absence reads as 1.
|
|
@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 boardRootMissingIndex() {
|
|
expectFixtureFailure(
|
|
"Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex"
|
|
) {
|
|
$0 == .boardRootMissingIndex
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Malformed/many-defects.kanban — the collect-all board
|
|
|
|
/// **The loader collects every fail-fast defect in the walk rather than stopping at the first**
|
|
/// (01-storage-format.md § Malformed input, settled 2026-07-31 — the decision surface's whole
|
|
/// premise: "one aggregated surface presents them all", never a chain of modals).
|
|
///
|
|
/// Three classes on one board, on real disk: the root's own missing `schema`, a card written by a
|
|
/// newer Lanework, and a lane whose frontmatter will not parse.
|
|
private enum ManyDefects {
|
|
static let board = "Malformed/many-defects.kanban"
|
|
|
|
static let intactLane = "10000000-0000-4000-8000-000000000001"
|
|
static let brokenLane = "30000000-0000-4000-8000-000000000002"
|
|
static let tailLane = "50000000-0000-4000-8000-000000000003"
|
|
|
|
static let intactCard = "20000000-0000-4000-8000-000000000001"
|
|
static let newerCard = "20000000-0000-4000-8000-000000000002"
|
|
/// Broken, and behind the broken lane — never enumerated, so never in the aggregate.
|
|
static let cardBehindTheBrokenLane = "40000000-0000-4000-8000-000000000009"
|
|
static let tailCard = "60000000-0000-4000-8000-000000000004"
|
|
}
|
|
|
|
struct FixtureManyDefectsTests {
|
|
|
|
/// The whole list, in walk order, asserted as a list — the ordering *is* the contract, because it
|
|
/// is what the surface groups top-down and what `primary` reads off.
|
|
@Test func everyFailFastDefectIsCollectedInWalkOrder() {
|
|
do {
|
|
_ = try loadFixture(ManyDefects.board)
|
|
Issue.record("a board with three fail-fast defects loaded")
|
|
} catch let failure as BoardLoadFailure {
|
|
// The paths, as a list, because the *order* is the contract: the root first — its own
|
|
// `schema` is the this-really-is-a-board gate, and the walk continued past it because
|
|
// nothing below reads the root's document — then lanes in folder-name order with each
|
|
// lane's cards inside it.
|
|
#expect(failure.defects.map(\.path) == [
|
|
"index.md",
|
|
"\(ManyDefects.intactLane)/\(ManyDefects.newerCard)/index.md",
|
|
"\(ManyDefects.brokenLane)/index.md",
|
|
])
|
|
guard failure.defects.count == 3 else { return }
|
|
#expect(failure.defects[0].reason == .missingSchema)
|
|
#expect(failure.defects[1].reason == .schemaNewerThanApp(found: 2))
|
|
// The lane's reason is matched by shape rather than by the parser's exact sentence,
|
|
// which is the YAML engine's wording and not this suite's to pin.
|
|
if case .unparseableYAML = failure.defects[2].reason {} else {
|
|
Issue.record("expected unparseable YAML on the lane, got \(failure.defects[2].reason)")
|
|
}
|
|
} catch {
|
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// **A broken lane takes its subtree with it, and that is the designed loop.** The card under the
|
|
/// unparseable lane is broken too and is deliberately absent from the aggregate: it was never
|
|
/// enumerated. Repair the lane, Re-check — "re-runs the whole walk" — and the next aggregate
|
|
/// carries it. Deeper defects surface one repair at a time, by design, not by omission.
|
|
@Test func aBrokenLanesSubtreeIsNeverEnumerated() {
|
|
do {
|
|
_ = try loadFixture(ManyDefects.board)
|
|
Issue.record("a board with three fail-fast defects loaded")
|
|
} catch let failure as BoardLoadFailure {
|
|
#expect(!failure.defects.contains { $0.path.contains(ManyDefects.cardBehindTheBrokenLane) })
|
|
} catch {
|
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// `primary` is the walk's first defect — the outermost problem, which is the one a one-line
|
|
/// surface should name.
|
|
@Test func primaryIsTheRootsOwnDefect() {
|
|
do {
|
|
_ = try loadFixture(ManyDefects.board)
|
|
Issue.record("a board with three fail-fast defects loaded")
|
|
} catch let failure as BoardLoadFailure {
|
|
#expect(failure.primary == BoardLoadError(path: "index.md", reason: .missingSchema))
|
|
#expect(failure.description.hasSuffix("(and 2 more)"))
|
|
} catch {
|
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// **The root is unskippable** (01-storage-format.md § Malformed input): the surface never offers
|
|
/// Skip there — the root defects have minted repairs, and a newer root schema is Cancel-only — so
|
|
/// a set naming the root's `index.md` is ignored rather than obeyed. Policed in the loader, which
|
|
/// is what makes a board with no root document impossible to construct.
|
|
@Test func aSkipSetNamingTheRootIsIgnored() {
|
|
do {
|
|
_ = try BoardLoader.load(
|
|
boardRoot: fixtureBoard(ManyDefects.board),
|
|
skipping: [
|
|
"index.md",
|
|
".",
|
|
"\(ManyDefects.intactLane)/\(ManyDefects.newerCard)/index.md",
|
|
"\(ManyDefects.brokenLane)/index.md",
|
|
]
|
|
)
|
|
Issue.record("the root's own defect was skipped — a board with no schema loaded")
|
|
} catch let failure as BoardLoadFailure {
|
|
// Exactly the root's, and nothing else: the two below-root entries were honoured.
|
|
#expect(failure.defects == [BoardLoadError(path: "index.md", reason: .missingSchema)])
|
|
} catch {
|
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Malformed/skippable-defects.kanban — the skip channel
|
|
|
|
/// **"Skip is user-consented tolerance, loudly marked"** (01-storage-format.md § Malformed input,
|
|
/// ruled 2026-07-31): a skipped item loads the board without it, the file stays on disk untouched,
|
|
/// and the opened board carries a warning naming what left. Per-open, never persisted — which this
|
|
/// suite gets for free, because the set is an argument.
|
|
private enum SkippableDefects {
|
|
static let board = "Malformed/skippable-defects.kanban"
|
|
|
|
static let intactLane = "10000000-0000-4000-8000-000000000001"
|
|
static let brokenLane = "30000000-0000-4000-8000-000000000002"
|
|
static let tailLane = "50000000-0000-4000-8000-000000000003"
|
|
|
|
static let intactCard = "20000000-0000-4000-8000-000000000001"
|
|
static let newerCard = "20000000-0000-4000-8000-000000000002"
|
|
static let cardBehindTheBrokenLane = "40000000-0000-4000-8000-000000000009"
|
|
static let tailCard = "60000000-0000-4000-8000-000000000004"
|
|
|
|
static let newerCardPath = "\(intactLane)/\(newerCard)/index.md"
|
|
static let brokenLanePath = "\(brokenLane)/index.md"
|
|
}
|
|
|
|
struct FixtureSkippableDefectsTests {
|
|
|
|
/// Unskipped, the board is an ordinary two-defect refusal — the baseline the skips are measured
|
|
/// against, and the proof the fixture is broken in exactly two places.
|
|
@Test func withoutSkipsTheBoardRefusesWithBothDefects() {
|
|
do {
|
|
_ = try loadFixture(SkippableDefects.board)
|
|
Issue.record("a board with two fail-fast defects loaded")
|
|
} catch let failure as BoardLoadFailure {
|
|
#expect(failure.defects.map(\.path) == [
|
|
SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath,
|
|
])
|
|
} catch {
|
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// Skip both and the board opens — **and each skipped item leaves with its whole subtree**. The
|
|
/// broken lane's own card is perfectly valid and is gone too: that is the honest cost of the
|
|
/// tolerance, and the reason the notice names what left rather than pretending nothing did.
|
|
@Test func skippingEveryDefectLoadsTheBoardWithoutThoseItems() throws {
|
|
let result = try BoardLoader.load(
|
|
boardRoot: fixtureBoard(SkippableDefects.board),
|
|
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
|
)
|
|
|
|
#expect(result.model.title.value == "Skippable Defects")
|
|
#expect(result.model.lanes.map(\.id.rawValue) == [
|
|
SkippableDefects.intactLane, SkippableDefects.tailLane,
|
|
])
|
|
|
|
let intact = try #require(result.model.lanes.first { $0.id.rawValue == SkippableDefects.intactLane })
|
|
#expect(intact.cards.map(\.id.rawValue) == [SkippableDefects.intactCard])
|
|
|
|
// The subtree went with the lane, valid card and all.
|
|
let everyCard = result.model.lanes.flatMap { $0.cards.map(\.id.rawValue) }
|
|
#expect(!everyCard.contains(SkippableDefects.cardBehindTheBrokenLane))
|
|
#expect(everyCard == [SkippableDefects.intactCard, SkippableDefects.tailCard])
|
|
}
|
|
|
|
/// The loud mark: one warning per skip, naming the defect's own path — which is what the surface
|
|
/// row named and what the opened board's notice resolves its Reveal in Finder against.
|
|
@Test func everySkipIsWarnedAbout() throws {
|
|
let result = try BoardLoader.load(
|
|
boardRoot: fixtureBoard(SkippableDefects.board),
|
|
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
|
)
|
|
|
|
#expect(result.warnings == [
|
|
.userSkipped(path: SkippableDefects.newerCardPath),
|
|
.userSkipped(path: SkippableDefects.brokenLanePath),
|
|
])
|
|
}
|
|
|
|
/// A partial skip is an ordinary refusal over what is left — the surface's per-item override,
|
|
/// and the reason Skip is a set rather than a switch.
|
|
@Test func skippingOneDefectStillRefusesForTheOther() {
|
|
do {
|
|
_ = try BoardLoader.load(
|
|
boardRoot: fixtureBoard(SkippableDefects.board),
|
|
skipping: [SkippableDefects.newerCardPath]
|
|
)
|
|
Issue.record("the unskipped lane defect did not refuse the load")
|
|
} catch let failure as BoardLoadFailure {
|
|
#expect(failure.defects.map(\.path) == [SkippableDefects.brokenLanePath])
|
|
} catch {
|
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// **Skip touches nothing on disk** — "the file stays on disk untouched, tolerated-invisible like
|
|
/// strays". Asserted on the one tree where a stray write would show up in `git status`.
|
|
@Test func aSkippedItemIsLeftExactlyWhereItIs() throws {
|
|
let root = fixtureBoard(SkippableDefects.board)
|
|
let before = try allIndexMdFiles(under: root).map(\.path).sorted()
|
|
|
|
_ = try BoardLoader.load(
|
|
boardRoot: root,
|
|
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
|
)
|
|
|
|
#expect(try allIndexMdFiles(under: root).map(\.path).sorted() == before)
|
|
let skipped = root.appendingPathComponent(SkippableDefects.brokenLanePath)
|
|
let text = try String(contentsOf: skipped, encoding: .utf8)
|
|
#expect(text.contains("labels: [red, green"), "the skipped file was rewritten")
|
|
}
|
|
}
|
|
|
|
// MARK: - Memo-vs-cold equivalence, over every golden board
|
|
|
|
/// **Result-purity with cost unspecified** (02-architecture.md § Live-reload resilience, blessed
|
|
/// 2026-07-31: "The loader's contract is result-purity with cost unspecified: same tree in, same
|
|
/// snapshot out, and the memo can only change how fast").
|
|
///
|
|
/// The memo is the one thing in the loader that could make two walks of the same tree disagree, so
|
|
/// the claim is stated where the trees are real and hand-authored: every golden board is walked cold,
|
|
/// then walked again with the first walk's memo, and the two results are compared whole. Nothing here
|
|
/// edits a tree — the mechanism's per-file behaviour is `BoardLoaderParseMemoTests`' subject; this is
|
|
/// the equivalence, across every shape the fixture corpus holds.
|
|
///
|
|
/// The malformed boards are included on purpose. A refusal is a result too, and a memo that changed
|
|
/// which defects a walk collected — or their order — would be the worst possible way for this to be
|
|
/// wrong, since the decision surface is written directly against that list.
|
|
struct FixtureMemoEquivalenceTests {
|
|
|
|
/// Every board under `Fixtures/Valid`, by relative path.
|
|
static let validBoards = [
|
|
"Valid/rich-board.kanban",
|
|
"Valid/interrupted-create.kanban",
|
|
"Valid/non-uuid-strays.kanban",
|
|
"Valid/stray-files.kanban",
|
|
"Valid/tombstones.kanban",
|
|
"Valid/duplicate-order-tie-break.kanban",
|
|
"Valid/unknown-key-order.kanban",
|
|
"Valid/coercion.kanban",
|
|
"Valid/duplicate-top-level-keys.kanban",
|
|
"Valid/board-level-deleted.kanban",
|
|
"Valid/optional-keys.kanban",
|
|
]
|
|
|
|
/// Every board under `Fixtures/Malformed`, by relative path.
|
|
static let malformedBoards = [
|
|
"Malformed/board-root-missing-index.kanban",
|
|
"Malformed/missing-schema.kanban",
|
|
"Malformed/unparseable-yaml.kanban",
|
|
"Malformed/schema-newer-than-app.kanban",
|
|
"Malformed/many-defects.kanban",
|
|
"Malformed/skippable-defects.kanban",
|
|
]
|
|
|
|
@Test("A memoized walk of a valid board is the cold walk, whole", arguments: validBoards)
|
|
func aMemoizedWalkMatchesTheColdWalk(board: String) throws {
|
|
let cold = try loadFixture(board)
|
|
let warm = try BoardLoader.load(boardRoot: fixtureBoard(board), memo: cold.memo)
|
|
|
|
#expect(warm.model == cold.model)
|
|
#expect(warm.warnings == cold.warnings)
|
|
#expect(warm.defects == cold.defects)
|
|
#expect(warm.trashKinds == cold.trashKinds)
|
|
// The memo the second walk produced can stand in for the first's, which is what makes the
|
|
// store's chain of reloads self-sustaining rather than degrading walk by walk.
|
|
#expect(warm.memo.count == cold.memo.count)
|
|
}
|
|
|
|
@Test("A memoized walk of a valid board opens no index.md at all", arguments: validBoards)
|
|
func aMemoizedWalkReadsNothing(board: String) throws {
|
|
let cold = try loadFixture(board)
|
|
|
|
let counter = BoardLoader.ParseCounter()
|
|
_ = try BoardLoader.load(boardRoot: fixtureBoard(board), memo: cold.memo, counter: counter)
|
|
|
|
#expect(counter.counts.parsed == 0)
|
|
#expect(counter.counts.reused == cold.memo.count)
|
|
#expect(counter.counts.reused > 0, "a fixture with nothing to memoize proves nothing")
|
|
}
|
|
|
|
@Test("A memoized walk of a malformed board refuses identically", arguments: malformedBoards)
|
|
func aMemoizedWalkRefusesIdentically(board: String) {
|
|
// A refusing walk hands back no memo — that is the design, not an omission: a defective file
|
|
// is never recorded, and a walk that threw produced no `LoadResult` to carry one on. So the
|
|
// memo under test is the empty one a first walk would offer, and what is pinned is that the
|
|
// memo parameter never moves the aggregate a refusal reports.
|
|
let defects = refusal(board, memo: nil)
|
|
#expect(defects == refusal(board, memo: BoardLoader.ParseMemo()))
|
|
#expect(!defects.isEmpty, "\(board) is in the malformed corpus but loaded")
|
|
}
|
|
|
|
/// One malformed board's aggregate, or `[]` where it unexpectedly loaded.
|
|
private func refusal(_ board: String, memo: BoardLoader.ParseMemo?) -> [BoardLoadError] {
|
|
do throws(BoardLoadFailure) {
|
|
_ = try BoardLoader.load(boardRoot: fixtureBoard(board), memo: memo)
|
|
return []
|
|
} catch {
|
|
return error.defects
|
|
}
|
|
}
|
|
|
|
/// The malformed corpus' real memo case: a board that refuses is repaired-by-skip into one that
|
|
/// loads, and the memo that produced carries into the next walk without moving the answer.
|
|
@Test("A skipped-open board reloads through its own memo unchanged")
|
|
func aSkippedOpenReloadsUnchanged() throws {
|
|
let skips: Set<String> = [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
|
let root = fixtureBoard(SkippableDefects.board)
|
|
|
|
let cold = try BoardLoader.load(boardRoot: root, skipping: skips)
|
|
let warm = try BoardLoader.load(boardRoot: root, skipping: skips, memo: cold.memo)
|
|
|
|
#expect(warm.model == cold.model)
|
|
#expect(warm.warnings == cold.warnings)
|
|
#expect(warm.defects == cold.defects)
|
|
}
|
|
}
|