Nine rulings land as code. Reorders don't stamp — one container-change predicate (WriteOperation.rewritesOrderOnly): within-container reorders and the renumber rescale rewrite only order, while cross-lane, cross-board, and trash moves stamp modified and clear modified-by; no trash special case exists, and the m8 undo inverses conform through the same seam. Copies are transactions: the root-strict/nested-lenient split retires for a whole-subtree stampability preflight that refuses loudly naming the offender, and every item-level copy severs remote/remote-state at every level (whole-board forks carry them verbatim). Paste refuses, never degrades: the embedded-index.md materialization and its loss row retire; a missing staged snapshot produces nothing and posts an error-tone one-shot named from manifest metadata. Coerce-tier fallbacks log through the Defect stream with path context attached loader-side. Displacement is level-uniform: a file squatting attachments inside a card heals by the same rename ladder as board-root squatters; comments stays tolerated. Delete Immediately joins card and lane context menus as Delete's ⌥-alternate with its own VO custom action, routed through an explicit container so the menu target outranks standing selection. Agent guide v7 teaches the stamp discipline and the card-level attachments claim, and sheds two stale v6 lines (lanes trash now; kind is taught). Verified conformant, unchanged: edition-aware Undo/Redo disable, trash marquee full-height backdrop. Both schemes 1854 tests / 318 suites green; verify-editions 30/30. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
251 lines
10 KiB
Swift
251 lines
10 KiB
Swift
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()
|
|
}
|
|
|
|
/// 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 `<root>/.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 {
|
|
|
|
/// `<root>/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 `<root>/.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"
|
|
|
|
/// An item carrying one of the **reserved tracker keys** — `remote` on a board or card,
|
|
/// `remote-state` on a lane (01-storage-format.md § Enhanced schema) — beside an ordinary unknown
|
|
/// key, so a copy's tracker sever can be told apart from unknown-key preservation breaking.
|
|
///
|
|
/// Nothing in this version reads the keys; what the suites pin is that an **item-level copy drops
|
|
/// them** (ruled 2026-07-29) while a whole-board fork carries them verbatim.
|
|
static func tracked(order: String, title: String, key: String) -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
project: lanework # agent overlay
|
|
\(key): gitea#42
|
|
created: 2026-01-01T09:00:00Z
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|