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
458 lines
20 KiB
Swift
458 lines
20 KiB
Swift
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 the real store** — which since the
|
|
/// 2026-07-29 re-homing is in the shared App Group container, so it would be the sibling edition's
|
|
/// template store too. `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<String> {
|
|
var paths: Set<String> = []
|
|
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<String> {
|
|
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 beside the registry in the shared home, and is never created by naming it")
|
|
func theStoreIsNamedNotCreated() {
|
|
let store = TemplateEngine.userStore
|
|
|
|
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
|
|
// Beside the registry and the clipboard's staging store, in the App Group container
|
|
// (09-templates.md ▸ Storage, re-homed 2026-07-29 — templates cross editions). Compared against
|
|
// the one shared home rather than spelled out, so the assertion follows it wherever it goes —
|
|
// including the scratch redirect a test host gets (`AppGroup.isUnitTestHost`). `AppGroup` rather
|
|
// than the two stores' own defaults because those are `@MainActor` and this suite is not; that
|
|
// the three agree is `AppGroupContainerTests`' assertion.
|
|
#expect(store.deletingLastPathComponent() == AppGroup.stateDirectory)
|
|
// 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.
|
|
}
|
|
}
|