import Foundation import Testing @testable import Kanban // MARK: - Fixtures /// A temp directory holding hand-written `index.md` files, written and read back as raw bytes /// so every assertion here is about what is actually on disk — the writer's whole contract /// (02-architecture.md § Layering ▸ Components, "a write is done when the file is on disk"). private struct WriterFixture { let root: URL init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("BoardWriterTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } /// Restores permissions before removing: the atomicity test deliberately makes a folder /// unwritable, and an unwritable folder is also an unremovable one. func tearDown() { let manager = FileManager.default if let walker = manager.enumerator(atPath: root.path) { for case let relative as String in walker { try? manager.setAttributes( [.posixPermissions: 0o755], ofItemAtPath: root.appendingPathComponent(relative).path ) } } try? manager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path) try? manager.removeItem(at: root) } func url(_ relativePath: String) -> URL { relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) } /// Writes `text` verbatim (BOM-less UTF-8, line endings exactly as given) to /// `/index.md`. @discardableResult func item(_ relativePath: String, _ text: String) throws -> URL { try write(Data(text.utf8), to: relativePath) } @discardableResult func item(_ relativePath: String, bytes: Data) throws -> URL { try write(bytes, to: relativePath) } @discardableResult private func write(_ data: Data, to relativePath: String) throws -> URL { let folder = url(relativePath) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try data.write(to: folder.appendingPathComponent("index.md")) return folder } func indexData(_ relativePath: String) throws -> Data { try Data(contentsOf: url(relativePath).appendingPathComponent("index.md")) } func indexText(_ relativePath: String) throws -> String { try String(decoding: indexData(relativePath), as: UTF8.self) } /// Every entry in the folder, hidden ones included — the writer's temp files are hidden, so /// only a listing that sees them can prove there is no residue. func entryNames(_ relativePath: String) throws -> [String] { try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted() } } private enum Fixture { /// Unknown keys in a deliberate order, an own-line comment above and below, an inline /// comment, a `modified-by` stamp, and a body — everything one `title` rewrite must leave /// exactly as it found it. static let rich = """ --- # hand-written header schema: 1 title: Original order: 1024 project: lanework # agent overlay sphere: work labels: [a, b, c] created: 2026-07-26T16:41:38Z modified: 2026-07-26T19:06:55Z modified-by: claude # trailing note --- Body text. More body — with *markdown*. """ static let minimal = """ --- schema: 1 order: 1024 title: Thing --- Body """ /// The whole frontmatter as one flow mapping: reads fine, has no line per key to rewrite. static let flowMapping = "---\n{schema: 1, order: 1024}\n---\nbody\n" /// YAML's explicit-key syntax — the key is a sequence, not a name the editor can match. static let nonScalarKey = "---\n? [a, b]\n: value\nschema: 1\n---\nbody\n" } /// Literal UUID-shaped folder names, so the display-order tie-break is pinned rather than /// accidental (`Ranks.isOrderedForDisplay`). private enum Child { static let a = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let b = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" static let c = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" static let deleted = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" static let indexless = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" } private func writeFailure(_ operation: () throws -> Void) -> BoardWriteError? { do { try operation() Issue.record("expected the write to fail, but it succeeded") return nil } catch let error as BoardWriteError { return error } catch { Issue.record("expected a BoardWriteError, got \(error)") return nil } } /// The file's lines minus every line that opens one of `keys` — what a rewrite of exactly those /// keys has to leave byte-identical. private func lines(of text: String, excludingKeys keys: [String]) -> [String] { text.components(separatedBy: "\n") .filter { line in !keys.contains { line.hasPrefix("\($0):") } } } // MARK: - Preservation struct BoardWriterPreservationTests { @Test func aTitleEditTouchesOnlyTheTitleAndTheStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let after = try fixture.indexText("card") let stamped = [FrontmatterKeys.title, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy] #expect(lines(of: after, excludingKeys: stamped) == lines(of: Fixture.rich, excludingKeys: stamped)) #expect(after.contains("title: Renamed\n")) let document = try FrontmatterDocument.parse(after) #expect(document.unknownFields.map(\.key) == ["project", "sphere", "labels"]) #expect(document.keys == [ "schema", "title", "order", "project", "sphere", "labels", "created", "modified", ]) #expect(document.rawValue(for: "labels") == "[a, b, c]") #expect(after.hasPrefix("---\n# hand-written header\nschema: 1\n")) #expect(after.contains("project: lanework # agent overlay\n")) #expect(after.contains("\n# trailing note\n---\n")) } @Test func aFrontmatterEditLeavesTheBodyByteIdentical() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) let body = Data("Body text.\n\nMore body — with *markdown*.\n".utf8) try BoardWriter.updateIndex(inItemFolder: folder, operation: "reorder card") { document in document.set(FrontmatterKeys.order, to: .double(2048)) } let after = try fixture.indexData("card") #expect(after.suffix(body.count) == body) #expect(try FrontmatterDocument.parse(fixture.indexText("card")).order == .valid(2048)) } @Test func aSuccessfulWriteLeavesNoTempFileBehind() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } #expect(try fixture.entryNames("card") == ["index.md"]) } /// The engine's per-line ending guarantee (01-storage-format.md § Fractal layout ▸ Rules), /// verified end to end through the writer: a CRLF file stays CRLF. @Test func aCRLFFileKeepsCRLFOnEveryRewrittenLine() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let text = "---\r\nschema: 1\r\norder: 1024\r\ntitle: Thing\r\n" + "modified: 2026-01-01T00:00:00Z\r\nmodified-by: claude\r\n---\r\nbody\r\n" let folder = try fixture.item("card", text) try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let after = try fixture.indexText("card") #expect(after.contains("title: Renamed\r\n")) #expect(after.contains("modified: ") && !after.contains("2026-01-01T00:00:00Z")) #expect(!after.contains("modified-by")) #expect(after.hasSuffix("---\r\nbody\r\n")) // No bare LF survives anywhere once the CRLF pairs are taken out. #expect(!after.replacingOccurrences(of: "\r\n", with: "").contains("\n")) } } // MARK: - Stamps struct BoardWriterStampTests { @Test func modifiedIsStampedAndModifiedByIsCleared() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) #expect(try FrontmatterDocument.parse(fixture.indexText("card")).modifiedBy == .valid("claude")) try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let after = try fixture.indexText("card") #expect(!after.contains("modified-by")) let document = try FrontmatterDocument.parse(after) #expect(document.modifiedBy == .missing) let modified = try #require(document.modified.value) #expect(abs(modified.timeIntervalSinceNow) < 60) } /// The stamps run *after* the caller's closure, so they always win — a call site cannot /// leave a stale `modified` or a re-added `modified-by` behind. @Test func theStampsOutrankWhatTheEditsClosureDid() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.modified, to: .date(Date(timeIntervalSince1970: 0))) document.set(FrontmatterKeys.modifiedBy, to: .string("claude")) } let document = try FrontmatterDocument.parse(fixture.indexText("card")) #expect(document.modifiedBy == .missing) let modified = try #require(document.modified.value) #expect(abs(modified.timeIntervalSinceNow) < 60) } /// A file with no `modified` at all gains one — appended before the closing delimiter. @Test func aFileWithoutAModifiedKeyGainsOne() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let document = try FrontmatterDocument.parse(fixture.indexText("card")) #expect(document.keys == ["schema", "order", "title", "modified"]) #expect(document.modified.value != nil) #expect(document.body == "Body\n") } } // MARK: - Readable but uneditable /// 01-storage-format.md § Frontmatter, settled: a frontmatter shape the surgical editor cannot /// key by spans loads and renders normally, and every app write to it refuses loudly. struct BoardWriterUneditableTests { @Test func ordinaryDocumentsAreEditable() throws { #expect(try FrontmatterDocument.parse(Fixture.rich).uneditableShape == nil) #expect(try FrontmatterDocument.parse(Fixture.minimal).uneditableShape == nil) #expect(try FrontmatterDocument.parse("---\nflow: {a: 1,\nb: 2}\nlast: x\n---\nbody\n").uneditableShape == nil) #expect(FrontmatterDocument(body: "new").uneditableShape == nil) } @Test func aWholeFrontmatterFlowMappingReadsButRefusesWrites() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.flowMapping) let document = try FrontmatterDocument.parse(Fixture.flowMapping) #expect(document.uneditableShape == .keyWithoutOwnLine) #expect(document.schema == .valid(1)) #expect(document.order == .valid(1024)) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(error?.operation == "rename card") #expect(error?.path.hasSuffix("card/index.md") == true) #expect(error?.description.contains("rename card") == true) #expect(try fixture.indexText("card") == Fixture.flowMapping) #expect(try fixture.entryNames("card") == ["index.md"]) } @Test func aNonScalarKeyReadsButRefusesWrites() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.nonScalarKey) #expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).uneditableShape == .nonScalarKey) #expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } #expect(error?.reason == .uneditableFrontmatter(.nonScalarKey)) #expect(try fixture.indexText("card") == Fixture.nonScalarKey) } /// A key whose spacing the span matcher cannot key on lands in the same refusal — the /// detection is conservative on purpose. @Test func aKeyWithSpaceBeforeItsColonIsUneditable() throws { #expect(try FrontmatterDocument.parse("---\nschema : 1\n---\nbody\n").uneditableShape == .keyWithoutOwnLine) } } // MARK: - Failure paths struct BoardWriterFailureTests { @Test func aFileThatIsNotUTF8IsALoudErrorAndIsLeftAlone() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let latin1 = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1)) let folder = try fixture.item("card", bytes: latin1) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UTF-8")) #expect(try fixture.indexData("card") == latin1) #expect(try fixture.entryNames("card") == ["index.md"]) } @Test func aMissingIndexIsALoudError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = fixture.url("card") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { _ in } } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(try fixture.entryNames("card") == []) } @Test func unparseableFrontmatterIsALoudError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let text = "---\nschema: 1\n bad: indent\n---\nbody\n" let folder = try fixture.item("card", text) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { _ in } } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(try fixture.indexText("card") == text) } /// The atomicity guarantee under an I/O failure: the temp file cannot be created, so the /// destination still holds its previous bytes and nothing is left lying around. @Test func aFailedWriteLeavesTheFileAndTheFolderExactlyAsTheyWere() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) let before = try fixture.indexData("card") try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) } let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } guard case .io = error?.reason else { Issue.record("expected .io, got \(String(describing: error?.reason))") return } #expect(error?.operation == "rename card") #expect(error?.path.hasSuffix("card/index.md") == true) #expect(try fixture.indexData("card") == before) #expect(try fixture.entryNames("card") == ["index.md"]) } } // MARK: - Renumber /// 01-storage-format.md § Ordering: the renumber fallback — the sole exception to /// "a reorder rewrites only the moved item's index.md". struct BoardWriterRenumberTests { /// Crowded orders whose display sequence disagrees with folder-name order, so the assignment /// is provably by `order` first and name only as tie-break. private func crowdedLane(_ fixture: WriterFixture) throws -> URL { try fixture.item("lane/\(Child.a)", child(order: "1.0000003", title: "A")) try fixture.item("lane/\(Child.b)", child(order: "1.0000001", title: "B")) try fixture.item("lane/\(Child.c)", child(order: "1.0000002", title: "C")) try fixture.item( "lane/\(Child.deleted)", "---\nschema: 1\norder: 0.5\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\nmodified-by: claude\n---\nbody\n" ) // Skipped for the same reasons BoardLoader skips them. try fixture.item("lane/notes", "---\nschema: 1\norder: 1\ntitle: Stray\n---\nbody\n") try FileManager.default.createDirectory( at: fixture.url("lane/\(Child.indexless)"), withIntermediateDirectories: true ) return fixture.url("lane") } private func child(order: String, title: String) -> String { """ --- schema: 1 order: \(order) title: \(title) project: lanework # agent overlay modified-by: claude --- \(title) body """ } @Test func visibleChildrenAreRenumberedInDisplayOrder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let lane = try crowdedLane(fixture) try BoardWriter.renumberVisibleChildren(of: lane) let orders = try [Child.b, Child.c, Child.a].map { try FrontmatterDocument.parse(fixture.indexText("lane/\($0)")).order } #expect(orders == [.valid(1024), .valid(2048), .valid(3072)]) #expect(try fixture.indexText("lane/\(Child.b)").contains("order: 1024\n")) } @Test func eachRewrittenChildIsStampedAndKeepsItsUnknownKeys() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let lane = try crowdedLane(fixture) try BoardWriter.renumberVisibleChildren(of: lane) for name in [Child.a, Child.b, Child.c] { let text = try fixture.indexText("lane/\(name)") let document = try FrontmatterDocument.parse(text) #expect(document.modifiedBy == .missing) #expect(document.modified.value != nil) #expect(document.unknownFields.map(\.key) == ["project"]) #expect(text.contains("project: lanework # agent overlay\n")) #expect(document.body.hasSuffix(" body\n")) #expect(try fixture.entryNames("lane/\(name)") == ["index.md"]) } } @Test func tombstonedAndStrayChildrenAreUntouched() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let lane = try crowdedLane(fixture) let tombstone = try fixture.indexData("lane/\(Child.deleted)") let stray = try fixture.indexData("lane/notes") try BoardWriter.renumberVisibleChildren(of: lane) #expect(try fixture.indexData("lane/\(Child.deleted)") == tombstone) #expect(try fixture.indexData("lane/notes") == stray) } /// A renumber runs over loaded, valid children: one broken sibling fails the whole /// operation, and it fails before anything has been rewritten. @Test func aChildWithAMalformedOrderFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: banana\ntitle: B\n---\nbody\n") let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) #expect(error?.path.contains(Child.b) == true) #expect(error?.operation == "renumber children") #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @Test func aChildWithNoOrderFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) try fixture.item("lane/\(Child.b)", "---\nschema: 1\ntitle: B\n---\nbody\n") let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } #expect(error?.reason == .unreadable(message: "missing required 'order' field")) #expect(error?.path.contains(Child.b) == true) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @Test func aChildWithUneditableFrontmatterFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) try fixture.item("lane/\(Child.b)", Fixture.flowMapping) let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @Test func renumberingAnEmptyParentIsANoOp() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try FileManager.default.createDirectory(at: fixture.url("lane"), withIntermediateDirectories: true) try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) #expect(try fixture.entryNames("lane") == []) } } // MARK: - Loader integration struct BoardWriterLoaderIntegrationTests { /// The temp files are dot-prefixed precisely so `BoardLoader`'s `.skipsHiddenFiles` never /// sees a crashed write's residue — not as a stray warning, and not as a candidate. @Test func leftoverTempFilesAreInvisibleToTheLoader() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n") for folder in ["", Child.a] { let residue = fixture.url(folder).appendingPathComponent(".index.md.lanework-\(UUID().uuidString)") try Data("half-written\n".utf8).write(to: residue) } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings.isEmpty) #expect(result.model.lanes.map(\.id.rawValue) == [Child.a]) } /// A board written by the writer reloads to the same values it was given. @Test func aWrittenBoardReloadsCleanly() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") let lane = try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n") try fixture.item("\(Child.a)/\(Child.b)", "---\nschema: 1\norder: 1024\ntitle: Card\n---\nbody\n") try BoardWriter.updateIndex(inItemFolder: lane, operation: "rename lane") { document in document.set(FrontmatterKeys.title, to: .string("Doing")) } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings.isEmpty) #expect(result.model.lanes.first?.title == .valid("Doing")) #expect(result.model.lanes.first?.modifiedBy == .missing) #expect(result.model.lanes.first?.modified.value != nil) #expect(result.model.lanes.first?.cards.first?.title == .valid("Card")) } } // MARK: - Create board /// `BoardWriter.createBoard`: the folder (if missing) plus a freshly minted `index.md` — never /// a rewrite, so these tests check what a brand-new file looks like rather than what a rewrite /// preserved. struct BoardWriterCreateBoardTests { @Test func createsAFolderAndAValidIndexTheLoaderOpens() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("MyBoard.kanban") try BoardWriter.createBoard(at: root, title: "My Board") let result = try BoardLoader.load(boardRoot: root) #expect(result.warnings.isEmpty) #expect(result.model.schema == 1) #expect(result.model.title == .valid("My Board")) #expect(result.model.modifiedBy == .missing) let created = try #require(result.model.created.value) let modified = try #require(result.model.modified.value) #expect(abs(created.timeIntervalSinceNow) < 60) #expect(abs(modified.timeIntervalSinceNow) < 60) } @Test func keyOrderIsSchemaTitleCreatedModified() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("MyBoard.kanban") try BoardWriter.createBoard(at: root, title: "My Board") let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban")) #expect(document.keys == ["schema", "title", "created", "modified"]) } @Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("Untitled.kanban") try BoardWriter.createBoard(at: root, title: nil) let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban")) #expect(document.keys == ["schema", "created", "modified"]) #expect(!document.contains(FrontmatterKeys.title)) let result = try BoardLoader.load(boardRoot: root) #expect(result.model.title == .missing) } @Test func refusesToClobberAnExistingBoard() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let existing = "---\nschema: 1\ntitle: Existing\n---\nbody\n" let root = try fixture.item("Existing.kanban", existing) let error = writeFailure { try BoardWriter.createBoard(at: root, title: "New") } guard case .io = error?.reason else { Issue.record("expected .io, got \(String(describing: error?.reason))") return } #expect(error?.path.hasSuffix("Existing.kanban/index.md") == true) #expect(try fixture.indexText("Existing.kanban") == existing) } @Test func anExtensionLessBoardFolderIsCreatedAndOpensSuccessfully() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("PlainBoard") try BoardWriter.createBoard(at: root, title: "Plain") let result = try BoardLoader.load(boardRoot: root) #expect(result.warnings.isEmpty) #expect(result.model.title == .valid("Plain")) } @Test func createdEqualsModifiedAtCreationTime() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("Board.kanban") try BoardWriter.createBoard(at: root, title: "Board") let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban")) #expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified)) } } // MARK: - Create lane / card /// `BoardWriter.createLane`/`createCard`: minting a fresh identity and placing it after the /// current visible siblings (01-storage-format.md § Ordering). `fixture.root` doubles as the /// board root for lane creation — `createChild` never reads the parent's own `index.md`, only /// its children, so a bare directory is as good a board root as a fully-formed one for these /// tests. struct BoardWriterCreateChildTests { @Test func mintsLowercaseUUIDShapedNamesDistinctAcrossCalls() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let first = try BoardWriter.createLane(inBoard: fixture.root, title: "Todo") let second = try BoardWriter.createLane(inBoard: fixture.root, title: "Doing") for id in [first, second] { #expect(BoardLoader.isUUIDShaped(id.rawValue)) #expect(id.rawValue == id.rawValue.lowercased()) } #expect(first != second) } @Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) #expect(document.keys == ["schema", "title", "order", "created", "modified"]) #expect(document.schema == .valid(1)) #expect(document.modifiedBy == .missing) } @Test func anEmptyParentsFirstChildLandsAt1024() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Only") let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) #expect(document.order == .valid(1024)) } @Test func appendsAfterVisibleSiblingsAtMaxPlus1024() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") try fixture.item(Child.b, "---\nschema: 1\norder: 2048\ntitle: B\n---\nbody\n") let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(3072)) } /// The highest-order sibling is tombstoned — tombstones are inert to ordering, so it must /// not factor into the append target at all (not `9999 + 1024`, not anything derived from it). @Test func aTombstonedHighestOrderSiblingIsExcludedFromTheAppendTarget() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") try fixture.item( Child.deleted, "---\nschema: 1\norder: 9999\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(2048)) } /// A UUID-shaped sibling folder with no `index.md` — an interrupted create — is skipped /// during the order scan, not fatal; the new item still lands correctly, and the board as a /// whole still loads, surfacing only the loader's ordinary `.missingIndex` warning. @Test func aSiblingFolderWithoutIndexIsSkippedNotFatal() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") try FileManager.default.createDirectory(at: fixture.url(Child.indexless), withIntermediateDirectories: true) let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(2048)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings == [.missingIndex(path: Child.indexless)]) #expect(Set(result.model.lanes.map(\.id.rawValue)) == Set([Child.a, newID.rawValue])) } @Test func aSiblingWithAMalformedOrderFailsTheCreateNamingTheSibling() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: banana\ntitle: A\n---\nbody\n") let before = try fixture.entryNames("") let error = writeFailure { _ = try BoardWriter.createLane(inBoard: fixture.root, title: "New") } #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) #expect(error?.path.contains(Child.a) == true) #expect(error?.operation == "create lane") // Nothing was minted: the scan fails before the new folder is ever created. #expect(try fixture.entryNames("") == before) } @Test func aMissingParentFolderIsALoudUnreadableError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let missingParent = fixture.url("does-not-exist") let error = writeFailure { _ = try BoardWriter.createLane(inBoard: missingParent, title: "New") } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } } @Test func aParentThatIsAFileIsALoudUnreadableError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let filePath = fixture.root.appendingPathComponent("afile") try Data("x".utf8).write(to: filePath) let error = writeFailure { _ = try BoardWriter.createLane(inBoard: filePath, title: "New") } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } } @Test func createdEqualsModifiedAtCreationTime() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) #expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified)) } /// `createCard` is `createLane` one level down — a lane folder (need not have its own /// `index.md` for this to work; `createChild` never reads the parent's own file) is just as /// good a parent as a board root. @Test func createCardMintsUnderALaneFolderWithTheSameRules() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") let laneFolder = fixture.url(laneID.rawValue) let first = try BoardWriter.createCard(inLane: laneFolder, title: "First") let second = try BoardWriter.createCard(inLane: laneFolder, title: "Second") #expect(BoardLoader.isUUIDShaped(first.rawValue)) #expect(first != second) let firstDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(first.rawValue)")) let secondDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(second.rawValue)")) #expect(firstDocument.order == .valid(1024)) #expect(secondDocument.order == .valid(2048)) } } // MARK: - Create round-trip struct BoardWriterCreateIntegrationTests { /// `createBoard` → `createLane` → `createCard` → `BoardLoader.load`: the whole structure /// shows up, in append order, exactly as created. @Test func createBoardCreateLaneCreateCardRoundTripsThroughTheLoader() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("Trip.kanban") try BoardWriter.createBoard(at: root, title: "Trip") let lane1 = try BoardWriter.createLane(inBoard: root, title: "Todo") let lane2 = try BoardWriter.createLane(inBoard: root, title: "Doing") let lane1Folder = root.appendingPathComponent(lane1.rawValue, isDirectory: true) let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First") let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second") let result = try BoardLoader.load(boardRoot: root) #expect(result.warnings.isEmpty) #expect(result.model.title == .valid("Trip")) #expect(result.model.lanes.map(\.id.rawValue) == [lane1.rawValue, lane2.rawValue]) #expect(result.model.lanes[0].title == .valid("Todo")) #expect(result.model.lanes[1].title == .valid("Doing")) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card1.rawValue, card2.rawValue]) #expect(result.model.lanes[0].cards[0].title == .valid("First")) #expect(result.model.lanes[0].cards[1].title == .valid("Second")) #expect(result.model.lanes[1].cards.isEmpty) } } // MARK: - Editability scope: reads vs rewrites /// The readable-but-uneditable pre-flight belongs only to operations that will *rewrite* /// siblings. A flow-mapping sibling loads and renders normally (01-storage-format.md § /// Frontmatter), so it must not block creating a new item beside it — but renumber, which /// would have to rewrite that very file, refuses up front, before anything is written. struct BoardWriterEditabilityScopeTests { private static let flowSibling = "---\n{schema: 1, order: 5000}\n---\n" @Test func createSucceedsBesideAnUneditableSibling() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\n---\n") try fixture.item(Child.a, Self.flowSibling) let id = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let text = try fixture.indexText(id.rawValue) #expect(text.contains("order: 6024")) } @Test func renumberRefusesOnAnUneditableSibling() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\n---\n") try fixture.item(Child.a, Self.flowSibling) try fixture.item(Child.b, "---\nschema: 1\norder: 1024\n---\n") let before = try fixture.indexData(Child.b) let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.root) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(error?.path.hasSuffix("\(Child.a)/index.md") == true) #expect(try fixture.indexData(Child.b) == before) #expect(try fixture.indexText(Child.a) == Self.flowSibling) } }