Files
lanework/KanbanTests/WriterTestSupport.swift
T
rzen 53bc71f7fb Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

181 lines
7.5 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: - 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
}
}