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 /// 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. @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")) } }