createBoard fills or mints a .kanban (or extension-less) folder and refuses to clobber an existing board; createLane/createCard mint fresh lowercase-UUIDv4 folders and append at max+1024 among visible siblings via the same strict scan renumber uses (extracted, shared). New files carry schema/title?/order?/created/modified — created==modified, no modified-by — written LF through the same atomic temp+rename path. The editability pre-flight is now scoped to operations that rewrite siblings: renumber refuses on an uneditable sibling, a create beside one proceeds. Schema constant unified on BoardLoader.supportedSchema. Info.plist declares the .kanban document type (UTI conforming to com.apple.package) so Finder treats a board as one document. 20 new unit tests; 196 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
923 lines
40 KiB
Swift
923 lines
40 KiB
Swift
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"))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|