Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):
- AppGroup namespace: container resolution with per-edition fallback
when unprovisioned, shared UserDefaults suite, edition identity, and
a unit-test-host redirect (the test host IS the app — its launch
sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
openNow keyed by bundle id; hand-written Codable keeps legacy keys
decoding (adopted in memory as the running edition's slots, upgraded
on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
first click runs an open panel pre-anchored at the recorded path,
prompt "Grant"; recordOpen mints this edition's slot onto the
matched shared record (path fallback only after identity fails and
only against records holding no grant of ours, so re-granting never
forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
registry when the sibling edition wrote it, so one edition's save
never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
popover gains BoardEditionPresence ("Also open in Lanework Pro"),
pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
claims doomed trees by atomic rename into .sweeping/ then deletes,
so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
re-ruling; scalars (quick-style recents, window size) move to the
shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
family group). No pathfinder 1.x migrator: 1.x predates the
registry; state starts fresh in the group container.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
299 lines
14 KiB
Swift
299 lines
14 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)")
|
|
}
|
|
}
|
|
let templates = TemplateEngine.bundledTemplates()
|
|
#expect(templates.count == folders.count)
|
|
|
|
// 09-templates.md ▸ Inventory: all ten pathfinder templates, each carrying over its own lane
|
|
// count — the definitive check that the bundle is the full set, not just "some folders load".
|
|
let expectedLaneCounts: [String: Int] = [
|
|
"basic": 2,
|
|
"classic-kanban": 3,
|
|
"software-project": 5,
|
|
"content-pipeline": 5,
|
|
"job-hunt": 5,
|
|
"sales-pipeline": 5,
|
|
"weekly-planner": 4,
|
|
"roadmap": 4,
|
|
"event-planning": 5,
|
|
"bug-tracker": 5,
|
|
]
|
|
#expect(Set(templates.map(\.slug)) == Set(expectedLaneCounts.keys), "all ten bundled slugs are present")
|
|
for template in templates {
|
|
#expect(template.lanes.count == expectedLaneCounts[template.slug], "\(template.slug) lane count")
|
|
}
|
|
|
|
#expect(templates.first?.slug == "basic", "Basic carries the lowest template.order")
|
|
let orders = templates.compactMap(\.order)
|
|
#expect(orders.count == templates.count, "every bundled template carries a template.order")
|
|
#expect(Set(orders).count == orders.count, "bundled template.order values are distinct")
|
|
#expect(orders == orders.sorted(), "bundledTemplates() lists templates in ascending template.order")
|
|
}
|
|
|
|
@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.iconColor == basic.model.iconColor.value)
|
|
#expect(basic.order == 100, "template: {order: 100} is the chooser position")
|
|
|
|
// Basic carries no icon key at all — the pathfinder's plain scaffold, inherited verbatim —
|
|
// so the pass-through half of this check needs a template that actually sets one.
|
|
let classicKanban = try #require(TemplateEngine.bundledTemplates().first { $0.slug == "classic-kanban" })
|
|
#expect(classicKanban.icon == classicKanban.model.icon.value)
|
|
}
|
|
|
|
@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: - The chooser's rows
|
|
|
|
/// 09-templates.md ▸ Storage's **three-tier chooser order**, and the unloadable row that sorts with
|
|
/// its last tier:
|
|
///
|
|
/// > Chooser order: bundled templates by `template.order`, then keyed user templates by
|
|
/// > `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder
|
|
/// > name). An unloadable user template sorts with the keyless tier, by folder name.
|
|
@Suite("TemplateEngine — chooser rows")
|
|
struct TemplateChooserRowTests {
|
|
|
|
@Test("The user tier is keyed by order first, then keyless boards by display name")
|
|
func userTierIsKeyedThenNamed() throws {
|
|
let fixture = try TemplateFixture(named: "Tiers")
|
|
defer { fixture.tearDown() }
|
|
try fixture.freeStandingBoard(named: "zebra", title: "Zebra")
|
|
try fixture.freeStandingBoard(named: "second", title: "Second", extraKeys: "template: {order: 200}")
|
|
try fixture.freeStandingBoard(named: "apple", title: "Apple")
|
|
try fixture.freeStandingBoard(named: "first", title: "First", extraKeys: "template: {order: 100}")
|
|
|
|
let rows = TemplateEngine.userRows(in: fixture.store)
|
|
|
|
#expect(rows.map(\.name) == ["First", "Second", "Apple", "Zebra"])
|
|
}
|
|
|
|
@Test("An unloadable folder sorts with the keyless tier, by folder name")
|
|
func unloadableSortsWithTheKeylessTier() throws {
|
|
let fixture = try TemplateFixture(named: "Broken sorting")
|
|
defer { fixture.tearDown() }
|
|
try fixture.freeStandingBoard(named: "keyed", title: "Keyed", extraKeys: "template: {order: 100}")
|
|
try fixture.freeStandingBoard(named: "Zebra", title: "Zebra")
|
|
try fixture.malformedBoard(named: "Muddle")
|
|
|
|
let rows = TemplateEngine.userRows(in: fixture.store)
|
|
|
|
// Keyed first, then the keyless tier by name — where the broken folder takes its place under
|
|
// its own folder name, because "the failed load can supply neither `template.order` nor
|
|
// `title`".
|
|
#expect(rows.map(\.name) == ["Keyed", "Muddle", "Zebra"])
|
|
#expect(rows.map { $0.template == nil } == [false, true, false])
|
|
}
|
|
|
|
@Test("Both tiers, in order: bundled, then keyed user templates, then keyless ones")
|
|
func bundledTierComesFirstWhateverTheUserOrdersSay() throws {
|
|
let fixture = try TemplateFixture(named: "Both tiers")
|
|
defer { fixture.tearDown() }
|
|
// Deliberately *lower* than every bundled order (Basic is 100): the tier is the outermost
|
|
// key, so a user template can never sort itself in among the bundled ones.
|
|
try fixture.freeStandingBoard(named: "mine", title: "Mine", extraKeys: "template: {order: 1}")
|
|
try fixture.freeStandingBoard(named: "hand-dropped", title: "Hand Dropped")
|
|
|
|
let rows = TemplateEngine.chooserRows(userStore: fixture.store)
|
|
let bundled = TemplateEngine.bundledTemplates()
|
|
|
|
#expect(rows.count == bundled.count + 2)
|
|
#expect(rows.prefix(bundled.count).map(\.name) == bundled.map(\.name))
|
|
#expect(rows.suffix(2).map(\.name) == ["Mine", "Hand Dropped"])
|
|
}
|
|
|
|
@Test("A malformed template lists, carries the loader's error, and leaves the others alone")
|
|
func oneBadTemplateNeverFailsTheChooser() throws {
|
|
let fixture = try TemplateFixture(named: "Resilience")
|
|
defer { fixture.tearDown() }
|
|
try fixture.freeStandingBoard(named: "Good", title: "Good")
|
|
try fixture.malformedBoard(named: "Bad")
|
|
|
|
let rows = TemplateEngine.userRows(in: fixture.store)
|
|
|
|
#expect(rows.count == 2, "the broken folder is listed, not skipped")
|
|
let bad = try #require(rows.first { $0.name == "Bad" }?.unloadable)
|
|
#expect(bad.error.reason == .boardRootMissingIndex, "the loader's own fail-fast specifics, unreworded")
|
|
#expect(bad.url == fixture.store.appendingPathComponent("Bad.kanban", isDirectory: true))
|
|
#expect(rows.first { $0.name == "Good" }?.template != nil, "one bad template never fails the chooser")
|
|
}
|
|
|
|
@Test("A missing store is an empty list, and listing never creates one")
|
|
func aMissingStoreIsEmpty() throws {
|
|
let fixture = try TemplateFixture(named: "Absent")
|
|
defer { fixture.tearDown() }
|
|
let absent = fixture.store.appendingPathComponent("not-there", isDirectory: true)
|
|
|
|
#expect(TemplateEngine.userRows(in: absent).isEmpty)
|
|
#expect(!FileManager.default.fileExists(atPath: absent.path),
|
|
"discovery of a store that does not exist is an empty list, not a directory the app made")
|
|
}
|
|
}
|
|
|
|
// MARK: - Fixture
|
|
|
|
/// A temp store holding hand-written template board folders — the user store's shape, minus
|
|
/// the shared App Group container (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.
|
|
@discardableResult
|
|
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)
|
|
}
|
|
|
|
/// A folder in the store the loader rejects — the hand-editable store's one-edit-away state,
|
|
/// which the chooser has to survive. No `index.md` at all, which is 09's own example of a board
|
|
/// that cannot be read at all rather than one with a bad field.
|
|
@discardableResult
|
|
func malformedBoard(named name: String) throws -> URL {
|
|
let url = store.appendingPathComponent("\(name).kanban", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
try Data("not a board\n".utf8).write(to: url.appendingPathComponent("notes.md"))
|
|
return 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"))
|
|
}
|
|
}
|