Files
lanework/KanbanTests/FixtureBoardTests.swift
T
rzen af1860debf Relocate loose card files into attachments
01's Lanework-owns-the-board carve-out: a regular file beside a card's
index.md belongs in attachments/, and the app moves it there. The
loader detects read-only — a new LoadResult.looseCardFiles channel,
separate from the stray-tolerance warnings because it says the opposite
thing — skipping directories, symlinks, hidden entries, and the
reserved names compared case-insensitively (on APFS, Index.md IS the
index). The relocation rides one performWrite bracket at the tail of
every successful reload, which makes lock deferral free: the reload
that lifts a read-only lock is the reload that relocates. A
lane/card/filename memo keeps a failing relocation from hot-looping —
one one-shot, then silence until disk changes. The notice rides the
loss-row class, phrasing folded by BannerCenter (one file, one card's
files, a multi-card sweep), naming original filenames per the
importAttachment rule. Paste normalizes at the import boundary: staged
snapshots' loose files land in the pasted card's attachments silently,
every arrival path declaring its side via an explicit
normalizingLooseFiles parameter — drag paths decline and fall back to
the destination's own carve-out. checkIsCardFolder closes the hole
where a lane's notes.txt would have been relocated: card depth is
exact, UUID under UUID.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 09:09:31 -04:00

497 lines
22 KiB
Swift

import Foundation
import Testing
@testable import Kanban
// Golden fixture-board suite (01-storage-format.md, 02-architecture.md § Testing): real
// on-disk folder trees under `Fixtures/`, one board per tolerated/valid case and one per
// fail-fast case, asserted against `BoardLoader`. Complements `BoardLoaderTests.swift`'s
// synthetic smoke coverage — this suite is the comprehensive, hand-authored, disk-backed
// counterpart.
// MARK: - Bundle resource resolution
/// A tiny anchor class purely so `Bundle(for:)` can find the test bundle — there is no
/// `Bundle.module` in an xcodeproj target (that's an SPM-only convenience).
private final class FixtureBundleAnchor {}
/// The `Fixtures/` folder reference, copied into the test bundle's resources verbatim
/// (`project.yml`'s `KanbanTests` target). Real directories on disk, not synthesized strings.
private func fixturesRoot() -> URL {
guard let resources = Bundle(for: FixtureBundleAnchor.self).resourceURL else {
fatalError("test bundle has no resourceURL")
}
return resources.appendingPathComponent("Fixtures", isDirectory: true)
}
private func fixtureBoard(_ relativePath: String) -> URL {
fixturesRoot().appendingPathComponent(relativePath, isDirectory: true)
}
private func loadFixture(_ relativePath: String) throws -> LoadResult {
try BoardLoader.load(boardRoot: fixtureBoard(relativePath))
}
/// Every `index.md` beneath `root`, found by walking the real tree — used by the round-trip
/// assertions, which don't want to hardcode which files exist.
private func allIndexMdFiles(under root: URL) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
var results: [URL] = []
for case let url as URL in enumerator where url.lastPathComponent == "index.md" {
results.append(url)
}
return results
}
private func iso8601(_ text: String) -> Date {
guard let date = ISO8601DateFormatter().date(from: text) else {
fatalError("bad test fixture: '\(text)' is not ISO-8601")
}
return date
}
private func expectFixtureFailure(
_ relativePath: String,
path: String,
reasonDescription: String,
_ matches: (BoardLoadError.Reason) -> Bool
) {
do {
_ = try loadFixture(relativePath)
Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded")
} catch let error as BoardLoadError {
#expect(error.path == path, "\(relativePath): wrong path in error")
#expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
} catch {
Issue.record("\(relativePath): expected a BoardLoadError, got \(error)")
}
}
// MARK: - Valid/rich-board.kanban
private enum RichBoard {
static let laneDoing = "10000000-0000-4000-8000-000000000001"
static let laneDone = "20000000-0000-4000-8000-000000000002"
static let cardTaxonomy = "30000000-0000-4000-8000-000000000003"
static let cardSecond = "40000000-0000-4000-8000-000000000004"
static let cardShip = "50000000-0000-4000-8000-000000000005"
}
struct FixtureRichBoardTests {
@Test func loadsFullShapeWithStylingAndNoWarnings() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let model = result.model
#expect(model.title.value == "Rich Demo Board")
#expect(model.background.value == "#1E1E1E")
#expect(model.icon.value == "rectangle.stack.fill")
#expect(model.iconColor.value == "purple")
#expect(model.modifiedBy.value == "claude")
if case let .mapping(pairs) = model.template {
#expect(pairs.count == 1)
} else {
Issue.record("expected board template to be a mapping, got \(String(describing: model.template))")
}
#expect(model.lanes.map(\.id.rawValue) == [RichBoard.laneDoing, RichBoard.laneDone])
#expect(result.warnings.isEmpty)
let doing = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
#expect(doing.title.value == "Doing")
#expect(doing.width.value == 2)
#expect(doing.background.value == "#3478F6")
#expect(doing.cards.map(\.id.rawValue) == [RichBoard.cardTaxonomy, RichBoard.cardSecond])
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
#expect(taxonomy.title.value == "Design the fixture taxonomy")
#expect(taxonomy.modifiedBy.value == "claude")
#expect(taxonomy.body.contains("attachments/ holds a sketch"))
let done = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
#expect(done.cards.map(\.id.rawValue) == [RichBoard.cardShip])
#expect(done.cards[0].title.value == "Ship v1")
}
/// `attachments/` is **flat** (01-storage-format.md § Attachments): the card's listing is its
/// top-level regular files and nothing else. This card's folder holds all four shapes on real
/// disk — two ordinary files, a hidden one, and a subfolder with a file in it — so the rule is
/// asserted against a filesystem rather than against a mock.
///
/// The excluded three are excluded for three different reasons and only one of them is stated
/// in the design doc: subfolders are "tolerated, preserved verbatim … and not surfaced"; the
/// hidden file is the loader's uniform `.skipsHiddenFiles` stance (a `.DS_Store` is not
/// anyone's attachment); symlinks are the loader's never-resolve stance, covered in
/// `BoardLoaderTests` because git cannot carry that shape into a fixture reliably.
@Test func aCardListsOnlyTheTopLevelFilesOfItsAttachmentsFolder() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
#expect(taxonomy.attachments == ["notes.txt", "sketch.png"])
}
/// The overwhelmingly common shape: no `attachments/` folder at all. It reads as an empty
/// listing, never as a warning or a failure — nothing has been attached yet is an ordinary
/// state, and it is what makes the face's paperclip indicator absent by default.
@Test func cardsWithoutAnAttachmentsFolderListNothing() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
let second = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardSecond })
let done = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
#expect(second.attachments.isEmpty)
#expect(done.cards.map(\.attachments) == [[]])
#expect(result.warnings.isEmpty)
}
@Test func boardUnknownAndReservedKeysPreserveOrder() throws {
let result = try loadFixture("Valid/rich-board.kanban")
// schema-owned keys (schema, title, created, modified, modified-by, background, icon,
// iconColor) are filtered out; only the agent-overlay and reserved keys remain, in the
// order they were written.
#expect(result.model.document.unknownFields.map(\.key) == ["project", "sphere", "labels", "template"])
}
/// The whole-tree round-trip guarantee (01-storage-format.md § Fractal layout: "the app
/// never reformats a body it didn't change"): every `index.md` under the rich board parses
/// and re-serializes to its original bytes, untouched.
@Test func everyIndexMdInTheTreeRoundTripsByteIdentically() throws {
let root = fixtureBoard("Valid/rich-board.kanban")
let files = try allIndexMdFiles(under: root)
#expect(files.count == 7) // board + 2 lanes + 3 cards + the one comment folder's index.md
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
let document = try FrontmatterDocument.parse(text)
#expect(document.serialized() == text, "\(file.path) did not round-trip byte-identically")
}
}
}
// MARK: - Valid/interrupted-create.kanban
struct FixtureInterruptedCreateTests {
@Test func indexlessFoldersAreSkippedNotFailed() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let laneInterrupted = "20000000-0000-4000-8000-000000000002"
let card = "30000000-0000-4000-8000-000000000003"
let cardInterrupted = "40000000-0000-4000-8000-000000000004"
let result = try loadFixture("Valid/interrupted-create.kanban")
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.count == 2)
#expect(result.warnings.contains(.missingIndex(path: laneInterrupted)))
#expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(cardInterrupted)")))
}
}
// MARK: - Valid/non-uuid-strays.kanban
struct FixtureNonUUIDStraysTests {
@Test func nonUUIDFoldersAreStraysAtEveryDepthRegardlessOfIndex() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/non-uuid-strays.kanban")
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.count == 4)
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo-notes")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "scratch")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/draft")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/wip")))
}
}
// MARK: - Valid/stray-files.kanban
struct FixtureStrayFilesTests {
@Test func strayFilesEverywhereProduceNoWarningsAndDontAffectTheModel() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/stray-files.kanban")
#expect(result.warnings.isEmpty)
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
/// The board's card-level `scratch.md` is the **one** stray this fixture holds that is not
/// tolerated: the loose-file carve-out (01-storage-format.md § Fractal layout ▸ Rules, settled
/// 2026-07-28) says a regular file beside a card's `index.md` belongs in `attachments/`. It is
/// reported on its own channel — never as a `warning`, which is the *tolerance* vocabulary —
/// and the board-level and lane-level strays around it stay exactly as tolerated as they were.
///
/// **Detection does not mutate**: this is the loader, over a fixture that lives in git, and the
/// assertion that the file is still there afterwards is the read-only claim stated on the one
/// tree where a stray write would be visible in `git status`.
@Test func aCardLevelLooseFileIsReportedForRelocationWithoutBeingTouched() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/stray-files.kanban")
#expect(result.warnings.isEmpty)
#expect(result.looseCardFiles == [
LooseCardFiles(
laneID: ItemID(rawValue: lane),
cardID: ItemID(rawValue: card),
title: result.model.lanes[0].cards[0].title.value,
fileNames: ["scratch.md"]
),
])
let scratch = fixtureBoard("Valid/stray-files.kanban")
.appendingPathComponent("\(lane)/\(card)/scratch.md")
#expect(FileManager.default.fileExists(atPath: scratch.path))
#expect(!FileManager.default.fileExists(
atPath: fixtureBoard("Valid/stray-files.kanban")
.appendingPathComponent("\(lane)/\(card)/attachments").path
))
}
}
// MARK: - Valid/tombstones.kanban
struct FixtureTombstonesTests {
@Test func tombstonedLaneAndCardStayInTheSnapshotFlagged() throws {
let laneLive = "10000000-0000-4000-8000-000000000001"
let laneDead = "20000000-0000-4000-8000-000000000002"
let cardLive = "30000000-0000-4000-8000-000000000003"
let cardDead = "40000000-0000-4000-8000-000000000004"
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/tombstones.kanban")
#expect(result.warnings.isEmpty)
#expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead])
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
#expect(live.isDeleted == false)
#expect(live.cards.map(\.id.rawValue) == [cardLive, cardDead])
#expect(live.cards.map(\.isDeleted) == [false, true])
let dead = try #require(result.model.lanes.first { $0.id.rawValue == laneDead })
#expect(dead.isDeleted == true)
#expect(dead.cards.map(\.id.rawValue) == [cardUnderDeadLane])
// A tombstoned lane doesn't propagate deletion onto its children's own flag — the
// loader is structural, not recursive; hiding an ancestor's tombstoned subtree is a
// rendering concern, not a load-time one.
#expect(dead.cards[0].isDeleted == false)
}
}
// MARK: - Valid/duplicate-order-tie-break.kanban
struct FixtureDuplicateOrderTieBreakTests {
@Test func tiedLanesAndTiedCardsBreakByFolderNameAscending() throws {
let laneA = "10000000-0000-4000-8000-000000000001"
let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
let laneBBB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
let cardX = "10000000-0000-4000-8000-000000000001"
let cardY = "20000000-0000-4000-8000-000000000002"
let cardZ = "30000000-0000-4000-8000-000000000003"
let result = try loadFixture("Valid/duplicate-order-tie-break.kanban")
// laneA (order 1024) first; laneAAA and laneBBB tie at 2048, broken 'a' < 'b'.
#expect(result.model.lanes.map(\.id.rawValue) == [laneA, laneAAA, laneBBB])
let lane = try #require(result.model.lanes.first { $0.id.rawValue == laneA })
#expect(lane.cards.map(\.id.rawValue) == [cardX, cardY, cardZ])
#expect(Set(lane.cards.map(\.order)) == [1024])
}
}
// MARK: - Valid/unknown-key-order.kanban
struct FixtureUnknownKeyOrderTests {
@Test func unknownKeysPreserveDocumentOrderAtEveryLevel() throws {
let result = try loadFixture("Valid/unknown-key-order.kanban")
let model = result.model
#expect(model.document.unknownFields.map(\.key) == ["project", "sphere", "template", "labels", "custom-note"])
let lane = try #require(model.lanes.first)
#expect(lane.document.unknownFields.map(\.key) == ["remote-state", "assignees", "due", "custom"])
let card = try #require(lane.cards.first)
#expect(card.document.unknownFields.map(\.key) == ["labels", "assignees", "due", "remote", "agent-scratch"])
}
@Test func everyIndexMdRoundTripsByteIdentically() throws {
let root = fixtureBoard("Valid/unknown-key-order.kanban")
for file in try allIndexMdFiles(under: root) {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Valid/coercion.kanban
struct FixtureCoercionTests {
@Test func wrongTypeScalarsCoerceOrFallBackToDefaultPerField() throws {
let lane1 = "10000000-0000-4000-8000-000000000001"
let lane2 = "20000000-0000-4000-8000-000000000002"
let cardTitleInt = "30000000-0000-4000-8000-000000000003"
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
let cardBackgroundInt = "70000000-0000-4000-8000-000000000007"
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
let result = try loadFixture("Valid/coercion.kanban")
let model = result.model
let laneWidthCoerces = try #require(model.lanes.first { $0.id.rawValue == lane1 })
#expect(laneWidthCoerces.width == .valid(3)) // width: "3" (quoted string) coerces
let laneWidthMalformed = try #require(model.lanes.first { $0.id.rawValue == lane2 })
#expect(laneWidthMalformed.width == .malformed(raw: "1.5")) // non-integer, no sensible width
func card(_ id: String) throws -> Card {
try #require(laneWidthCoerces.cards.first { $0.id.rawValue == id })
}
#expect(try card(cardTitleInt).title == .valid("2048"))
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
#expect(try card(cardBackgroundInt).background == .valid("12345"))
let deletedBad = try card(cardDeletedBad)
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
// Presence outranks validity: an unusable timestamp still tombstones.
#expect(deletedBad.isDeleted == true)
}
}
// MARK: - Valid/duplicate-top-level-keys.kanban
struct FixtureDuplicateTopLevelKeysTests {
@Test func lastOccurrenceWinsAtBoardLaneAndCardLevel() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/duplicate-top-level-keys.kanban")
#expect(result.warnings.isEmpty) // NOT a fail-fast case, per the newer design-doc rule
#expect(result.model.title == .valid("Final Name"))
let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane })
#expect(loadedLane.order == 4096) // duplicated 'order' — a strict field — still last-wins
let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card })
#expect(loadedCard.title == .valid("Second Title"))
}
/// Earlier occurrences of a duplicated key are invisible to every read but still preserved
/// verbatim on disk (01-storage-format.md § Frontmatter) — proven by round-tripping every
/// file in this board, not just asserting the winning value.
@Test func earlierOccurrencesSurviveOnDiskViaRoundTrip() throws {
let root = fixtureBoard("Valid/duplicate-top-level-keys.kanban")
let files = try allIndexMdFiles(under: root)
#expect(files.count == 3)
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Valid/board-level-deleted.kanban
struct FixtureBoardLevelDeletedTests {
@Test func boardLevelDeletedIsIgnoredButRestOfBoardLoadsNormally() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/board-level-deleted.kanban")
#expect(result.warnings.contains(.boardLevelDeletedIgnored))
#expect(result.model.deleted == .valid(iso8601("2026-01-01T00:00:00Z")))
// Meaningless at board level, but never blanks the board — the rest loads as usual.
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
}
// MARK: - Malformed/*.kanban — fail-fast cases
struct FixtureMalformedTests {
@Test func unparseableYAML() {
expectFixtureFailure("Malformed/unparseable-yaml.kanban", path: "index.md", reasonDescription: "unparseableYAML") {
if case .unparseableYAML = $0 { true } else { false }
}
}
@Test func missingSchema() {
expectFixtureFailure("Malformed/missing-schema.kanban", path: "index.md", reasonDescription: "missingSchema") {
$0 == .missingSchema
}
}
@Test func schemaNewerThanApp() {
expectFixtureFailure(
"Malformed/schema-newer-than-app.kanban", path: "index.md", reasonDescription: "schemaNewerThanApp(2)"
) {
$0 == .schemaNewerThanApp(found: 2)
}
}
@Test func missingOrderOnLane() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/missing-order-lane.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func missingOrderOnCard() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/missing-order-card.kanban", path: "\(lane)/\(card)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
/// Explicit null reads as missing (01-storage-format.md § Malformed input): `order:` with
/// nothing after it fails the same way a missing key does, not as `.malformedOrder`.
@Test func explicitNullOrderReadsAsMissing() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/explicit-null-order.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func presentButNonNumericOrder() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/non-numeric-order.kanban",
path: "\(lane)/\(card)/index.md",
reasonDescription: "malformedOrder(banana)"
) {
$0 == .malformedOrder(raw: "banana")
}
}
@Test func boardRootMissingIndex() {
expectFixtureFailure(
"Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex"
) {
$0 == .boardRootMissingIndex
}
}
}