Build the template engine — board-as-template instantiation
A template is a board folder the ordinary loader reads — no second schema, no Swift catalog. BoardTemplate became exactly that: a loaded BoardModel with chooser-facing derivations, the lane-title stub gone. TemplateEngine instantiates by the copy-remint-restamp walk: .git and .trash excluded at top level only — both names mean something at a board root and nowhere else, and .gitignore must survive — every materialized folder reminted, created/modified stamped fresh (born today, not forked), modified-by cleared, the template: key carried inert, the blurb and style inherited, and loose card files normalized at this import boundary per the paste precedent so a new board never opens with a notice about a mess its own birth made. Legacy deleted: keys copy through verbatim to the one migrator — stripping would resurrect, skipping would destroy. Atomicity is construct-then-clean: a sibling temp can be sandbox-refused and a cross-volume rename is just a second copy, so the call removes what it created on every non-board exit and never touches an occupied destination. The cancellable per-item walk extracted into BoardTreeCopy serves Duplicate and instantiation with two parameters — top-level exclusions and folder-attribute carriage, the only axes they differ on. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -2,118 +2,168 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// Template instantiation — the on-disk half of File ▸ New Board… (09-templates.md ▸ Instantiation).
|
||||
/// Template **discovery** — the bundled store, and what a `BoardTemplate` reads off a real board
|
||||
/// folder (09-templates.md ▸ Definition format).
|
||||
///
|
||||
/// The window flow around it (the chooser, `NSSavePanel`, the open that follows) is untestable
|
||||
/// without a screen and deliberately holds no rules of its own; everything that *is* a rule — what
|
||||
/// gets written, what the board is called, and the order the lanes land in — lives in
|
||||
/// `BoardTemplate.instantiate(at:)` and is checked here against a real temp folder, through the
|
||||
/// app's own loader.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// An empty temp folder to create *into* — the save panel's answer, minus the panel.
|
||||
private func temporaryLocation(named name: String) throws -> (url: URL, tearDown: () -> Void) {
|
||||
let container = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("BoardTemplateTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true)
|
||||
return (container.appendingPathComponent(name, isDirectory: true), {
|
||||
try? FileManager.default.removeItem(at: container)
|
||||
})
|
||||
}
|
||||
/// The instantiation half lives in `TemplateEngineTests.swift`. What this file pins is 09's
|
||||
/// "testable for free" clause: "template validity is enforced by the loader's own fail-fast rules at
|
||||
/// test time (walk `Templates/`, load each)".
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Suite("BoardTemplate")
|
||||
struct BoardTemplateTests {
|
||||
|
||||
@Test("Basic writes a board and its three lanes, in order")
|
||||
func basicInstantiatesInOrder() throws {
|
||||
let location = try temporaryLocation(named: "Roadmap.kanban")
|
||||
defer { location.tearDown() }
|
||||
@Test("Every bundled template folder loads through the ordinary loader")
|
||||
func everyBundledTemplateLoads() throws {
|
||||
let store = try #require(TemplateEngine.bundledStore)
|
||||
let folders = TemplateEngine.templateFolders(in: store)
|
||||
#expect(!folders.isEmpty, "the app ships at least one template")
|
||||
|
||||
try BoardTemplate.basic.instantiate(at: location.url)
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: location.url)
|
||||
#expect(result.warnings.isEmpty, "a board this app just wrote must load clean")
|
||||
#expect(result.model.lanes.map { $0.title.value } == ["To Do", "Doing", "Done"])
|
||||
#expect(result.model.lanes.allSatisfy { !$0.isDeleted })
|
||||
#expect(result.model.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards")
|
||||
for folder in folders {
|
||||
let result = TemplateEngine.load(templateAt: folder, origin: .bundled)
|
||||
if case let .failure(error) = result {
|
||||
Issue.record("bundled template \(folder.lastPathComponent) failed to load: \(error.description)")
|
||||
}
|
||||
}
|
||||
#expect(TemplateEngine.bundledTemplates().count == folders.count)
|
||||
}
|
||||
|
||||
@Test("The lanes are ranked by the board convention — 1024 apart, from 1024")
|
||||
func lanesAreRankedByTheAppendConvention() throws {
|
||||
let location = try temporaryLocation(named: "Ranked.kanban")
|
||||
defer { location.tearDown() }
|
||||
@Test("Basic is bundled, first, and the plain To Do / Done scaffold")
|
||||
func basicIsTheFirstBundledTemplate() throws {
|
||||
let basic = try #require(TemplateEngine.bundledTemplates().first)
|
||||
|
||||
try BoardTemplate.basic.instantiate(at: location.url)
|
||||
|
||||
let orders = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.order)
|
||||
#expect(orders == [1024, 2048, 3072], "each lane appends after the one before it")
|
||||
#expect(basic.slug == "basic", "09 calls the bundle folder name the template's stable slug")
|
||||
#expect(basic.origin == .bundled)
|
||||
#expect(basic.name == "Basic")
|
||||
#expect(basic.lanes.map { $0.title.value } == ["To Do", "Done"])
|
||||
#expect(basic.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards")
|
||||
}
|
||||
|
||||
@Test("The board's title is the document name the user chose, not the template's")
|
||||
func titleComesFromTheChosenName() throws {
|
||||
let location = try temporaryLocation(named: "Q3 Planning.kanban")
|
||||
defer { location.tearDown() }
|
||||
@Test("Name, blurb, icon and chooser order all come off the template board's own index.md")
|
||||
func pickerMetadataIsReadFromTheBoard() throws {
|
||||
let basic = try #require(TemplateEngine.bundledTemplates().first)
|
||||
|
||||
try BoardTemplate.basic.instantiate(at: location.url)
|
||||
|
||||
// 01-storage-format.md § Board naming: display name and folder name start out matching.
|
||||
#expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Q3 Planning")
|
||||
#expect(basic.name == basic.model.title.value)
|
||||
#expect(!basic.blurb.isEmpty, "the body is the picker blurb and the new board's description")
|
||||
#expect(basic.blurb == basic.model.document.body.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
#expect(basic.icon == basic.model.icon.value)
|
||||
#expect(basic.iconColor == basic.model.iconColor.value)
|
||||
#expect(basic.order == 100, "template: {order: 100} is the chooser position")
|
||||
}
|
||||
|
||||
@Test("An extension-less location is as legal a board, and keeps its whole name as the title")
|
||||
func extensionlessLocationWorks() throws {
|
||||
let location = try temporaryLocation(named: "Plain")
|
||||
defer { location.tearDown() }
|
||||
@Test("An icon the running system cannot draw falls back to the board default")
|
||||
func unknownIconFallsBackToTheDefault() throws {
|
||||
let fixture = try TemplateFixture(named: "Odd Icon")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.board(icon: "not.a.real.symbol.name")
|
||||
|
||||
try BoardTemplate.basic.instantiate(at: location.url)
|
||||
let template = try fixture.template()
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Plain")
|
||||
#expect(template.icon == ItemSymbol.board)
|
||||
}
|
||||
|
||||
@Test("Every lane folder is a fresh lowercase UUID")
|
||||
func laneFoldersAreMintedIdentities() throws {
|
||||
let location = try temporaryLocation(named: "Minted.kanban")
|
||||
defer { location.tearDown() }
|
||||
// MARK: template.order
|
||||
|
||||
try BoardTemplate.basic.instantiate(at: location.url)
|
||||
@Test("A keyless board dropped in the store is a template with no order")
|
||||
func keylessTemplateHasNoOrder() throws {
|
||||
let fixture = try TemplateFixture(named: "Hand Dropped")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.board()
|
||||
|
||||
let ids = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.id.rawValue)
|
||||
#expect(ids.count == 3)
|
||||
#expect(Set(ids).count == 3, "three lanes, three identities")
|
||||
#expect(ids.allSatisfy { $0 == $0.lowercased() }, "the app emits lowercase UUIDs")
|
||||
#expect(ids.allSatisfy { UUID(uuidString: $0) != nil })
|
||||
#expect(try fixture.template().order == nil, "no template: key is required of a user template")
|
||||
}
|
||||
|
||||
@Test("Instantiating over an existing board refuses rather than clobbering it")
|
||||
func refusesToOverwriteAnExistingBoard() throws {
|
||||
let location = try temporaryLocation(named: "Taken.kanban")
|
||||
defer { location.tearDown() }
|
||||
try BoardTemplate.basic.instantiate(at: location.url)
|
||||
let before = try Data(contentsOf: location.url.appendingPathComponent("index.md"))
|
||||
@Test("A malformed template: key reads as keyless rather than as a failure")
|
||||
func malformedTemplateKeyIsKeyless() throws {
|
||||
let fixture = try TemplateFixture(named: "Odd Key")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.board(extraKeys: "template: [1, 2]")
|
||||
|
||||
let error = writeFailure { try BoardTemplate.basic.instantiate(at: location.url) }
|
||||
|
||||
#expect(error?.operation == .createBoard)
|
||||
#expect(
|
||||
try Data(contentsOf: location.url.appendingPathComponent("index.md")) == before,
|
||||
"the existing board is untouched — a create never replaces one"
|
||||
)
|
||||
#expect(try fixture.template().order == nil)
|
||||
}
|
||||
|
||||
@Test("The document name behind a chosen URL is the folder name without its extension")
|
||||
func documentNameStripsTheExtension() {
|
||||
#expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap.kanban")) == "Roadmap")
|
||||
#expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap")) == "Roadmap")
|
||||
@Test("A float order is as good as an integer one")
|
||||
func floatOrderReads() throws {
|
||||
let fixture = try TemplateFixture(named: "Float")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.board(extraKeys: "template: {order: 12.5}")
|
||||
|
||||
#expect(try fixture.template().order == 12.5)
|
||||
}
|
||||
|
||||
@Test("The chooser offers Basic, and Basic is first")
|
||||
func inventoryHoldsBasicFirst() {
|
||||
// m9-templates: this becomes the bundled inventory's ten, with Basic still first
|
||||
// (09-templates.md ▸ Inventory).
|
||||
#expect(BoardTemplate.all.first == BoardTemplate.basic)
|
||||
#expect(BoardTemplate.basic.slug == "basic")
|
||||
// MARK: Chooser order
|
||||
|
||||
@Test("Chooser order is template.order, then display name for the keyless tier")
|
||||
func chooserOrderIsKeyedThenNamed() throws {
|
||||
let fixture = try TemplateFixture(named: "Sorting")
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let second = try fixture.freeStandingBoard(named: "b", title: "Second", extraKeys: "template: {order: 200}")
|
||||
let first = try fixture.freeStandingBoard(named: "a", title: "First", extraKeys: "template: {order: 100}")
|
||||
let zebra = try fixture.freeStandingBoard(named: "z", title: "Zebra")
|
||||
let apple = try fixture.freeStandingBoard(named: "y", title: "Apple")
|
||||
|
||||
let sorted = TemplateEngine.sortedForChooser([zebra, second, apple, first])
|
||||
|
||||
#expect(sorted.map(\.name) == ["First", "Second", "Apple", "Zebra"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// A temp store holding hand-written template board folders — the user store's shape, minus
|
||||
/// Application Support (which no test may touch).
|
||||
struct TemplateFixture {
|
||||
|
||||
let store: URL
|
||||
let boardURL: URL
|
||||
|
||||
init(named name: String) throws {
|
||||
store = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("TemplateTests-\(UUID().uuidString)", isDirectory: true)
|
||||
boardURL = store.appendingPathComponent("\(name).kanban", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
try? FileManager.default.removeItem(at: store)
|
||||
}
|
||||
|
||||
/// The fixture's own board, with one lane so it is a board worth previewing.
|
||||
func board(title: String = "Fixture", icon: String? = nil, extraKeys: String = "") throws {
|
||||
try write(board: boardURL, title: title, icon: icon, extraKeys: extraKeys)
|
||||
}
|
||||
|
||||
/// A second board in the same store — the sorting tests need several.
|
||||
func freeStandingBoard(named name: String, title: String, extraKeys: String = "") throws -> BoardTemplate {
|
||||
let url = store.appendingPathComponent("\(name).kanban", isDirectory: true)
|
||||
try write(board: url, title: title, icon: nil, extraKeys: extraKeys)
|
||||
return try loadTemplate(at: url)
|
||||
}
|
||||
|
||||
func template() throws -> BoardTemplate {
|
||||
try loadTemplate(at: boardURL)
|
||||
}
|
||||
|
||||
private func loadTemplate(at url: URL) throws -> BoardTemplate {
|
||||
switch TemplateEngine.load(templateAt: url, origin: .user) {
|
||||
case let .success(template): return template
|
||||
case let .failure(error): throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func write(board url: URL, title: String, icon: String?, extraKeys: String) throws {
|
||||
let lane = url.appendingPathComponent("11111111-1111-4111-8111-111111111111", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: lane, withIntermediateDirectories: true)
|
||||
|
||||
var keys = ["schema: 1", "title: \(title)"]
|
||||
if let icon { keys.append("icon: \(icon)") }
|
||||
if !extraKeys.isEmpty { keys.append(extraKeys) }
|
||||
|
||||
try Data("---\n\(keys.joined(separator: "\n"))\n---\nBlurb.\n".utf8)
|
||||
.write(to: url.appendingPathComponent("index.md"))
|
||||
try Data("---\nschema: 1\ntitle: Lane\norder: 1024\n---\n".utf8)
|
||||
.write(to: lane.appendingPathComponent("index.md"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// Template **instantiation** — "create a board from this template", on disk (09-templates.md
|
||||
/// ▸ Instantiation).
|
||||
///
|
||||
/// The round trip is the heart of it: instantiate, then load the result through the ordinary
|
||||
/// `BoardLoader` and ask whether what came back is a board born today. Everything 09 promises is an
|
||||
/// assertion about those bytes —
|
||||
///
|
||||
/// - copied, **minus `.trash/` and `.git`**, and minus nothing else;
|
||||
/// - **fresh GUIDs** at every level — no identity survives from the template;
|
||||
/// - **fresh `created`/`modified`**, `modified-by` cleared — born today, not forked;
|
||||
/// - `title` = the name the user typed into the save panel;
|
||||
/// - `template:` carried and inert; strays, bodies and attachment bytes verbatim.
|
||||
///
|
||||
/// — plus the promise that makes it safe to run at all: **nothing half-made is ever left at the
|
||||
/// destination**, whether the user cancelled or the disk said no.
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// `writeFailure`'s twin for the engine's two-outcome vocabulary.
|
||||
private func instantiationFailure(_ operation: () throws -> Void) -> TemplateEngine.Failure? {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("expected the instantiation to fail, but it succeeded")
|
||||
return nil
|
||||
} catch let failure as TemplateEngine.Failure {
|
||||
return failure
|
||||
} catch {
|
||||
Issue.record("expected a TemplateEngine.Failure, got \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Every path under `root`, root-relative, hidden entries included.
|
||||
private func tree(of root: URL) -> Set<String> {
|
||||
var paths: Set<String> = []
|
||||
let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
|
||||
while let url = walker?.nextObject() as? URL {
|
||||
paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/// Every UUID-shaped folder name under `root` — the identities a copy either kept or reminted.
|
||||
private func identities(under root: URL) -> Set<String> {
|
||||
Set(tree(of: root)
|
||||
.flatMap { $0.split(separator: "/").map(String.init) }
|
||||
.filter(BoardLoader.isUUIDShaped)
|
||||
.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
/// The two GUIDs the bundled Basic template ships with — hard-coded on purpose: "no id survives
|
||||
/// from the template" is only an assertion if the test knows the ids it is looking for.
|
||||
private enum BundledBasic {
|
||||
static let toDo = "3f2a9c41-6b0e-4d7a-9f31-2c8b5e0a7d14"
|
||||
static let done = "8d3b1f22-4a7c-4e59-8b06-1d9f3c2e6a85"
|
||||
}
|
||||
|
||||
private func bundledBasic() throws -> BoardTemplate {
|
||||
try #require(TemplateEngine.bundledTemplates().first { $0.slug == "basic" })
|
||||
}
|
||||
|
||||
// MARK: - The hand-built template
|
||||
|
||||
/// A template carrying everything 09 and 01 have a rule about: the two exclusions, a legacy
|
||||
/// `deleted:` key, strays at board and lane level, a symlink, an attachment, and a loose file
|
||||
/// beside a card's `index.md`.
|
||||
private struct FixtureTemplate {
|
||||
|
||||
let fixture: WriterFixture
|
||||
let root: URL
|
||||
|
||||
static let name = "Fixture.kanban"
|
||||
static let blurb = "A fixture blurb, which becomes the new board's description.\n"
|
||||
|
||||
init() throws {
|
||||
fixture = try WriterFixture()
|
||||
root = fixture.url(Self.name)
|
||||
let path = Self.name
|
||||
|
||||
// The board: a template key, an agent overlay, old stamps, and someone else's attribution.
|
||||
try fixture.item(path, """
|
||||
---
|
||||
schema: 1
|
||||
title: Fixture Template
|
||||
icon: rectangle.split.3x1
|
||||
iconColor: fern
|
||||
template: {order: 42}
|
||||
project: lanework
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
\(Self.blurb)
|
||||
""")
|
||||
|
||||
// Board-level strays — "the copy is literal apart from the stated exclusions".
|
||||
try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8))
|
||||
try fixture.file("\(path)/.gitignore", Data(".DS_Store\n".utf8))
|
||||
|
||||
// The two exclusions.
|
||||
try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||
try FileManager.default.createDirectory(
|
||||
at: root.appendingPathComponent(".git/objects", isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try fixture.item("\(path)/.trash/\(Ident.card4)", Item.rich(order: "1024", title: "Thrown Away"))
|
||||
|
||||
// A symlink, never traversed, copied as a link.
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: root.appendingPathComponent("link.md").path,
|
||||
withDestinationPath: "../outside.txt"
|
||||
)
|
||||
|
||||
// A lane with a starter card: an attachment, a loose file, and a body.
|
||||
try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do"))
|
||||
try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter"))
|
||||
try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03]))
|
||||
try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("loose\n".utf8))
|
||||
|
||||
// A card an older app version tombstoned in place — the legacy `deleted:` key.
|
||||
try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card2)", """
|
||||
---
|
||||
schema: 1
|
||||
title: Legacy Tombstone
|
||||
order: 2048
|
||||
deleted: 2026-01-01T00:00:00Z
|
||||
---
|
||||
still here
|
||||
""")
|
||||
|
||||
// A second lane with a stray folder of its own — lane-level strays stay verbatim.
|
||||
try fixture.item("\(path)/\(Ident.lane2)", Item.rich(order: "2048", title: "Done"))
|
||||
try fixture.file("\(path)/\(Ident.lane2)/notes/scratch.md", Data("scratch\n".utf8))
|
||||
}
|
||||
|
||||
func tearDown() { fixture.tearDown() }
|
||||
|
||||
func template() throws -> BoardTemplate {
|
||||
switch TemplateEngine.load(templateAt: root, origin: .user) {
|
||||
case let .success(template): return template
|
||||
case let .failure(error): throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an instantiation lands — a sibling of the template inside the same temp root, which is
|
||||
/// also what makes "nothing was left behind" a one-line assertion.
|
||||
func destination(named name: String = "New Board.kanban") -> URL {
|
||||
fixture.url(name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Round trip
|
||||
|
||||
@Suite("TemplateEngine — the bundled round trip")
|
||||
struct TemplateEngineRoundTripTests {
|
||||
|
||||
@Test("Instantiating Basic produces a board that loads clean")
|
||||
func basicLoadsClean() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Q3 Planning.kanban")
|
||||
|
||||
try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Q3 Planning")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: destination)
|
||||
#expect(result.warnings.isEmpty, "a board the app just instantiated must load without a murmur")
|
||||
#expect(result.looseCardFiles.isEmpty)
|
||||
#expect(result.legacyTombstones.isEmpty)
|
||||
#expect(result.model.lanes.map { $0.title.value } == ["To Do", "Done"])
|
||||
#expect(result.model.lanes.map(\.order) == [1024, 2048])
|
||||
}
|
||||
|
||||
@Test("The title is the document name the user chose, not the template's")
|
||||
func titleIsTheChosenName() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Q3 Planning.kanban")
|
||||
|
||||
try TemplateEngine.instantiate(
|
||||
template: try bundledBasic(),
|
||||
to: destination,
|
||||
title: TemplateEngine.documentName(of: destination)
|
||||
)
|
||||
|
||||
// 01-storage-format.md § Board naming: display name and folder name start out matching.
|
||||
#expect(try BoardLoader.load(boardRoot: destination).model.title.value == "Q3 Planning")
|
||||
}
|
||||
|
||||
@Test("An extension-less location is as legal a board, and keeps its whole name as the title")
|
||||
func extensionlessLocationWorks() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Plain")
|
||||
|
||||
try TemplateEngine.instantiate(
|
||||
template: try bundledBasic(),
|
||||
to: destination,
|
||||
title: TemplateEngine.documentName(of: destination)
|
||||
)
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: destination).model.title.value == "Plain")
|
||||
}
|
||||
|
||||
@Test("No id survives from the template — every lane folder is a fresh mint")
|
||||
func everyIdentityIsReminted() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Minted.kanban")
|
||||
|
||||
try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Minted")
|
||||
|
||||
let minted = identities(under: destination)
|
||||
#expect(minted.count == 2)
|
||||
#expect(minted.isDisjoint(with: [BundledBasic.toDo, BundledBasic.done]),
|
||||
"template GUIDs are inert — instantiation remints at its own boundary")
|
||||
#expect(minted.allSatisfy { $0 == $0.lowercased() && UUID(uuidString: $0) != nil })
|
||||
}
|
||||
|
||||
@Test("Born today: created and modified are fresh, and modified-by is absent")
|
||||
func stampsAreFresh() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Born.kanban")
|
||||
let start = Date()
|
||||
|
||||
try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Born")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
let template = try bundledBasic().model
|
||||
|
||||
let boardCreated = try #require(model.created.value)
|
||||
#expect(boardCreated.timeIntervalSince(start) > -2, "born today, not forked from the template")
|
||||
#expect(boardCreated != template.created.value)
|
||||
#expect(try #require(model.modified.value).timeIntervalSince(start) > -2)
|
||||
#expect(model.modifiedBy.isMissing)
|
||||
|
||||
for lane in model.lanes {
|
||||
#expect(try #require(lane.created.value).timeIntervalSince(start) > -2)
|
||||
#expect(try #require(lane.modified.value).timeIntervalSince(start) > -2)
|
||||
#expect(lane.modifiedBy.isMissing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The template: key is carried onto the instantiated board, inert")
|
||||
func templateKeyIsCarriedInert() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Inert.kanban")
|
||||
|
||||
try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Inert")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
let template = try bundledBasic().model
|
||||
#expect(model.template == template.template,
|
||||
"kept, ignored, and preserved like any unknown key")
|
||||
#expect(model.document.body == template.document.body,
|
||||
"the blurb becomes the new board's description, byte for byte")
|
||||
}
|
||||
|
||||
@Test("The icon and its tint are inherited from the template")
|
||||
func iconIsInherited() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let destination = fixture.url("Styled.kanban")
|
||||
let basic = try bundledBasic()
|
||||
|
||||
try TemplateEngine.instantiate(template: basic, to: destination, title: "Styled")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
#expect(model.icon.value == basic.model.icon.value)
|
||||
#expect(model.iconColor.value == basic.model.iconColor.value)
|
||||
}
|
||||
|
||||
@Test("The bundled template itself is never touched by instantiating it")
|
||||
func theTemplateIsReadOnly() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let basic = try bundledBasic()
|
||||
let before = tree(of: basic.url)
|
||||
let bytes = try Data(contentsOf: basic.url.appendingPathComponent("index.md"))
|
||||
|
||||
try TemplateEngine.instantiate(template: basic, to: fixture.url("Copy.kanban"), title: "Copy")
|
||||
|
||||
#expect(tree(of: basic.url) == before)
|
||||
#expect(try Data(contentsOf: basic.url.appendingPathComponent("index.md")) == bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The hand-built template
|
||||
|
||||
@Suite("TemplateEngine — a hand-dropped template's edges")
|
||||
struct TemplateEngineFixtureTests {
|
||||
|
||||
@Test("`.git` and `.trash/` are the two exclusions, and they are the only ones")
|
||||
func theTwoExclusionsAreExcluded() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
let paths = tree(of: destination)
|
||||
#expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") },
|
||||
"a template is content, not history — no board is silently born in git mode")
|
||||
#expect(!paths.contains { $0 == ".trash" || $0.hasPrefix(".trash/") },
|
||||
"a new board isn't born with trash")
|
||||
// And everything else did come along — `.gitignore` included, which is a stray the
|
||||
// exclusion must not swallow: the two exclusions are exact names, not prefixes.
|
||||
#expect(paths.contains("CLAUDE.user.md"))
|
||||
#expect(paths.contains(".gitignore"))
|
||||
#expect(paths.contains("link.md"))
|
||||
#expect(paths.contains { $0.hasSuffix("notes/scratch.md") }, "a lane-level stray folder is a resident")
|
||||
}
|
||||
|
||||
@Test("Strays and attachments arrive byte for byte")
|
||||
func straysAndAttachmentsAreVerbatim() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
#expect(try Data(contentsOf: destination.appendingPathComponent("CLAUDE.user.md"))
|
||||
== Data("board instructions\n".utf8))
|
||||
#expect(try Data(contentsOf: destination.appendingPathComponent(".gitignore"))
|
||||
== Data(".DS_Store\n".utf8))
|
||||
|
||||
let card = try #require(cardFolders(under: destination).first { folder in
|
||||
FileManager.default.fileExists(atPath: folder.appendingPathComponent("attachments/shot.png").path)
|
||||
})
|
||||
#expect(try Data(contentsOf: card.appendingPathComponent("attachments/shot.png")) == Data([0x01, 0x02, 0x03]))
|
||||
}
|
||||
|
||||
@Test("A card's body survives the fresh stamps — frontmatter is edited, bytes are not rewritten")
|
||||
func bodiesSurviveTheRestamp() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
let starter = try #require(model.lanes.flatMap(\.cards).first { $0.title.value == "Starter" })
|
||||
#expect(starter.document.body == "Starter body — with *markdown*.\n")
|
||||
#expect(starter.document.value(for: "project") == .string("lanework"),
|
||||
"unknown keys ride along, comment and all")
|
||||
#expect(starter.modifiedBy.isMissing, "an app-mediated write clears a foreign attribution")
|
||||
#expect(try #require(starter.created.value).timeIntervalSinceNow > -60, "born today")
|
||||
|
||||
// The board's own blurb is its description now, untouched.
|
||||
#expect(model.document.body == FixtureTemplate.blurb)
|
||||
}
|
||||
|
||||
@Test("A symlink is copied as a link, never traversed")
|
||||
func symlinksArriveAsLinks() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
let link = destination.appendingPathComponent("link.md")
|
||||
let values = try link.resourceValues(forKeys: [.isSymbolicLinkKey])
|
||||
#expect(values.isSymbolicLink == true)
|
||||
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == "../outside.txt",
|
||||
"the link itself travels, never its target")
|
||||
}
|
||||
|
||||
@Test("A loose file beside a card's index.md lands in attachments/ — instantiation is an import boundary")
|
||||
func looseCardFilesAreNormalizedOnArrival() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: destination)
|
||||
#expect(result.looseCardFiles.isEmpty,
|
||||
"the new board lands already normalized rather than opening with a notice about its own birth")
|
||||
|
||||
let starter = try #require(result.model.lanes.flatMap(\.cards).first { $0.title.value == "Starter" })
|
||||
#expect(starter.attachments == ["notes.txt", "shot.png"])
|
||||
|
||||
let card = try #require(cardFolders(under: destination).first { folder in
|
||||
FileManager.default.fileExists(atPath: folder.appendingPathComponent("attachments/notes.txt").path)
|
||||
})
|
||||
#expect(!FileManager.default.fileExists(atPath: card.appendingPathComponent("notes.txt").path))
|
||||
#expect(try Data(contentsOf: card.appendingPathComponent("attachments/notes.txt")) == Data("loose\n".utf8))
|
||||
}
|
||||
|
||||
@Test("A legacy deleted: key copies through and is the store's to migrate, not the engine's")
|
||||
func legacyTombstonesCopyThroughForTheOneMigrator() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: destination)
|
||||
let tombstoned = try #require(result.model.lanes.flatMap(\.cards).first { $0.title.value == "Legacy Tombstone" })
|
||||
#expect(tombstoned.isDeleted, "neither stripped (a silent resurrection) nor dropped (destroyed content)")
|
||||
#expect(result.legacyTombstones.count == 1,
|
||||
"the new board's first load hands it to 01's one migrator, exactly as any other board's would")
|
||||
}
|
||||
|
||||
@Test("Every identity in the tree is fresh — cards and strays under a card included")
|
||||
func everyIdentityIsReminted() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board")
|
||||
|
||||
let minted = identities(under: destination)
|
||||
#expect(minted.count == 4, "two lanes and two cards — the trashed card was not copied")
|
||||
#expect(minted.isDisjoint(with: identities(under: source.root)))
|
||||
}
|
||||
|
||||
@Test("The template survives its own instantiation untouched")
|
||||
func theTemplateIsNeverWritten() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let before = tree(of: source.root)
|
||||
let boardBytes = try Data(contentsOf: source.root.appendingPathComponent("index.md"))
|
||||
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: source.destination(), title: "New Board")
|
||||
|
||||
#expect(tree(of: source.root) == before, "the loose file was normalized in the copy, not in the template")
|
||||
#expect(try Data(contentsOf: source.root.appendingPathComponent("index.md")) == boardBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nothing half-made
|
||||
|
||||
@Suite("TemplateEngine — the destination is all or nothing")
|
||||
struct TemplateEngineAtomicityTests {
|
||||
|
||||
@Test("Cancelling mid-walk leaves nothing at the destination")
|
||||
func cancellingRemovesThePartialBoard() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination()
|
||||
|
||||
// Trips on the fourth read: the destination exists by then and the walk is inside it, so
|
||||
// there is a genuine partial tree to remove rather than nothing to clean up.
|
||||
var reads = 0
|
||||
let failure = instantiationFailure {
|
||||
try TemplateEngine.instantiate(
|
||||
template: try source.template(),
|
||||
to: destination,
|
||||
title: "New Board",
|
||||
isCancelled: {
|
||||
reads += 1
|
||||
return reads > 3
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#expect(failure == TemplateEngine.Failure.cancelled)
|
||||
#expect(!FileManager.default.fileExists(atPath: destination.path),
|
||||
"a cancelled create never happened")
|
||||
#expect(try Set(source.fixture.entryNames("")) == [FixtureTemplate.name])
|
||||
}
|
||||
|
||||
@Test("Cancelling before the first item never creates the destination at all")
|
||||
func cancellingBeforeTheWalkCreatesNothing() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
|
||||
let failure = instantiationFailure {
|
||||
try TemplateEngine.instantiate(
|
||||
template: try source.template(),
|
||||
to: source.destination(),
|
||||
title: "New Board",
|
||||
isCancelled: { true }
|
||||
)
|
||||
}
|
||||
|
||||
#expect(failure == TemplateEngine.Failure.cancelled)
|
||||
#expect(try Set(source.fixture.entryNames("")) == [FixtureTemplate.name])
|
||||
}
|
||||
|
||||
@Test("A destination that is already taken is refused cleanly, and never clobbered")
|
||||
func collisionIsRefusedWithoutTouchingWhatIsThere() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let destination = source.destination(named: "Taken.kanban")
|
||||
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||
try Data("mine\n".utf8).write(to: destination.appendingPathComponent("index.md"))
|
||||
|
||||
let failure = instantiationFailure {
|
||||
try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "Taken")
|
||||
}
|
||||
|
||||
guard case let .failed(error) = failure else {
|
||||
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
||||
return
|
||||
}
|
||||
#expect(error.operation == .createBoard)
|
||||
#expect(error.path == destination.path)
|
||||
#expect(try Data(contentsOf: destination.appendingPathComponent("index.md")) == Data("mine\n".utf8),
|
||||
"an existing name is the user's — a create never replaces one")
|
||||
#expect(try Set(FileManager.default.contentsOfDirectory(atPath: destination.path)) == ["index.md"])
|
||||
}
|
||||
|
||||
@Test("A template that is not there fails as a create, naming the path")
|
||||
func aMissingTemplateFailsCleanly() throws {
|
||||
let source = try FixtureTemplate()
|
||||
defer { source.tearDown() }
|
||||
let template = try source.template()
|
||||
try FileManager.default.removeItem(at: source.root)
|
||||
let destination = source.destination()
|
||||
|
||||
let failure = instantiationFailure {
|
||||
try TemplateEngine.instantiate(template: template, to: destination, title: "New Board")
|
||||
}
|
||||
|
||||
guard case .failed = failure else {
|
||||
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
||||
return
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: destination.path),
|
||||
"the destination this call made goes with the failure")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
|
||||
/// Every card folder under an instantiated board — `<root>/<lane>/<card>`, by the loader's own level
|
||||
/// detection, so the tests never hard-code a minted identity they cannot know.
|
||||
private func cardFolders(under root: URL) -> [URL] {
|
||||
let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
|
||||
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
|
||||
return lanes.flatMap { lane in
|
||||
((try? BoardLoader.directoryCandidates(in: lane)) ?? [])
|
||||
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user