Files
lanework/KanbanTests/BoardTemplateTests.swift
T
rzen b6f559375b 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
2026-07-28 18:46:23 -04:00

170 lines
7.0 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// Template **discovery** — the bundled store, and what a `BoardTemplate` reads off a real board
/// folder (09-templates.md ▸ Definition format).
///
/// 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("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")
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("Basic is bundled, first, and the plain To Do / Done scaffold")
func basicIsTheFirstBundledTemplate() throws {
let basic = try #require(TemplateEngine.bundledTemplates().first)
#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("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)
#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 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")
let template = try fixture.template()
#expect(template.icon == ItemSymbol.board)
}
// MARK: template.order
@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()
#expect(try fixture.template().order == nil, "no template: key is required of a user template")
}
@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]")
#expect(try fixture.template().order == nil)
}
@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)
}
// 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"))
}
}