Write the write-path fidelity test suite
The executable spec for BoardWriter's cross-cutting guarantees, in a dedicated WriteFidelityTests.swift: minimal-touch verified down to sibling mtimes across a seven-operation session; renumber pinned as the sole exception (whole lane rewritten, board root / other lanes / tombstones untouched); delete→restore byte-precise (only the modified line differs, zero tombstone residue); unknown-key order preserved with keys interleaved among schema-owned ones; and a composite building a board purely through the writer, hand-editing a file like an agent would, then moving/copying/deleting/renumbering to a zero-warning reload. Guarantees already pinned by the per-operation suites (atomicity, move/copy identity, body round-trip) are referenced, not duplicated. Shared fixtures promoted to WriterTestSupport.swift. README gains the write-side feature bullet. 5 new tests; 250 total green. Closes milestone m2-storage-write. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -4,90 +4,7 @@ import Testing
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Writes an arbitrary file — not an `index.md` — creating its folder: attachments and
|
||||
/// strays, the content a copy has to carry verbatim without ever reading it.
|
||||
@discardableResult
|
||||
func file(_ relativePath: String, _ bytes: Data) throws -> URL {
|
||||
let fileURL = root.appendingPathComponent(relativePath)
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try bytes.write(to: fileURL)
|
||||
return fileURL
|
||||
}
|
||||
|
||||
func data(_ relativePath: String) throws -> Data {
|
||||
try Data(contentsOf: root.appendingPathComponent(relativePath))
|
||||
}
|
||||
|
||||
func exists(_ relativePath: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: url(relativePath).path)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
// `WriterFixture` lives in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
||||
|
||||
private enum Fixture {
|
||||
/// Unknown keys in a deliberate order, an own-line comment above and below, an inline
|
||||
@@ -140,48 +57,7 @@ private enum Child {
|
||||
static let indexless = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
||||
}
|
||||
|
||||
/// Literal UUID-shaped names for the move/copy suites, which need more of them than `Child`
|
||||
/// offers — an import-boundary test has the *same* identity living in two boards at once, and a
|
||||
/// compound arrival needs a lane with several cards.
|
||||
private enum Ident {
|
||||
static let lane1 = "11111111-1111-4111-8111-111111111111"
|
||||
static let lane2 = "22222222-2222-4222-8222-222222222222"
|
||||
static let lane3 = "33333333-3333-4333-8333-333333333333"
|
||||
static let lane4 = "44444444-4444-4444-8444-444444444444"
|
||||
static let card1 = "55555555-5555-4555-8555-555555555555"
|
||||
static let card2 = "66666666-6666-4666-8666-666666666666"
|
||||
static let card3 = "77777777-7777-4777-8777-777777777777"
|
||||
static let card4 = "99999999-9999-4999-8999-999999999999"
|
||||
static let indexless = "88888888-8888-4888-8888-888888888888"
|
||||
}
|
||||
|
||||
/// The `index.md` texts the move/copy suites move and copy around.
|
||||
private enum Item {
|
||||
static let board = "---\nschema: 1\ntitle: Board\n---\nBoard description.\n"
|
||||
|
||||
/// Everything a move or a copy has to leave alone: unknown keys with an inline comment, a
|
||||
/// `created` stamp from before today, a foreign `modified-by`, and a body.
|
||||
static func rich(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
project: lanework # agent overlay
|
||||
labels: [a, b, c]
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
\(title) body — with *markdown*.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// A whole-frontmatter flow mapping carrying a `modified-by`: readable, uneditable, and so
|
||||
/// left byte-verbatim by a copy — stale attribution included.
|
||||
static let uneditable = "---\n{schema: 1, order: 1024, title: Odd, modified-by: claude}\n---\nodd body\n"
|
||||
}
|
||||
// `Ident` and `Item` live in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
||||
|
||||
/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical.
|
||||
private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
|
||||
@@ -201,18 +77,7 @@ private func childrenByTitle(of relativePath: String, in fixture: WriterFixture)
|
||||
return byTitle
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// `writeFailure` lives in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
||||
|
||||
/// The file's lines minus every line that opens one of `keys` — what a rewrite of exactly those
|
||||
/// keys has to leave byte-identical.
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The executable spec for the write path's fidelity guarantees — the m2 "write-path fidelity
|
||||
/// test suite" card. `BoardWriterTests.swift` already pins per-operation behavior file by file
|
||||
/// (one suite per `BoardWriter` call); this file pins the guarantees that cut *across* calls and
|
||||
/// across the whole tree, the properties no single per-operation test is positioned to state.
|
||||
///
|
||||
/// Four of the card's six guarantees are already fully pinned in `BoardWriterTests.swift` and are
|
||||
/// not repeated here — only referenced:
|
||||
///
|
||||
/// - **Byte-identical body round-trip**, by direct byte comparison —
|
||||
/// `BoardWriterPreservationTests.aFrontmatterEditLeavesTheBodyByteIdentical`.
|
||||
/// - **Atomicity**: temp-file+rename leaves no residue on success —
|
||||
/// `BoardWriterPreservationTests.aSuccessfulWriteLeavesNoTempFileBehind`; an injected mid-write
|
||||
/// failure (an unwritable parent folder) leaves the original bytes *and* the directory listing
|
||||
/// untouched, temp file included — `BoardWriterFailureTests
|
||||
/// .aFailedWriteLeavesTheFileAndTheFolderExactlyAsTheyWere`. Audited: both halves of "verifiable
|
||||
/// temp-file+rename, nothing left behind either way" already exist; there is no missing piece.
|
||||
/// - **Move/copy identity** — UUID preservation, fresh minting, and the import-boundary collision
|
||||
/// repair, including the per-folder compound case (a lane arriving with one colliding card stays
|
||||
/// a lane *move*) — `BoardWriterMoveTests`/`BoardWriterCopyTests` in full, in particular
|
||||
/// `aLaneMoveRemintsOnlyTheCollidingCard`.
|
||||
///
|
||||
/// What follows fills the remaining gaps: minimal-touch stated with **mtimes**, not just bytes
|
||||
/// (existing sibling-byte assertions never look at the filesystem's own "did this file move"
|
||||
/// signal); the renumber fallback stated as the *positive* exception across a whole board rather
|
||||
/// than within one lane; delete→restore at full byte precision against the pre-delete original
|
||||
/// (existing coverage checks the *result* is undeleted and reordered correctly, not that the
|
||||
/// bytes differ from the original by exactly one line); unknown-key order through a writer op
|
||||
/// with keys deliberately interleaved among schema-owned ones (existing coverage groups the
|
||||
/// unknown keys together); and one end-to-end composite scenario tying every guarantee together.
|
||||
|
||||
// MARK: - Shared helpers
|
||||
|
||||
/// A file's bytes and on-disk modification date — the two facts "minimal touch" promises stay
|
||||
/// put for every `index.md` a mutation does not target.
|
||||
private func snapshot(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
|
||||
let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
|
||||
let data = try Data(contentsOf: indexURL)
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
|
||||
guard let modified = attributes[.modificationDate] as? Date else {
|
||||
Issue.record("no modification date for \(relativePath)")
|
||||
return (data, .distantPast)
|
||||
}
|
||||
return (data, modified)
|
||||
}
|
||||
|
||||
/// Every folder under the fixture root holding an `index.md`, relative to the root (`""` for the
|
||||
/// root itself) — recomputed on demand since `createCard`/`createLane` grow the set mid-test.
|
||||
private func allIndexFolders(_ fixture: WriterFixture) -> [String] {
|
||||
guard let walker = FileManager.default.enumerator(
|
||||
at: fixture.root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
let rootPath = fixture.root.standardizedFileURL.path
|
||||
var folders: [String] = []
|
||||
for case let url as URL in walker where url.lastPathComponent == "index.md" {
|
||||
let folderPath = url.deletingLastPathComponent().standardizedFileURL.path
|
||||
if folderPath == rootPath {
|
||||
folders.append("")
|
||||
} else if folderPath.hasPrefix(rootPath + "/") {
|
||||
folders.append(String(folderPath.dropFirst(rootPath.count + 1)))
|
||||
}
|
||||
}
|
||||
return folders.sorted()
|
||||
}
|
||||
|
||||
/// The file's lines minus every line that opens one of `keys` — a local twin of
|
||||
/// `BoardWriterTests.swift`'s file-private `lines(of:excludingKeys:)`; each file keeps its own
|
||||
/// since the declaration is `private` (file-scoped) and this file's fixtures are its own.
|
||||
private func lines(of text: String, excludingKeys keys: [String]) -> [String] {
|
||||
text.components(separatedBy: "\n")
|
||||
.filter { line in !keys.contains { line.hasPrefix("\($0):") } }
|
||||
}
|
||||
|
||||
/// A couple of extra literal identities beyond `Ident`'s: this file's boards run wider (2×2
|
||||
/// grids, a renumber lane with an "other lane" alongside it) than `Ident`'s four lane/card slots
|
||||
/// cover on their own.
|
||||
private enum MoreIdent {
|
||||
static let card5 = "aaaaaaaa-1111-4111-8111-111111111112"
|
||||
}
|
||||
|
||||
// MARK: - Minimal touch, with mtimes
|
||||
|
||||
/// 01-storage-format.md § Ordering, "a reorder rewrites only the moved item's `index.md`", and §
|
||||
/// Fractal layout ▸ Rules' round-trip guarantee — the mtime half. A same-content rewrite is
|
||||
/// invisible to a byte comparison but not to the filesystem, so "untouched" has to mean both.
|
||||
struct WriteFidelityMinimalTouchTests {
|
||||
/// One realistic editing session — reorder, style, rename, delete, restore, create, import —
|
||||
/// run back to back on a 2-lane × 2-card board. After each step, every `index.md` the step
|
||||
/// did not target is asserted byte-identical *and* mtime-identical to its state just before
|
||||
/// that step; the targeted file is asserted to have actually changed (proof the snapshot
|
||||
/// method is sensitive to a real edit, not vacuously passing). The last step — an attachment
|
||||
/// import — targets *nothing*: `importAttachments` never opens an `index.md` at all, so even
|
||||
/// the host card's own frontmatter must come through unmoved.
|
||||
@Test func aSequenceOfEverydayMutationsLeavesEveryOtherIndexByteAndMtimeIdentical() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Card Three"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Card Four"))
|
||||
|
||||
var snapshots: [String: (data: Data, modified: Date)] = [:]
|
||||
for folder in allIndexFolders(fixture) {
|
||||
snapshots[folder] = try snapshot(fixture, folder)
|
||||
}
|
||||
|
||||
func step(_ label: String, targeting targets: Set<String>, _ operation: () throws -> Void) throws {
|
||||
try operation()
|
||||
|
||||
for (folder, before) in snapshots where !targets.contains(folder) {
|
||||
let after = try snapshot(fixture, folder)
|
||||
#expect(after.data == before.data, "\(label) touched \(folder)'s bytes")
|
||||
#expect(after.modified == before.modified, "\(label) touched \(folder)'s mtime")
|
||||
}
|
||||
for target in targets {
|
||||
let before = try #require(snapshots[target])
|
||||
let after = try snapshot(fixture, target)
|
||||
#expect(after.data != before.data, "\(label) left \(target) byte-identical")
|
||||
snapshots[target] = after
|
||||
}
|
||||
for folder in allIndexFolders(fixture) where snapshots[folder] == nil {
|
||||
snapshots[folder] = try snapshot(fixture, folder)
|
||||
}
|
||||
}
|
||||
|
||||
try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: "reorder card"
|
||||
) { $0.set(FrontmatterKeys.order, to: .double(1536)) }
|
||||
}
|
||||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: "set background"
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
||||
}
|
||||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: "rename card"
|
||||
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
|
||||
}
|
||||
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||
try BoardWriter.deleteItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
|
||||
}
|
||||
try step("restore", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||
try BoardWriter.restoreItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
|
||||
}
|
||||
|
||||
var createdCard = ""
|
||||
try step("create card", targeting: []) {
|
||||
createdCard = try BoardWriter.createCard(inLane: fixture.url(Ident.lane1), title: "Fresh").rawValue
|
||||
}
|
||||
#expect(BoardLoader.isUUIDShaped(createdCard))
|
||||
|
||||
try step("import attachment", targeting: []) {
|
||||
// Dot-prefixed so it never becomes a board-root stray the loader has to warn about
|
||||
// — the source lives outside the board tree in spirit, just not in path.
|
||||
let source = try fixture.file(".sources/shot.png", Data([0x01, 0x02]))
|
||||
_ = try BoardWriter.importAttachments([source], intoCard: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||
}
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.root).warnings.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The renumber exception, stated positively
|
||||
|
||||
/// 01-storage-format.md § Ordering, "the one exception to the only-the-moved-item rule":
|
||||
/// `renumberVisibleChildren` rewrites *every* visible sibling in the lane it renumbers — bytes
|
||||
/// and mtimes both move — while everything outside that lane, tombstones inside it included,
|
||||
/// stays put. `BoardWriterRenumberTests` already pins the within-lane byte half (tombstones and
|
||||
/// strays untouched); this test widens the lens to the whole board and adds the mtime half on
|
||||
/// both sides of the exception.
|
||||
struct WriteFidelityRenumberTests {
|
||||
@Test func renumberRewritesTheWholeLaneButLeavesEverythingElseByteAndMtimeIdentical() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1.0000003", title: "A"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000001", title: "B"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "1.0000002", title: "C"))
|
||||
try fixture.item(
|
||||
"\(Ident.lane1)/\(Ident.card4)",
|
||||
"---\nschema: 1\norder: 0.5\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(MoreIdent.card5)", Item.rich(order: "1024", title: "Other Lane's Card"))
|
||||
|
||||
let untouched = ["", Ident.lane1, Ident.lane2, "\(Ident.lane2)/\(MoreIdent.card5)", "\(Ident.lane1)/\(Ident.card4)"]
|
||||
let renumbered = ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane1)/\(Ident.card2)", "\(Ident.lane1)/\(Ident.card3)"]
|
||||
var before: [String: (data: Data, modified: Date)] = [:]
|
||||
for path in untouched + renumbered { before[path] = try snapshot(fixture, path) }
|
||||
|
||||
try BoardWriter.renumberVisibleChildren(of: fixture.url(Ident.lane1))
|
||||
|
||||
for path in untouched {
|
||||
let previous = try #require(before[path])
|
||||
let after = try snapshot(fixture, path)
|
||||
#expect(after.data == previous.data)
|
||||
#expect(after.modified == previous.modified)
|
||||
}
|
||||
for path in renumbered {
|
||||
let previous = try #require(before[path])
|
||||
let after = try snapshot(fixture, path)
|
||||
#expect(after.data != previous.data)
|
||||
#expect(after.modified != previous.modified)
|
||||
}
|
||||
|
||||
let orders = try [Ident.card2, Ident.card3, Ident.card1].map {
|
||||
try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
|
||||
}
|
||||
#expect(orders == [.valid(1024), .valid(2048), .valid(3072)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete → restore, byte precision
|
||||
|
||||
/// 01-storage-format.md § Deletion: Put Back "undoes exactly what `deleteItem` wrote". Existing
|
||||
/// coverage (`BoardWriterDeleteRestoreTests`) checks the *result* — undeleted, reordered
|
||||
/// correctly — this test checks the *bytes*: after a full delete→restore round trip, the file
|
||||
/// differs from the pre-delete original in exactly one place, the `modified:` line, with every
|
||||
/// comment, inline comment, unknown key, and per-line ending untouched, and zero occurrences of
|
||||
/// `deleted` anywhere in the text.
|
||||
struct WriteFidelityTombstoneTests {
|
||||
@Test func deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||
let original = "---\n"
|
||||
+ "schema: 1\n"
|
||||
+ "# a hand-written note\n"
|
||||
+ "title: Original\n"
|
||||
+ "order: 1536\n"
|
||||
+ "project: lanework # agent overlay\n"
|
||||
+ "sphere: work\r\n"
|
||||
+ "labels: [a, b, c]\n"
|
||||
+ "modified: 2026-01-01T00:00:00Z\n"
|
||||
+ "---\n"
|
||||
+ "Body text.\n\nMore body — with *markdown*.\n"
|
||||
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
let folder = try fixture.item(cardPath, original)
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 1\norder: 2048\ntitle: Sibling\n---\n")
|
||||
|
||||
try BoardWriter.deleteItem(at: folder)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
|
||||
|
||||
try BoardWriter.restoreItem(at: folder)
|
||||
let after = try fixture.indexText(cardPath)
|
||||
|
||||
#expect(lines(of: after, excludingKeys: [FrontmatterKeys.modified])
|
||||
== lines(of: original, excludingKeys: [FrontmatterKeys.modified]))
|
||||
// The untouched CRLF unknown-key line and the inline comment both survived verbatim.
|
||||
#expect(after.contains("sphere: work\r\n"))
|
||||
#expect(after.contains("project: lanework # agent overlay\n"))
|
||||
// No residue of the key that made this item a tombstone, anywhere in the text.
|
||||
#expect(!after.contains("deleted"))
|
||||
|
||||
let document = try FrontmatterDocument.parse(after)
|
||||
#expect(document.deleted == .missing)
|
||||
#expect(document.order == .valid(1536))
|
||||
#expect(document.title == .valid("Original"))
|
||||
|
||||
// Position among siblings unchanged: the loader sees the card back at its recorded
|
||||
// order, ahead of the sibling that was never touched.
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.warnings.isEmpty)
|
||||
let cards = try #require(result.model.lanes.first?.cards)
|
||||
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
|
||||
#expect(cards[0].isDeleted == false)
|
||||
#expect(cards[0].order == 1536)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unknown-key order through a writer op
|
||||
|
||||
/// 01-storage-format.md § Fractal layout ▸ Rules, "unknown frontmatter keys and their order are
|
||||
/// preserved verbatim on every rewrite" — load-bearing for agent overlays (08-agent-integration.md).
|
||||
/// `BoardWriterPreservationTests.aTitleEditTouchesOnlyTheTitleAndTheStamps` already pins this for
|
||||
/// three unknown keys grouped contiguously; this test strengthens it with five, deliberately
|
||||
/// interleaved among the schema-owned keys rather than clustered, so the guarantee is about
|
||||
/// *document order*, not merely "the unknown-keys subsequence".
|
||||
struct WriteFidelityUnknownKeyOrderTests {
|
||||
@Test func interleavedUnknownKeysKeepTheirSequenceThroughATitleRewrite() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let original = """
|
||||
---
|
||||
schema: 1
|
||||
alpha: 1
|
||||
title: Before
|
||||
beta: 2
|
||||
order: 1024
|
||||
gamma: 3
|
||||
created: 2026-01-01T00:00:00Z
|
||||
delta: 4
|
||||
modified: 2026-01-01T00:00:00Z
|
||||
epsilon: 5
|
||||
---
|
||||
Body.
|
||||
|
||||
"""
|
||||
let folder = try fixture.item("card", original)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("After"))
|
||||
}
|
||||
|
||||
let after = try fixture.indexText("card")
|
||||
let excluded = [FrontmatterKeys.title, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
|
||||
#expect(lines(of: after, excludingKeys: excluded) == lines(of: original, excludingKeys: excluded))
|
||||
|
||||
let document = try FrontmatterDocument.parse(after)
|
||||
#expect(document.unknownFields.map(\.key) == ["alpha", "beta", "gamma", "delta", "epsilon"])
|
||||
#expect(document.keys == [
|
||||
"schema", "alpha", "title", "beta", "order", "gamma", "created", "delta", "modified", "epsilon",
|
||||
])
|
||||
#expect(document.title == .valid("After"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cross-cutting composite
|
||||
|
||||
/// The "a brand-new board is indistinguishable from a hand-edited one" claim, composed into one
|
||||
/// session: a board built purely through `BoardWriter`, one file hand-edited outside it entirely
|
||||
/// (simulating an agent or a human in a text editor), then moved, copied, tombstoned, restored,
|
||||
/// and renumbered — and at the end `BoardLoader.load` succeeds with zero warnings, the hand-added
|
||||
/// unknown keys survived, and every title and body reads back exactly as set.
|
||||
struct WriteFidelityCompositeTests {
|
||||
@Test func aBoardBuiltThroughTheWriterAndThenHandEditedLoadsCleanlyAfterEveryOperation() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let root = fixture.url("Composite.kanban")
|
||||
|
||||
try BoardWriter.createBoard(at: root, title: "Composite")
|
||||
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 lane2Folder = root.appendingPathComponent(lane2.rawValue, isDirectory: true)
|
||||
|
||||
let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First")
|
||||
let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second")
|
||||
let card3 = try BoardWriter.createCard(inLane: lane1Folder, title: "Third")
|
||||
for (id, body) in [(card1, "First body.\n"), (card2, "Second body.\n"), (card3, "Third body.\n")] {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: "edit body"
|
||||
) { $0.body = body }
|
||||
}
|
||||
|
||||
// A hand edit: an agent opens Second's file directly and adds unknown keys plus a
|
||||
// comment — no `BoardWriter` call involved, exactly what "indistinguishable" has to
|
||||
// survive everything that follows.
|
||||
let card2Path = "Composite.kanban/\(lane1.rawValue)/\(card2.rawValue)"
|
||||
let handEdited = try fixture.indexText(card2Path).replacingOccurrences(
|
||||
of: "modified:",
|
||||
with: "project: lanework\nsphere: work # agent overlay\n# a note\nmodified:"
|
||||
)
|
||||
try fixture.item(card2Path, handEdited)
|
||||
|
||||
// Move: First crosses into Doing — same board, so identity travels unchanged.
|
||||
let moveResult = try BoardWriter.moveItem(
|
||||
at: lane1Folder.appendingPathComponent(card1.rawValue),
|
||||
toParent: lane2Folder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: nil
|
||||
)
|
||||
#expect(moveResult.id == card1)
|
||||
#expect(moveResult.reminted.isEmpty)
|
||||
|
||||
// Copy: Third forks into Doing alongside it — a new identity, the original left in Todo.
|
||||
let copyID = try BoardWriter.copyItem(
|
||||
at: lane1Folder.appendingPathComponent(card3.rawValue), toParent: lane2Folder, order: nil, stamps: .fork
|
||||
)
|
||||
#expect(copyID != card3)
|
||||
|
||||
// Delete, then restore, the hand-edited card — its unknown keys and comment must come
|
||||
// back with no residue of `deleted`.
|
||||
try BoardWriter.deleteItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
|
||||
try BoardWriter.restoreItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
|
||||
|
||||
// Renumber both lanes — the fallback that touches every visible sibling's `order`.
|
||||
try BoardWriter.renumberVisibleChildren(of: lane1Folder)
|
||||
try BoardWriter.renumberVisibleChildren(of: lane2Folder)
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: root)
|
||||
#expect(result.warnings.isEmpty)
|
||||
#expect(result.model.title == .valid("Composite"))
|
||||
|
||||
let lanes = Dictionary(uniqueKeysWithValues: result.model.lanes.map { ($0.id, $0) })
|
||||
let todo = try #require(lanes[lane1])
|
||||
let doing = try #require(lanes[lane2])
|
||||
|
||||
// Todo kept the two cards that never left: Second (hand-edited, survived a full
|
||||
// delete/restore round trip) and Third (untouched original, still resident after being
|
||||
// only *copied*, never moved).
|
||||
#expect(Set(todo.cards.map(\.id)) == [card2, card3])
|
||||
let restoredSecond = try #require(todo.cards.first { $0.id == card2 })
|
||||
#expect(restoredSecond.title == .valid("Second"))
|
||||
#expect(restoredSecond.body == "Second body.\n")
|
||||
#expect(restoredSecond.isDeleted == false)
|
||||
#expect(restoredSecond.document.unknownFields.map(\.key) == ["project", "sphere"])
|
||||
#expect(restoredSecond.document.rawValue(for: "project") == "lanework")
|
||||
let untouchedThird = try #require(todo.cards.first { $0.id == card3 })
|
||||
#expect(untouchedThird.title == .valid("Third"))
|
||||
#expect(untouchedThird.body == "Third body.\n")
|
||||
|
||||
// Doing holds the mover (original identity) and the fork (a fresh one).
|
||||
#expect(Set(doing.cards.map(\.id)) == [card1, copyID])
|
||||
let movedFirst = try #require(doing.cards.first { $0.id == card1 })
|
||||
#expect(movedFirst.title == .valid("First"))
|
||||
#expect(movedFirst.body == "First body.\n")
|
||||
let forkedThird = try #require(doing.cards.first { $0.id == copyID })
|
||||
#expect(forkedThird.title == .valid("Third"))
|
||||
#expect(forkedThird.body == "Third body.\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// Test helpers shared by `BoardWriterTests.swift` (per-operation coverage) and
|
||||
/// `WriteFidelityTests.swift` (the cross-cutting write-path fidelity guarantees this file's
|
||||
/// twin exists to pin) — promoted out of `BoardWriterTests.swift`, `internal` rather than
|
||||
/// `private`, the moment a second file needed them. Everything here writes and reads raw bytes
|
||||
/// on disk, never through the app's own read path, so every assertion built on top of it is
|
||||
/// about what is actually on disk (02-architecture.md § Layering ▸ Components, "a write is done
|
||||
/// when the file is on disk").
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// A temp directory holding hand-written `index.md` files, written and read back as raw bytes.
|
||||
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
|
||||
}
|
||||
|
||||
/// Writes an arbitrary file — not an `index.md` — creating its folder: attachments and
|
||||
/// strays, the content a copy has to carry verbatim without ever reading it.
|
||||
@discardableResult
|
||||
func file(_ relativePath: String, _ bytes: Data) throws -> URL {
|
||||
let fileURL = root.appendingPathComponent(relativePath)
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try bytes.write(to: fileURL)
|
||||
return fileURL
|
||||
}
|
||||
|
||||
func data(_ relativePath: String) throws -> Data {
|
||||
try Data(contentsOf: root.appendingPathComponent(relativePath))
|
||||
}
|
||||
|
||||
func exists(_ relativePath: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: url(relativePath).path)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Move/copy identities
|
||||
|
||||
/// Literal UUID-shaped names for the move/copy suites, which need more of them than
|
||||
/// `BoardWriterTests.swift`'s own `Child` offers — an import-boundary test has the *same*
|
||||
/// identity living in two boards at once, and a compound arrival needs a lane with several
|
||||
/// cards.
|
||||
enum Ident {
|
||||
static let lane1 = "11111111-1111-4111-8111-111111111111"
|
||||
static let lane2 = "22222222-2222-4222-8222-222222222222"
|
||||
static let lane3 = "33333333-3333-4333-8333-333333333333"
|
||||
static let lane4 = "44444444-4444-4444-8444-444444444444"
|
||||
static let card1 = "55555555-5555-4555-8555-555555555555"
|
||||
static let card2 = "66666666-6666-4666-8666-666666666666"
|
||||
static let card3 = "77777777-7777-4777-8777-777777777777"
|
||||
static let card4 = "99999999-9999-4999-8999-999999999999"
|
||||
static let indexless = "88888888-8888-4888-8888-888888888888"
|
||||
}
|
||||
|
||||
/// The `index.md` texts the move/copy suites move and copy around.
|
||||
enum Item {
|
||||
static let board = "---\nschema: 1\ntitle: Board\n---\nBoard description.\n"
|
||||
|
||||
/// Everything a move or a copy has to leave alone: unknown keys with an inline comment, a
|
||||
/// `created` stamp from before today, a foreign `modified-by`, and a body.
|
||||
static func rich(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
project: lanework # agent overlay
|
||||
labels: [a, b, c]
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
\(title) body — with *markdown*.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// A whole-frontmatter flow mapping carrying a `modified-by`: readable, uneditable, and so
|
||||
/// left byte-verbatim by a copy — stale attribution included.
|
||||
static let uneditable = "---\n{schema: 1, order: 1024, title: Odd, modified-by: claude}\n---\nodd body\n"
|
||||
}
|
||||
|
||||
// MARK: - Failure assertion
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user