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() } /// Moves a folder from one place in the board to another — a **foreign** move, the way an agent /// or a hand-editor makes one: `FileManager` and nothing else, no `index.md` rewritten, no rank /// touched. What the container-crossing rules are stated in terms of (02-architecture.md § /// Live-reload resilience, resettled 2026-07-28). func moveFolder(_ relativePath: String, to destinationPath: String) throws { let destination = url(destinationPath) try FileManager.default.createDirectory( at: destination.deletingLastPathComponent(), withIntermediateDirectories: true ) try FileManager.default.moveItem(at: url(relativePath), to: destination) } /// A card folder moved into `/.trash/` — the shape of a delete on disk, made foreignly. func move(_ relativePath: String, toTrash cardName: String) throws { try moveFolder(relativePath, to: ".trash/\(cardName)") } /// A card folder moved out of the trash into a lane — the shape of a restore, made foreignly. func move(_ relativePath: String, toLane laneName: String, card cardName: String) throws { try moveFolder(relativePath, to: "\(laneName)/\(cardName)") } } // MARK: - Snapshots /// The half of the fixture the **snapshot-comparison** suites need (`BoardDiffTests`, /// `BoardAnnouncerTests`): boards written as files and read back through the real loader. /// /// They compare `BoardModel` values, and a hand-assembled model would be assembling something /// `BoardLoader` can never produce — a lane with a malformed `order`, a card whose `document` does /// not match its fields. Writing bytes and loading them is the only way the two snapshots in a diff /// are the two snapshots a reload would actually have compared. extension WriterFixture { /// `/index.md`, the one file every board must have. @discardableResult func board(title: String = "Board", body: String = "Board description.") throws -> URL { try item("", "---\nschema: 1\ntitle: \(title)\n---\n\(body)\n") } @discardableResult func lane(_ id: String, order: String, title: String, width: Int? = nil, body: String = "") throws -> URL { let widthLine = width.map { "width: \($0)\n" } ?? "" return try item(id, "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(widthLine)---\n\(body)\n") } @discardableResult func card( _ id: String, in laneID: String, order: String, title: String, body: String = "", modified: String? = nil ) throws -> URL { let modifiedLine = modified.map { "modified: \($0)\n" } ?? "" return try item("\(laneID)/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(modifiedLine)---\n\(body)\n") } /// A card written straight into `/.trash/` — the materialized trash's shape, for the /// diff rule that says churn in there is not a change to the board. @discardableResult func trashCard(_ id: String, order: String, title: String) throws -> URL { try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\n") } /// The board as the loader reads it right now — the value a reload would have landed. func snapshot() throws -> BoardModel { try BoardLoader.load(boardRoot: root).model } } // 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 } }