Build BoardWriter — atomic, minimal-touch writes

The single point through which every mutation becomes a filesystem
operation: read fresh from disk (strict byte-faithful UTF-8), refuse
readable-but-uneditable frontmatter shapes, apply the edit through the
span engine, stamp modified / clear modified-by, then temp-file+rename
atomically. Renumber-visible-children is the sole minimal-touch
exception, tombstones untouched. Structured BoardWriteError carries
operation + path + reason for the future banner surface.

Alongside, three edges the card surfaced:
- The loader now decodes byte-faithfully too, so a BOM'd or non-UTF-8
  index.md is rejected at load per the encoding contract, instead of
  loading via NSString's silent BOM strip and then refusing every write.
- An appended frontmatter line adopts the file's prevailing line ending
  (a CRLF file stays uniformly CRLF when a stamp first lands in it).
- Empty-but-not-blank frontmatter ({}, null, ~) is detected as
  uneditable — appending after it would be unparseable YAML.

30 new unit tests; 176 total green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 16:49:42 -04:00
parent a131399c02
commit 19f67bdb78
6 changed files with 1061 additions and 8 deletions
+40
View File
@@ -502,3 +502,43 @@ struct BoardLoaderFailFastTests {
}
}
}
// 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"
}
}
}
+595
View File
@@ -0,0 +1,595 @@
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
/// `<relativePath>/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"))
}
}
+34
View File
@@ -962,3 +962,37 @@ struct FrontmatterEmissionTests {
== .mapping([YAMLValue.Pair(key: .string("order"), value: .int(7))]))
}
}
// MARK: - Appended lines and empty-but-not-blank blocks
struct FrontmatterAppendedLineTests {
@Test func appendedKeyAdoptsCRLFInACRLFFile() throws {
var document = try FrontmatterDocument.parse("---\r\nschema: 1\r\n---\r\nbody\r\n")
document.set("title", to: .string("x"))
#expect(document.serialized() == "---\r\nschema: 1\r\ntitle: x\r\n---\r\nbody\r\n")
}
@Test func appendedKeyStaysLFInAnLFFile() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\n---\nbody\n")
document.set("title", to: .string("x"))
#expect(document.serialized() == "---\nschema: 1\ntitle: x\n---\nbody\n")
}
/// `{}`, `null`, `~` resolve to an empty mapping yet leave text no key owns appending
/// after it would be unparseable YAML, so the shape refuses edits (readable-but-uneditable).
@Test(arguments: ["---\n{}\n---\nbody\n", "---\nnull\n---\nbody\n", "---\n~\n---\nbody\n"])
func emptyButNotBlankFrontmatterIsUneditable(text: String) throws {
#expect(try FrontmatterDocument.parse(text).uneditableShape == .keyWithoutOwnLine)
}
/// Truly blank or comment-only frontmatter stays editable appending after it is exactly
/// what `set` is for, and the result must reparse.
@Test(arguments: ["---\n---\nbody\n", "---\n\n---\nbody\n", "---\n# just a comment\n---\nbody\n"])
func blankOrCommentOnlyFrontmatterStaysEditable(text: String) throws {
var document = try FrontmatterDocument.parse(text)
#expect(document.uneditableShape == nil)
document.set("schema", to: .int(1))
let reparsed = try FrontmatterDocument.parse(document.serialized())
#expect(reparsed.schema == .valid(1))
}
}