The board speaks when files change under the user, per DESIGN/10 § Live board announcements. BoardDiff is the pure snapshot summarizer (identity sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so pro-m1's semantic commit engine can build on it; edited = rendered content only, moved beats edited, implied events don't steal the subject). BoardAnnouncer is the decision seam: focusOutcome computes the vanishing-focus sentence and the walk-up-then-sideways recovery (next lane by order, else previous, board container only when none remain, never the trash); speech(for:) is the one-sentence precedence ladder — raised condition > bracket completion > cleared condition > vanished focus > digest — foreign-only for the last two rungs, so app-mediated echoes stay silent. BoardStore.land assembles ReloadFacts and posts exactly one sentence per reload through the injectable announce outlet (AccessibilityAnnouncer, medium priority, never interrupting). Selection recovery layers on top of ItemReferenceSet re-resolution — survivors veto, the emptied selection lands on the vanished item's lane and re-arms ⌘N's active-lane memory. performWholesale(announcing:) arms a completion phrase consumed by the closing reload — nil on every base bracket today; pro-m1 fills git phrasings. Locks raised outside the reload path (vanished root, unwritable location) announce through the same ladder, and the banner strip is a labeled "Board status" container whose row labels are the announced sentences (AccessibilityPhrases.bannerLabel — one string for eye and ear). Announcements classify at reload granularity (WatchOrigin) as a deliberate interim: DESIGN/02's EchoLedger (per-file classification, the announcer's specified input, git-free) was scheduled with the auto-committer that the edition split moved to pro-m1 — filed on the Redesign board for a ruling. 1533 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
230 lines
9.6 KiB
Swift
230 lines
9.6 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"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|