Design-review resolutions: schema-owned display fields coerce where a sensible reading exists (wrong-type scalars read as source text, width accepts exact-integer strings/doubles) and default where none does; null reads as missing; duplicate keys last-one-wins and inline-comment re-splicing recorded in the design (engine change follows). Strict schema/order fail-fast unchanged. 85 tests green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
360 lines
13 KiB
Swift
360 lines
13 KiB
Swift
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)
|
|
}
|
|
|
|
/// 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() }
|
|
|
|
try fixture.index("", "schema: 1\n")
|
|
try fixture.index("lane-1", "schema: 1\norder: 1024\ndeleted: yesterday\n")
|
|
try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\ndeleted: yesterday\n")
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
|
let lane = try #require(result.model.lanes.first { $0.id.rawValue == "lane-1" })
|
|
#expect(lane.deleted == .malformed(raw: "yesterday"))
|
|
#expect(lane.isDeleted)
|
|
|
|
let card = try #require(lane.cards.first { $0.id.rawValue == "card-1" })
|
|
#expect(card.deleted == .malformed(raw: "yesterday"))
|
|
#expect(card.isDeleted)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
}
|