diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 84f7b3b..24898be 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -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 - /// `/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. diff --git a/KanbanTests/WriteFidelityTests.swift b/KanbanTests/WriteFidelityTests.swift new file mode 100644 index 0000000..dcc2d2a --- /dev/null +++ b/KanbanTests/WriteFidelityTests.swift @@ -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, _ 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") + } +} diff --git a/KanbanTests/WriterTestSupport.swift b/KanbanTests/WriterTestSupport.swift new file mode 100644 index 0000000..95e69d5 --- /dev/null +++ b/KanbanTests/WriterTestSupport.swift @@ -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 + /// `/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 + } +} diff --git a/README.md b/README.md index e37efa4..da34e6d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Lanework is in early development. This list tracks what has actually shipped and *No UI yet — the storage foundation is in place:* - **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards. +- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. ## Development