import Foundation import Testing @testable import Kanban // MARK: - Fixture builder /// A tiny in-memory-driven builder for synthetic board trees under a temp directory. Smoke /// coverage only (per 02-architecture.md § Testing) — a comprehensive golden-fixture suite /// over real trees under `Fixtures/` is a separate, later card. private struct BoardFixture { let root: URL init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("BoardLoaderTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } func tearDown() { try? FileManager.default.removeItem(at: root) } /// Writes `index.md` at `relativePath` (created if needed), with `body` after the /// frontmatter delimiters. @discardableResult func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL { let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let text = "---\n\(frontmatter)---\n\(body)" try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8) return folder } /// Creates a folder with no `index.md` — the "interrupted two-step create" shape. @discardableResult func emptyFolder(_ relativePath: String) throws -> URL { let folder = root.appendingPathComponent(relativePath, isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) return folder } /// A stray file, not a folder — never a level candidate regardless of its name. func strayFile(_ relativePath: String, contents: String = "stray") throws { let url = root.appendingPathComponent(relativePath) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) } } /// A fresh folder name with UUIDv4's shape (lowercase hex, `8-4-4-4-12`) — the only shape /// `BoardLoader` accepts as a lane/card candidate (01-storage-format.md § Fractal layout ▸ /// Rules, "Name shape gates level detection"). Used wherever a test just needs *a* valid /// lane/card identity and doesn't care about the exact value; tests that need a specific /// lexicographic ordering use literal UUID-shaped strings instead. private func uuidFolderName() -> String { UUID().uuidString.lowercased() } private func expectFailure( _ expectedReason: BoardLoadError.Reason, path: String, _ operation: () throws -> Void ) { do { try operation() Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded") } catch let error as BoardLoadError { #expect(error.path == path) #expect(error.reason == expectedReason) } catch { Issue.record("expected a BoardLoadError, got \(error)") } } // MARK: - Well-formed board struct BoardLoaderWellFormedTests { @Test func loadsCompleteStructureWithOrderingAndTombstoneFlags() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let laneA = uuidFolderName() let laneB = uuidFolderName() let cardFirst = uuidFolderName() let cardSecond = uuidFolderName() let cardDeleted = uuidFolderName() try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n") try fixture.index(laneB, "schema: 1\norder: 2048\ntitle: B Lane\n") try fixture.index(laneA, "schema: 1\norder: 1024\ntitle: A Lane\n") try fixture.index("\(laneA)/\(cardSecond)", "schema: 1\norder: 2048\ntitle: Second\n") try fixture.index("\(laneA)/\(cardFirst)", "schema: 1\norder: 1024\ntitle: First\n") try fixture.index( "\(laneA)/\(cardDeleted)", "schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n" ) let result = try BoardLoader.load(boardRoot: fixture.root) let model = result.model #expect(model.title.value == "Demo Board") #expect(model.schema == 1) if case let .mapping(pairs) = model.template { #expect(pairs.count == 1) } else { Issue.record("expected template to be a mapping, got \(String(describing: model.template))") } #expect(model.lanes.map(\.id.rawValue) == [laneA, laneB]) let lane = try #require(model.lanes.first { $0.id.rawValue == laneA }) #expect(lane.cards.map(\.id.rawValue) == [cardDeleted, cardFirst, cardSecond]) #expect(lane.cards.map(\.isDeleted) == [true, false, false]) #expect(result.warnings.isEmpty) } /// Tombstone semantics key on the `deleted` key's *presence*, not its validity /// (01-storage-format.md § Frontmatter, § Deletion): a `deleted` value with no sensible /// date reading still tombstones — the user's intent to delete outranks the broken date. @Test func malformedDeletedValueStillTombstonesLaneAndCard() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\ndeleted: yesterday\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n") let result = try BoardLoader.load(boardRoot: fixture.root) let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane }) #expect(loadedLane.deleted == .malformed(raw: "yesterday")) #expect(loadedLane.isDeleted) let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card }) #expect(loadedCard.deleted == .malformed(raw: "yesterday")) #expect(loadedCard.isDeleted) } @Test func tiesAreBrokenByFolderNameNotTitle() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } // Both UUID-shaped so both are lane candidates; chosen so their lexicographic order // ('0' < 'f') is known ahead of time. let laneFirst = "00000000-0000-4000-8000-000000000000" let laneSecond = "ffffffff-ffff-4fff-8fff-ffffffffffff" try fixture.index("", "schema: 1\n") // Same order; titles are reversed relative to folder-name order, to catch a loader // that accidentally wires title into the tie-break instead of the folder name. try fixture.index(laneSecond, "schema: 1\norder: 1024\ntitle: Should Be Second\n") try fixture.index(laneFirst, "schema: 1\norder: 1024\ntitle: Should Be First\n") let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes.map(\.id.rawValue) == [laneFirst, laneSecond]) } } // MARK: - Skip rules (UUID-shaped candidates only) struct BoardLoaderSkipTests { @Test func indexlessUUIDFolderBelowRootIsSkippedWithMissingIndexWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let orphanLane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.emptyFolder(orphanLane) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [lane]) #expect(result.warnings.contains(.missingIndex(path: orphanLane))) } @Test func indexlessUUIDCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() let orphanCard = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.emptyFolder("\(lane)/\(orphanCard)") let result = try BoardLoader.load(boardRoot: fixture.root) let loadedLane = try #require(result.model.lanes.first) #expect(loadedLane.cards.map(\.id.rawValue) == [card]) #expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(orphanCard)"))) } } // MARK: - Strays ignored (non-directories, hidden entries, symlinks) struct BoardLoaderStrayTests { @Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.strayFile("notes.txt") try fixture.strayFile(".DS_Store") try fixture.strayFile("\(lane)/notes.txt") _ = try fixture.emptyFolder(".git") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [lane]) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) #expect(result.warnings.isEmpty) } @Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() try fixture.index("", "schema: 1\n") let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n") try FileManager.default.createSymbolicLink( at: fixture.root.appendingPathComponent("linked-lane"), withDestinationURL: realLaneURL ) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.isEmpty) } } // MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout ▸ Rules, // "Name shape gates level detection") struct BoardLoaderNonUUIDStrayTests { /// A non-UUID-shaped folder is a stray even when its `index.md` is perfectly valid /// lane-shaped content — the name shape gates candidacy before the file is ever read. @Test func nonUUIDFolderWithValidLaneShapedIndexIsIgnoredWithWarningAndBoardLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") try fixture.index("todo", "schema: 1\norder: 2048\ntitle: Hand-authored lane\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo"))) } /// The motivating case: before this rule, a non-UUID folder with a broken `index.md` /// (missing required `order`) would fail-fast the whole load. Now the name shape gates it /// out as a stray before the loader ever parses the file, so the rest of the board still /// loads. @Test func nonUUIDFolderWithBrokenIndexIsIgnoredWithWarningAndBoardStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") // Missing 'order' — would be a fail-fast .missingOrder if this were UUID-shaped. try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo"))) } /// Same motivating case, one level down: a non-UUID card-depth folder with a broken /// `index.md` is a stray, not a fail-fast, and doesn't stop its lane's other cards loading. @Test func nonUUIDCardFolderWithBrokenIndexIsIgnoredWithWarningAndLaneStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let realCard = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(realCard)", "schema: 1\norder: 1024\n") try fixture.index("\(lane)/scratch", "schema: 1\n") // missing 'order' too let result = try BoardLoader.load(boardRoot: fixture.root) let loadedLane = try #require(result.model.lanes.first) #expect(loadedLane.cards.map(\.id.rawValue) == [realCard]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/scratch"))) } /// Case sensitivity: an uppercase (or mixed-case) UUID string doesn't have UUIDv4's /// *lowercase* shape, so it's a stray — folder names are never normalized. @Test func uppercaseUUIDFolderIsTreatedAsNonUUIDStray() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: uppercaseLane))) } /// Reserved card children are covered "by construction" now: `attachments/` and /// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents /// anyway (cards are leaves) — either way, they must never surface a warning. @Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png") try fixture.strayFile("\(lane)/\(card)/comments/whatever.md") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.first?.cards.map(\.id.rawValue) == [card]) #expect(result.warnings.isEmpty) } } // MARK: - Board-level deleted struct BoardLoaderBoardLevelDeletedTests { @Test func boardLevelDeletedIsIgnoredAndWarned() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.isEmpty) #expect(result.warnings.contains(.boardLevelDeletedIgnored)) } } // MARK: - Fail-fast struct BoardLoaderFailFastTests { @Test func rootMissingIndexThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } expectFailure(.boardRootMissingIndex, path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func rootThatIsAFileThrowsNotADirectory() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let fileRoot = fixture.root.appendingPathComponent("not-a-folder") try "hello".write(to: fileRoot, atomically: true, encoding: .utf8) expectFailure(.notADirectory, path: ".") { _ = try BoardLoader.load(boardRoot: fileRoot) } } @Test func unreadableRootThrows() throws { let missing = FileManager.default.temporaryDirectory .appendingPathComponent("BoardLoaderTests-does-not-exist-\(UUID().uuidString)") do { _ = try BoardLoader.load(boardRoot: missing) Issue.record("expected a BoardLoadError but load succeeded") } catch { #expect(error.path == ".") if case .unreadableRoot = error.reason { // expected } else { Issue.record("expected .unreadableRoot, got \(error.reason)") } } } @Test func unparseableYAMLThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } // No closing delimiter. try "---\nschema: 1\n".write( to: fixture.root.appendingPathComponent("index.md"), atomically: true, encoding: .utf8 ) do { _ = try BoardLoader.load(boardRoot: fixture.root) Issue.record("expected a BoardLoadError but load succeeded") } catch { #expect(error.path == "index.md") if case .unparseableYAML = error.reason { // expected } else { Issue.record("expected .unparseableYAML, got \(error.reason)") } } } @Test func missingSchemaThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "title: No Schema\n") expectFailure(.missingSchema, path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func malformedSchemaThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "schema: not-a-number\n") expectFailure(.malformedSchema(raw: "not-a-number"), path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func schemaNewerThanAppThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "schema: 2\n") expectFailure(.schemaNewerThanApp(found: 2), path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } /// A UUID-shaped folder still fails fast on structurally-bad content — the name shape only /// gates *candidacy*, never the validity of a folder that qualifies. @Test func missingOrderOnUUIDLaneThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\n") expectFailure(.missingOrder, path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func malformedOrderOnUUIDLaneThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: not-a-number\n") expectFailure(.malformedOrder(raw: "not-a-number"), path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func missingOrderOnUUIDCardThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\n") expectFailure(.missingOrder, path: "\(lane)/\(card)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } } // MARK: - Encoding strictness /// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding /// contract (01-storage-format.md § Fractal layout ▸ Rules) actually holds at load time: a /// BOM'd file fails the frontmatter delimiter, a non-UTF-8 file is named as such — the same /// strict decode `BoardWriter` uses, so a file can never load here and then refuse every write. struct BoardLoaderEncodingTests { @Test func bomPrefixedBoardIndexIsRejected() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } var bytes = Data([0xEF, 0xBB, 0xBF]) bytes.append(Data("---\nschema: 1\n---\nbody\n".utf8)) try bytes.write(to: fixture.root.appendingPathComponent("index.md")) #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in guard let loadError = error as? BoardLoadError, case .unparseableYAML = loadError.reason else { return false } return loadError.path == "index.md" } } @Test func nonUTF8BoardIndexIsRejected() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } // "café" in ISO-8859-1 — 0xE9 is not valid UTF-8. var bytes = Data("---\nschema: 1\ntitle: caf".utf8) bytes.append(0xE9) bytes.append(Data("\n---\n".utf8)) try bytes.write(to: fixture.root.appendingPathComponent("index.md")) #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in guard let loadError = error as? BoardLoadError, case let .unparseableYAML(message, _) = loadError.reason else { return false } return message == "file is not UTF-8" } } }