The welcome window becomes the real thing: Xcode-style, hidden title bar with background drag, branding and actions left, recents right — rows carrying the board symbol, name, location, and the registry's cached lane/card counts (stamped at close, never a scan at welcome time), sorted by last opened. Launch failures surface row-level per 02: a failure joins its recents row as a warning caption, an unresolvable bookmark renders unavailable with Forget its one affordance, and only a failure with no row to carry it falls back to a compact list; a board opening again heals its row. New Board (Opt-Cmd-N) opens the Pages-style template chooser — shipped with the single Basic template and the m9 seams marked — flowing through the save panel into createBoard/createLane and straight into a board window. Open Recent gains its submenu with Clear Menu (byte-identical to forgetting every row, pinned by test), and File > Duplicate forks the frontmost board to a Finder-style copy sibling: pending work flushes first through the close flush's step two alone (sessions stay open — 09's stated exception), every GUID and tombstone carries (the whole-board carve-out from copies-remint), and the copy opens in its own window while the original stays put. 36 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
120 lines
5.3 KiB
Swift
120 lines
5.3 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// Template instantiation — the on-disk half of File ▸ New Board… (09-templates.md ▸ Instantiation).
|
|
///
|
|
/// The window flow around it (the chooser, `NSSavePanel`, the open that follows) is untestable
|
|
/// without a screen and deliberately holds no rules of its own; everything that *is* a rule — what
|
|
/// gets written, what the board is called, and the order the lanes land in — lives in
|
|
/// `BoardTemplate.instantiate(at:)` and is checked here against a real temp folder, through the
|
|
/// app's own loader.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// An empty temp folder to create *into* — the save panel's answer, minus the panel.
|
|
private func temporaryLocation(named name: String) throws -> (url: URL, tearDown: () -> Void) {
|
|
let container = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("BoardTemplateTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true)
|
|
return (container.appendingPathComponent(name, isDirectory: true), {
|
|
try? FileManager.default.removeItem(at: container)
|
|
})
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@Suite("BoardTemplate")
|
|
struct BoardTemplateTests {
|
|
|
|
@Test("Basic writes a board and its three lanes, in order")
|
|
func basicInstantiatesInOrder() throws {
|
|
let location = try temporaryLocation(named: "Roadmap.kanban")
|
|
defer { location.tearDown() }
|
|
|
|
try BoardTemplate.basic.instantiate(at: location.url)
|
|
|
|
let result = try BoardLoader.load(boardRoot: location.url)
|
|
#expect(result.warnings.isEmpty, "a board this app just wrote must load clean")
|
|
#expect(result.model.lanes.map { $0.title.value } == ["To Do", "Doing", "Done"])
|
|
#expect(result.model.lanes.allSatisfy { !$0.isDeleted })
|
|
#expect(result.model.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards")
|
|
}
|
|
|
|
@Test("The lanes are ranked by the board convention — 1024 apart, from 1024")
|
|
func lanesAreRankedByTheAppendConvention() throws {
|
|
let location = try temporaryLocation(named: "Ranked.kanban")
|
|
defer { location.tearDown() }
|
|
|
|
try BoardTemplate.basic.instantiate(at: location.url)
|
|
|
|
let orders = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.order)
|
|
#expect(orders == [1024, 2048, 3072], "each lane appends after the one before it")
|
|
}
|
|
|
|
@Test("The board's title is the document name the user chose, not the template's")
|
|
func titleComesFromTheChosenName() throws {
|
|
let location = try temporaryLocation(named: "Q3 Planning.kanban")
|
|
defer { location.tearDown() }
|
|
|
|
try BoardTemplate.basic.instantiate(at: location.url)
|
|
|
|
// 01-storage-format.md § Board naming: display name and folder name start out matching.
|
|
#expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Q3 Planning")
|
|
}
|
|
|
|
@Test("An extension-less location is as legal a board, and keeps its whole name as the title")
|
|
func extensionlessLocationWorks() throws {
|
|
let location = try temporaryLocation(named: "Plain")
|
|
defer { location.tearDown() }
|
|
|
|
try BoardTemplate.basic.instantiate(at: location.url)
|
|
|
|
#expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Plain")
|
|
}
|
|
|
|
@Test("Every lane folder is a fresh lowercase UUID")
|
|
func laneFoldersAreMintedIdentities() throws {
|
|
let location = try temporaryLocation(named: "Minted.kanban")
|
|
defer { location.tearDown() }
|
|
|
|
try BoardTemplate.basic.instantiate(at: location.url)
|
|
|
|
let ids = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.id.rawValue)
|
|
#expect(ids.count == 3)
|
|
#expect(Set(ids).count == 3, "three lanes, three identities")
|
|
#expect(ids.allSatisfy { $0 == $0.lowercased() }, "the app emits lowercase UUIDs")
|
|
#expect(ids.allSatisfy { UUID(uuidString: $0) != nil })
|
|
}
|
|
|
|
@Test("Instantiating over an existing board refuses rather than clobbering it")
|
|
func refusesToOverwriteAnExistingBoard() throws {
|
|
let location = try temporaryLocation(named: "Taken.kanban")
|
|
defer { location.tearDown() }
|
|
try BoardTemplate.basic.instantiate(at: location.url)
|
|
let before = try Data(contentsOf: location.url.appendingPathComponent("index.md"))
|
|
|
|
let error = writeFailure { try BoardTemplate.basic.instantiate(at: location.url) }
|
|
|
|
#expect(error?.operation == .createBoard)
|
|
#expect(
|
|
try Data(contentsOf: location.url.appendingPathComponent("index.md")) == before,
|
|
"the existing board is untouched — a create never replaces one"
|
|
)
|
|
}
|
|
|
|
@Test("The document name behind a chosen URL is the folder name without its extension")
|
|
func documentNameStripsTheExtension() {
|
|
#expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap.kanban")) == "Roadmap")
|
|
#expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap")) == "Roadmap")
|
|
}
|
|
|
|
@Test("The chooser offers Basic, and Basic is first")
|
|
func inventoryHoldsBasicFirst() {
|
|
// m9-templates: this becomes the bundled inventory's ten, with Basic still first
|
|
// (09-templates.md ▸ Inventory).
|
|
#expect(BoardTemplate.all.first == BoardTemplate.basic)
|
|
#expect(BoardTemplate.basic.slug == "basic")
|
|
}
|
|
}
|