From 95418a26709922e2d6b9fe82cc12512f65381a67 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 15:40:46 -0400 Subject: [PATCH] =?UTF-8?q?Build=20BoardLoader=20=E2=80=94=20fail-fast=20f?= =?UTF-8?q?ractal=20tree=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure tree walk: root → lanes → cards, level is position. Fail-fast with path+reason for bad YAML, missing/malformed schema or order, newer schema, rootless board; index-less folders skip with a collected+logged warning; strays, hidden files, and symlinks ignored; board-level deleted ignored with a warning; tombstones loaded and flagged. 17 tests. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/BoardLoader.swift | 323 +++++++++++++++++++++++++++ KanbanTests/BoardLoaderTests.swift | 338 +++++++++++++++++++++++++++++ 2 files changed, 661 insertions(+) create mode 100644 Kanban/Storage/BoardLoader.swift create mode 100644 KanbanTests/BoardLoaderTests.swift diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift new file mode 100644 index 0000000..01124e4 --- /dev/null +++ b/Kanban/Storage/BoardLoader.swift @@ -0,0 +1,323 @@ +import Foundation +import os + +/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure +/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal +/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input) +/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent +/// partial result. +/// +/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders → +/// cards. Any non-reserved directory containing `index.md` at those depths is a level +/// regardless of its name — no UUID-shape filtering, no name-based gating. +/// +/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card* +/// (01-storage-format.md § Fractal layout ▸ Rules); since cards are leaves here — this loader +/// never scans a card folder's contents beyond checking for `index.md` — that reservation is +/// satisfied by construction and needs no explicit filtering. +/// +/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never +/// followed, whether it points to a file or a directory — this loader does not resolve +/// cross-volume or cyclic trees. +public enum BoardLoader: Sendable { + + /// Schema version this app understands; anything higher fails fast + /// (01-storage-format.md § Malformed input). `fileprivate` rather than `private`: also + /// read by `BoardLoadError.Reason.description` below, in this same file. + fileprivate static let supportedSchema = 1 + + /// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense + /// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather + /// than through a typed `FrontmatterDocument` accessor. + private static let templateKey = "template" + + private static let indexFileName = "index.md" + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader") + + // MARK: - Entry point + + public static func load(boardRoot: URL) throws(BoardLoadError) -> LoadResult { + try checkIsReadableDirectory(boardRoot) + + let boardIndexURL = boardRoot.appendingPathComponent(indexFileName) + guard FileManager.default.fileExists(atPath: boardIndexURL.path) else { + throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex) + } + let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName) + let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName) + + var warnings: [LoadWarning] = [] + func warn(_ warning: LoadWarning) { + warnings.append(warning) + logger.warning("\(warning.description, privacy: .public)") + } + + // Legal per the frontmatter table, meaningless at board level — ignore and log, never + // tombstone (01-storage-format.md § Deletion). + if !boardDocument.deleted.isMissing { + warn(.boardLevelDeletedIgnored) + } + + var lanes: [Lane] = [] + for laneURL in try directoryCandidates(in: boardRoot) { + let laneName = laneURL.lastPathComponent + guard hasIndex(laneURL) else { + warn(.missingIndex(path: laneName)) + continue + } + + let lanePath = laneName + "/" + indexFileName + let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath) + let laneSchema = try validatedSchema(in: laneDocument, path: lanePath) + let laneOrder = try validatedOrder(in: laneDocument, path: lanePath) + + var cards: [Card] = [] + for cardURL in try directoryCandidates(in: laneURL) { + let cardName = cardURL.lastPathComponent + let cardRelPath = laneName + "/" + cardName + guard hasIndex(cardURL) else { + warn(.missingIndex(path: cardRelPath)) + continue + } + + let cardPath = cardRelPath + "/" + indexFileName + let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath) + let cardSchema = try validatedSchema(in: cardDocument, path: cardPath) + let cardOrder = try validatedOrder(in: cardDocument, path: cardPath) + + cards.append(Card( + id: ItemID(rawValue: cardName), + schema: cardSchema, + title: cardDocument.title, + created: cardDocument.created, + modified: cardDocument.modified, + modifiedBy: cardDocument.modifiedBy, + deleted: cardDocument.deleted, + background: cardDocument.background, + icon: cardDocument.icon, + iconColor: cardDocument.iconColor, + order: cardOrder, + document: cardDocument + )) + } + + lanes.append(Lane( + id: ItemID(rawValue: laneName), + schema: laneSchema, + title: laneDocument.title, + created: laneDocument.created, + modified: laneDocument.modified, + modifiedBy: laneDocument.modifiedBy, + deleted: laneDocument.deleted, + background: laneDocument.background, + icon: laneDocument.icon, + iconColor: laneDocument.iconColor, + order: laneOrder, + width: laneDocument.width, + cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }), + document: laneDocument + )) + } + + let model = BoardModel( + rootURL: boardRoot, + schema: boardSchema, + title: boardDocument.title, + created: boardDocument.created, + modified: boardDocument.modified, + modifiedBy: boardDocument.modifiedBy, + deleted: boardDocument.deleted, + background: boardDocument.background, + icon: boardDocument.icon, + iconColor: boardDocument.iconColor, + template: boardDocument.value(for: templateKey), + lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }), + document: boardDocument + ) + + return LoadResult(model: model, warnings: warnings) + } + + // MARK: - Filesystem helpers + + private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory")) + } + guard isDirectory.boolValue else { + throw BoardLoadError(path: ".", reason: .notADirectory) + } + } + + private static func hasIndex(_ folder: URL) -> Bool { + FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path) + } + + /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding + /// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray + /// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded + /// here too: only directories are level candidates. + /// + /// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no + /// candidates" rather than failing the whole load — fail-fast is reserved for the board + /// root and for malformed `index.md` content, not transient directory-listing races below + /// it. + private static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: folder, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return entries + .filter { url in + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { + return false + } + return values.isDirectory == true && values.isSymbolicLink != true + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + // MARK: - Document reading + field validation + + private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument { + let text: String + do { + text = try String(contentsOf: url, encoding: .utf8) + } catch { + throw BoardLoadError( + path: path, + reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil) + ) + } + + do { + return try FrontmatterDocument.parse(text) + } catch { + let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil } + throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line)) + } + } + + private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int { + switch document.schema { + case .missing: + throw BoardLoadError(path: path, reason: .missingSchema) + case let .malformed(raw): + throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw)) + case let .valid(value): + guard value <= supportedSchema else { + throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value)) + } + return value + } + } + + private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double { + switch document.order { + case .missing: + throw BoardLoadError(path: path, reason: .missingOrder) + case let .malformed(raw): + throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw)) + case let .valid(value): + return value + } + } +} + +// MARK: - Result + +/// A successful load: the snapshot plus anything tolerated-but-notable encountered along the +/// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban", +/// category: "loader")`) so it shows up in Console even if a caller never inspects it. +public struct LoadResult: Sendable { + public var model: BoardModel + public var warnings: [LoadWarning] +} + +/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError` +/// for what does. +public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { + /// A folder below the board root has no `index.md` — skipped, not fail-fast (an + /// interrupted two-step create must not brick the board). `path` is relative to the board + /// root. + case missingIndex(path: String) + + /// A board-level `deleted:` key is legal per the frontmatter table but meaningless + /// (01-storage-format.md § Deletion) — ignored, never tombstones the board. + case boardLevelDeletedIgnored + + public var description: String { + switch self { + case let .missingIndex(path): + "\(path): folder has no index.md, skipped" + case .boardLevelDeletedIgnored: + "index.md: board-level 'deleted' key is meaningless, ignored" + } + } +} + +// MARK: - Error + +/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to +/// the board root where one exists) plus `reason` says exactly what's wrong. No partial +/// loads: throwing this means `BoardLoader.load` produced nothing at all. +public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible { + public let path: String + public let reason: Reason + + public var description: String { "\(path): \(reason.description)" } + + public enum Reason: Sendable, Equatable, CustomStringConvertible { + /// The board root itself has no `index.md` — unlike every level below it, this is not + /// skip-and-warn: there is no board without one. + case boardRootMissingIndex + /// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a + /// frontmatter block that isn't a mapping. `line` is 1-based within the file when the + /// underlying error carries one. + case unparseableYAML(message: String, line: Int?) + case missingSchema + case malformedSchema(raw: String) + /// `schema` is present, valid, and greater than this app's `supportedSchema`. + case schemaNewerThanApp(found: Int) + /// `order` is required on lanes and cards, never on the board itself. + case missingOrder + case malformedOrder(raw: String) + /// The board root exists but is a file, not a directory. + case notADirectory + /// The board root doesn't exist, or its contents couldn't be listed. + case unreadableRoot(message: String) + + public var description: String { + switch self { + case .boardRootMissingIndex: + "board root is missing index.md" + case let .unparseableYAML(message, line): + if let line { + "unparseable YAML at line \(line): \(message)" + } else { + "unparseable YAML: \(message)" + } + case .missingSchema: + "missing required 'schema' field" + case let .malformedSchema(raw): + "malformed 'schema' field: \(raw)" + case let .schemaNewerThanApp(found): + "schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))" + case .missingOrder: + "missing required 'order' field" + case let .malformedOrder(raw): + "malformed 'order' field: \(raw)" + case .notADirectory: + "board root is not a directory" + case let .unreadableRoot(message): + "board root is unreadable: \(message)" + } + } + } +} diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift new file mode 100644 index 0000000..23b076c --- /dev/null +++ b/KanbanTests/BoardLoaderTests.swift @@ -0,0 +1,338 @@ +import Foundation +import Testing +@testable import Kanban + +// MARK: - Fixture builder + +/// A tiny in-memory-driven builder for synthetic board trees under a temp directory. Smoke +/// coverage only (per 02-architecture.md § Testing) — a comprehensive golden-fixture suite +/// over real trees under `Fixtures/` is a separate, later card. +private struct BoardFixture { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardLoaderTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func tearDown() { + try? FileManager.default.removeItem(at: root) + } + + /// Writes `index.md` at `relativePath` (created if needed), with `body` after the + /// frontmatter delimiters. + @discardableResult + func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL { + let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let text = "---\n\(frontmatter)---\n\(body)" + try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8) + return folder + } + + /// Creates a folder with no `index.md` — the "interrupted two-step create" shape. + @discardableResult + func emptyFolder(_ relativePath: String) throws -> URL { + let folder = root.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + return folder + } + + /// A stray file, not a folder — never a level candidate regardless of its name. + func strayFile(_ relativePath: String, contents: String = "stray") throws { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + } +} + +private func expectFailure( + _ expectedReason: BoardLoadError.Reason, + path: String, + _ operation: () throws -> Void +) { + do { + try operation() + Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded") + } catch let error as BoardLoadError { + #expect(error.path == path) + #expect(error.reason == expectedReason) + } catch { + Issue.record("expected a BoardLoadError, got \(error)") + } +} + +// MARK: - Well-formed board + +struct BoardLoaderWellFormedTests { + @Test func loadsCompleteStructureWithOrderingAndTombstoneFlags() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n") + try fixture.index("b-lane", "schema: 1\norder: 2048\ntitle: B Lane\n") + try fixture.index("a-lane", "schema: 1\norder: 1024\ntitle: A Lane\n") + + try fixture.index("a-lane/card-2", "schema: 1\norder: 2048\ntitle: Second\n") + try fixture.index("a-lane/card-1", "schema: 1\norder: 1024\ntitle: First\n") + try fixture.index( + "a-lane/card-deleted", + "schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n" + ) + + let result = try BoardLoader.load(boardRoot: fixture.root) + let model = result.model + + #expect(model.title.value == "Demo Board") + #expect(model.schema == 1) + if case let .mapping(pairs) = model.template { + #expect(pairs.count == 1) + } else { + Issue.record("expected template to be a mapping, got \(String(describing: model.template))") + } + + #expect(model.lanes.map(\.id.rawValue) == ["a-lane", "b-lane"]) + + let laneA = try #require(model.lanes.first { $0.id.rawValue == "a-lane" }) + #expect(laneA.cards.map(\.id.rawValue) == ["card-deleted", "card-1", "card-2"]) + #expect(laneA.cards.map(\.isDeleted) == [true, false, false]) + #expect(result.warnings.isEmpty) + } + + @Test func tiesAreBrokenByFolderNameNotTitle() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + // Same order; titles are reversed relative to folder-name order, to catch a loader + // that accidentally wires title into the tie-break instead of the folder name. + try fixture.index("zzz-lane", "schema: 1\norder: 1024\ntitle: Should Be Second\n") + try fixture.index("aaa-lane", "schema: 1\norder: 1024\ntitle: Should Be First\n") + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes.map(\.id.rawValue) == ["aaa-lane", "zzz-lane"]) + } +} + +// MARK: - Skip rules + +struct BoardLoaderSkipTests { + @Test func indexlessFolderBelowRootIsSkippedWithWarning() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\norder: 1024\n") + try fixture.emptyFolder("orphan-lane") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"]) + #expect(result.warnings.contains(.missingIndex(path: "orphan-lane"))) + } + + @Test func indexlessCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\norder: 1024\n") + try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n") + try fixture.emptyFolder("lane-1/orphan-card") + + let result = try BoardLoader.load(boardRoot: fixture.root) + let lane = try #require(result.model.lanes.first) + #expect(lane.cards.map(\.id.rawValue) == ["card-1"]) + #expect(result.warnings.contains(.missingIndex(path: "lane-1/orphan-card"))) + } +} + +// MARK: - Strays ignored + +struct BoardLoaderStrayTests { + @Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\norder: 1024\n") + try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n") + + try fixture.strayFile("notes.txt") + try fixture.strayFile(".DS_Store") + try fixture.strayFile("lane-1/notes.txt") + _ = try fixture.emptyFolder(".git") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == ["card-1"]) + #expect(result.warnings.isEmpty) + } + + @Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + let realLane = try fixture.index("real-lane", "schema: 1\norder: 1024\n") + try FileManager.default.createSymbolicLink( + at: fixture.root.appendingPathComponent("linked-lane"), + withDestinationURL: realLane + ) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == ["real-lane"]) + #expect(result.warnings.isEmpty) + } +} + +// MARK: - Board-level deleted + +struct BoardLoaderBoardLevelDeletedTests { + @Test func boardLevelDeletedIsIgnoredAndWarned() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.isEmpty) + #expect(result.warnings.contains(.boardLevelDeletedIgnored)) + } +} + +// MARK: - Fail-fast + +struct BoardLoaderFailFastTests { + @Test func rootMissingIndexThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + expectFailure(.boardRootMissingIndex, path: "index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } + + @Test func rootThatIsAFileThrowsNotADirectory() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let fileRoot = fixture.root.appendingPathComponent("not-a-folder") + try "hello".write(to: fileRoot, atomically: true, encoding: .utf8) + + expectFailure(.notADirectory, path: ".") { + _ = try BoardLoader.load(boardRoot: fileRoot) + } + } + + @Test func unreadableRootThrows() throws { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardLoaderTests-does-not-exist-\(UUID().uuidString)") + + do { + _ = try BoardLoader.load(boardRoot: missing) + Issue.record("expected a BoardLoadError but load succeeded") + } catch { + #expect(error.path == ".") + if case .unreadableRoot = error.reason { + // expected + } else { + Issue.record("expected .unreadableRoot, got \(error.reason)") + } + } + } + + @Test func unparseableYAMLThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + // No closing delimiter. + try "---\nschema: 1\n".write( + to: fixture.root.appendingPathComponent("index.md"), + atomically: true, + encoding: .utf8 + ) + + do { + _ = try BoardLoader.load(boardRoot: fixture.root) + Issue.record("expected a BoardLoadError but load succeeded") + } catch { + #expect(error.path == "index.md") + if case .unparseableYAML = error.reason { + // expected + } else { + Issue.record("expected .unparseableYAML, got \(error.reason)") + } + } + } + + @Test func missingSchemaThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "title: No Schema\n") + + expectFailure(.missingSchema, path: "index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } + + @Test func malformedSchemaThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: not-a-number\n") + + expectFailure(.malformedSchema(raw: "not-a-number"), path: "index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } + + @Test func schemaNewerThanAppThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 2\n") + + expectFailure(.schemaNewerThanApp(found: 2), path: "index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } + + @Test func missingOrderOnLaneThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\n") + + expectFailure(.missingOrder, path: "lane-1/index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } + + @Test func malformedOrderOnLaneThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\norder: not-a-number\n") + + expectFailure(.malformedOrder(raw: "not-a-number"), path: "lane-1/index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } + + @Test func missingOrderOnCardThrows() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\norder: 1024\n") + try fixture.index("lane-1/card-1", "schema: 1\n") + + expectFailure(.missingOrder, path: "lane-1/card-1/index.md") { + _ = try BoardLoader.load(boardRoot: fixture.root) + } + } +}