import Foundation import Testing @testable import Kanban /// **Save as Template** — the board copied into the user templates store, on disk (09-templates.md /// ▸ Save as Template). /// /// Every promise 09 makes about that copy is an assertion about bytes in a store: /// /// - **`.git` and `.trash/` are dropped**, and nothing else is — strays, `CLAUDE.user.md`, a seeded /// `.gitignore`, attachments and bodies all carry through; /// - **GUIDs and timestamps are kept** — a template is a fork, and both are inert anyway because /// instantiation remints and restamps at its own boundary; /// - **a `template:` key lands on the copy**, with an order appended after the existing user /// templates, overwriting a stale one the board carried in; /// - **collisions auto-rename, Finder-style** — never overwritten, never refused; /// - and **the app never stamps a key into store files it didn't write itself**: listing and /// instantiating a hand-dropped board leave it exactly as its author left it. /// /// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind. /// /// Every test drives an explicit store URL. **No test may touch Application Support** — `userStore` /// is named here only to prove the engine never creates it on its own. // MARK: - Helpers private func saveFailure(_ operation: () throws -> Void) -> TemplateEngine.Failure? { do { try operation() Issue.record("expected the save 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 storeTree(of root: URL) -> Set { var paths: Set = [] 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 } private func names(in folder: URL) throws -> Set { Set(try FileManager.default.contentsOfDirectory(atPath: folder.path)) } // MARK: - The board being saved /// A board carrying everything 09 has a rule about: the two exclusions, board- and lane-level /// strays, an attachment, a stale `template:` key from its own instantiation, and old timestamps /// with someone else's attribution on them. private struct SavableBoard { let fixture: WriterFixture let root: URL let store: URL static let name = "Roadmap.kanban" init() throws { fixture = try WriterFixture() root = fixture.url(Self.name) store = fixture.url("Templates") let path = Self.name try fixture.item(path, """ --- schema: 1 title: Roadmap icon: map iconColor: fern template: {order: 800} project: lanework created: 2026-01-01T09:00:00Z modified: 2026-02-02T09:00:00Z modified-by: claude --- The board's description, which becomes the template's blurb. """) // Strays, at board and lane level — "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 fixture.file("\(path)/.git/objects/pack/pack-1.pack", Data([0x01, 0x02])) try fixture.item("\(path)/\(BoardLoader.trashFolderName)/\(Ident.card4)", Item.rich(order: "1024", title: "Thrown Away")) 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.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() } @discardableResult func save(isCancelled: () -> Bool = { false }) throws -> URL { try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Roadmap", into: store, isCancelled: isCancelled) } } // MARK: - The copy @Suite("Save as Template — the copy") struct SaveAsTemplateCopyTests { @Test("The store is created by the first save — the engine names it, it doesn't make it early") func theFirstSaveMintsTheStore() throws { let board = try SavableBoard() defer { board.tearDown() } #expect(!FileManager.default.fileExists(atPath: board.store.path)) let landed = try board.save() #expect(FileManager.default.fileExists(atPath: board.store.path)) #expect(landed == board.store.appendingPathComponent("Roadmap.kanban", isDirectory: true)) } @Test("`.git` and `.trash/` are the two exclusions, and they are the only ones") func theTwoExclusionsAreExcluded() throws { let board = try SavableBoard() defer { board.tearDown() } let landed = try board.save() let paths = storeTree(of: landed) #expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") }, "a template is content, not history — the board's whole repo must not land in the store") #expect(!paths.contains { $0 == ".trash" || $0.hasPrefix(".trash/") }, "a template isn't a fork; the board's trash is not part of what was saved") #expect(paths.contains("CLAUDE.user.md")) #expect(paths.contains(".gitignore"), "the exclusions are exact names, not prefixes") #expect(paths.contains { $0.hasSuffix("notes/scratch.md") }) #expect(paths.contains { $0.hasSuffix("attachments/shot.png") }) } @Test("Strays and attachments arrive byte for byte") func straysAreVerbatim() throws { let board = try SavableBoard() defer { board.tearDown() } let landed = try board.save() #expect(try Data(contentsOf: landed.appendingPathComponent("CLAUDE.user.md")) == Data("board instructions\n".utf8)) #expect(try Data(contentsOf: landed.appendingPathComponent(".gitignore")) == Data(".DS_Store\n".utf8)) #expect(try Data(contentsOf: landed.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png")) == Data([0x01, 0x02, 0x03])) } @Test("GUIDs are kept — a whole-board copy is a new namespace, and instantiation remints anyway") func identitiesAreKept() throws { let board = try SavableBoard() defer { board.tearDown() } let landed = try board.save() #expect(try names(in: landed).isSuperset(of: [Ident.lane1, Ident.lane2])) #expect(try names(in: landed.appendingPathComponent(Ident.lane1)).contains(Ident.card1)) } @Test("Timestamps and foreign attributions are kept — the copies-keep-created rule, inertly") func timestampsAreUntouched() throws { let board = try SavableBoard() defer { board.tearDown() } let cardBefore = try Data(contentsOf: board.root .appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/index.md")) let landed = try board.save() // A card's index.md is copied by the file system and never rewritten: identical bytes, so // `created`, `modified` and `modified-by` are all exactly as they were. #expect(try Data(contentsOf: landed.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/index.md")) == cardBefore) // The board's own index.md *is* rewritten (the `template:` key), so it is asserted by value. let model = try BoardLoader.load(boardRoot: landed).model #expect(model.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z")) #expect(model.title.value == "Roadmap") } @Test("The board being saved is never written to") func theSourceBoardIsUntouched() throws { let board = try SavableBoard() defer { board.tearDown() } let before = storeTree(of: board.root) let indexBefore = try Data(contentsOf: board.root.appendingPathComponent("index.md")) try board.save() #expect(storeTree(of: board.root) == before) #expect(try Data(contentsOf: board.root.appendingPathComponent("index.md")) == indexBefore, "the key is stamped on the copy — the user's board never gains one") } @Test("The saved template loads, and instantiates into an ordinary board") func theSavedTemplateRoundTrips() throws { let board = try SavableBoard() defer { board.tearDown() } let landed = try board.save() let rows = TemplateEngine.userRows(in: board.store) let template = try #require(rows.first?.template) #expect(template.url == landed) #expect(template.name == "Roadmap") #expect(template.blurb == "The board's description, which becomes the template's blurb.") let destination = board.fixture.url("New Board.kanban") try TemplateEngine.instantiate(template: template, to: destination, title: "New Board") let result = try BoardLoader.load(boardRoot: destination) #expect(result.model.lanes.map { $0.title.value } == ["To Do", "Done"]) #expect(result.model.lanes.flatMap(\.cards).map { $0.title.value } == ["Starter"]) #expect(result.model.title.value == "New Board") // The board's own lane-level stray (`Done/notes/`) is still a stray after two copies — which // is the point: it survived both, unread and unrendered, exactly as 09 promises. #expect(result.warnings.count == 1) } } // MARK: - The key @Suite("Save as Template — the template: key") struct SaveAsTemplateKeyTests { @Test("The first user template takes the first order") func firstSaveTakesTheFirstOrder() throws { let board = try SavableBoard() defer { board.tearDown() } let landed = try board.save() #expect(try BoardLoader.load(boardRoot: landed).model.template == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(100))])) } @Test("A stale order carried in from the board's own instantiation is overwritten") func staleOrderIsOverwritten() throws { let board = try SavableBoard() defer { board.tearDown() } // The fixture board carries `template: {order: 800}` — Roadmap's bundled position, which is // exactly the residue 09 describes ("it was itself instantiated from a template"). #expect(try BoardLoader.load(boardRoot: board.root).model.template == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(800))])) let landed = try board.save() let order = try #require(TemplateEngine.rows(in: board.store, origin: .user).first?.order) #expect(order == 100, "the stale order is replaced, not respected") #expect(try BoardLoader.load(boardRoot: landed).model.document.value(for: "project") == .string("lanework"), "the surgical edit leaves every other key where it was") } @Test("Each save is appended after the existing user templates") func ordersAreAppended() throws { let board = try SavableBoard() defer { board.tearDown() } try board.save() try board.save() try board.save() let rows = TemplateEngine.userRows(in: board.store) #expect(rows.map(\.order) == [100, 200, 300]) #expect(rows.map(\.name) == ["Roadmap", "Roadmap", "Roadmap"]) } @Test("A hand-dropped keyless board is counted as existing but never given a key") func aKeylessNeighbourIsNeverStamped() throws { let board = try SavableBoard() defer { board.tearDown() } try FileManager.default.createDirectory(at: board.store, withIntermediateDirectories: true) let dropped = board.store.appendingPathComponent("Hand Dropped.kanban", isDirectory: true) try FileManager.default.createDirectory( at: dropped.appendingPathComponent(Ident.lane3, isDirectory: true), withIntermediateDirectories: true ) try Data("---\nschema: 1\ntitle: Hand Dropped\n---\nMine.\n".utf8) .write(to: dropped.appendingPathComponent("index.md")) try Data("---\nschema: 1\ntitle: Lane\norder: 1024\n---\n".utf8) .write(to: dropped.appendingPathComponent("\(Ident.lane3)/index.md")) let droppedBytes = try Data(contentsOf: dropped.appendingPathComponent("index.md")) // Listing it, saving beside it, and instantiating from it: three reads, no writes. let template = try #require(TemplateEngine.userRows(in: board.store).first?.template) try board.save() try TemplateEngine.instantiate( template: template, to: board.fixture.url("From Dropped.kanban"), title: "From Dropped" ) #expect(try Data(contentsOf: dropped.appendingPathComponent("index.md")) == droppedBytes, "the app never stamps a key into store files it didn't write itself") #expect(TemplateEngine.userRows(in: board.store).first { $0.name == "Hand Dropped" }?.order == nil) } @Test("A keyless neighbour contributes no order, so the first save still takes the first one") func keylessNeighboursDoNotMoveTheLadder() throws { let board = try SavableBoard() defer { board.tearDown() } try FileManager.default.createDirectory(at: board.store, withIntermediateDirectories: true) let dropped = board.store.appendingPathComponent("Hand Dropped.kanban", isDirectory: true) try FileManager.default.createDirectory(at: dropped, withIntermediateDirectories: true) try Data("---\nschema: 1\ntitle: Hand Dropped\n---\n".utf8) .write(to: dropped.appendingPathComponent("index.md")) let landed = try board.save() #expect(try BoardLoader.load(boardRoot: landed).model.template == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(100))])) } @Test("nextUserOrder reads the store's own highest key, not a counter") func nextOrderFollowsHandEdits() throws { let fixture = try TemplateFixture(named: "Ladder") defer { fixture.tearDown() } try fixture.freeStandingBoard(named: "a", title: "A", extraKeys: "template: {order: 100}") try fixture.freeStandingBoard(named: "b", title: "B", extraKeys: "template: {order: 4200}") #expect(TemplateEngine.nextUserOrder(in: fixture.store) == 4300) } } // MARK: - Collisions @Suite("Save as Template — store collisions") struct SaveAsTemplateCollisionTests { @Test("A name already in the store auto-renames, Finder-style — never overwritten, never refused") func collisionsAutoRename() throws { let board = try SavableBoard() defer { board.tearDown() } let first = try board.save() let second = try board.save() let third = try board.save() #expect(first.lastPathComponent == "Roadmap.kanban") #expect(second.lastPathComponent == "Roadmap 2.kanban") #expect(third.lastPathComponent == "Roadmap 3.kanban") #expect(try names(in: board.store) == ["Roadmap.kanban", "Roadmap 2.kanban", "Roadmap 3.kanban"]) } @Test("The template already in the store is left exactly as it was") func theExistingTemplateIsUntouched() throws { let board = try SavableBoard() defer { board.tearDown() } let first = try board.save() let before = try Data(contentsOf: first.appendingPathComponent("index.md")) try board.save() #expect(try Data(contentsOf: first.appendingPathComponent("index.md")) == before, "saving never overwrites an existing template and never refuses") } @Test("An extension-less board folder ladders on its whole name") func extensionlessNamesLadderToo() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let store = fixture.url("Templates") let root = fixture.url("Plain") try fixture.item("Plain", "---\nschema: 1\ntitle: Plain\n---\nBlurb.\n") let first = try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Plain", into: store) let second = try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Plain", into: store) #expect(first.lastPathComponent == "Plain") #expect(second.lastPathComponent == "Plain 2") } } // MARK: - Nothing half-made @Suite("Save as Template — the store entry is all or nothing") struct SaveAsTemplateAtomicityTests { @Test("Cancelling mid-walk removes the partial store entry") func cancellingRemovesThePartial() throws { let board = try SavableBoard() defer { board.tearDown() } // Trips on the fourth read: the entry exists by then and the walk is inside it, so there is // a genuine partial tree to remove. var reads = 0 let failure = saveFailure { try board.save(isCancelled: { reads += 1 return reads > 3 }) } #expect(failure == TemplateEngine.Failure.cancelled) #expect(try names(in: board.store).isEmpty, "a cancelled save never happened") } @Test("Cancelling before the first item leaves the store empty but minted") func cancellingBeforeTheWalkCreatesNoEntry() throws { let board = try SavableBoard() defer { board.tearDown() } let failure = saveFailure { try board.save(isCancelled: { true }) } #expect(failure == TemplateEngine.Failure.cancelled) #expect(try names(in: board.store).isEmpty) } @Test("A board that is not there fails as a save, naming the operation, and leaves nothing behind") func aMissingBoardFailsCleanly() throws { let board = try SavableBoard() defer { board.tearDown() } try FileManager.default.removeItem(at: board.root) let failure = saveFailure { try board.save() } guard case let .failed(error) = failure else { Issue.record("expected an ordinary failure, got \(String(describing: failure))") return } #expect(error.operation == .saveAsTemplate(title: "Roadmap")) #expect(try names(in: board.store).isEmpty) } } // MARK: - The store's location @Suite("Save as Template — the user store") struct UserTemplateStoreTests { @Test("The user store is named inside the app container and is never created by naming it") func theStoreIsNamedNotCreated() { let store = TemplateEngine.userStore #expect(store.lastPathComponent == TemplateEngine.storeFolderName) #expect(store.deletingLastPathComponent().lastPathComponent == (Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban")) #expect(store.path.contains("Application Support")) // Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and // Reveal in Finder's, and both are driven with an explicit store in this suite. } }