Build the template chooser and Save as Template
The chooser completes its three tiers: bundled by template order, then keyed user templates, then keyless boards by display name — and a malformed user template still lists, by folder name with the loader's own sentence on the row, never failing its neighbours. The store is re-scanned on every presentation and on app activation, the Reveal round trip made honest without watching a folder 09 deliberately leaves unwatched; Reveal lives in the chooser's header and mints the store on first press. Save as Template repeats Duplicate's sequence — progress row with Cancel, flush, detached cancellable copy — through the engine: mint the store, read the next user order before the copy can count itself, Finder-ladder the name, copy excluding .git and .trash/, then stamp the whole template: mapping on the landed copy through updateIndex, with no bracket because the copy lives outside every watched board. Folder attributes deliberately don't carry — the one lock the command stays live under is the read-only-DMG one, and carrying its mode bits would mint a read-only template in the user's own store; the command gates instead on the real hazard, unsaved card content. A signpost names the template only when the ladder renamed it. One name ladder now serves Duplicate and the store. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -140,6 +140,93 @@ struct BoardTemplateTests {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -166,12 +253,24 @@ struct TemplateFixture {
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user