Build the welcome screen
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
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// File ▸ Duplicate's copy (03-board-ui.md § Welcome screen & templates).
|
||||
///
|
||||
/// Three promises, and all three are about what the copy *doesn't* do:
|
||||
///
|
||||
/// - it doesn't remint — "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out,
|
||||
/// and the thing that keeps a copied `.git` history naming paths that still exist;
|
||||
/// - it doesn't drop the trash — "Tombstoned items are carried too (settled) ... the duplicate is
|
||||
/// born exactly matching its history";
|
||||
/// - it doesn't overwrite — the Finder-style `copy` ladder renames instead.
|
||||
///
|
||||
/// Tested against real folders, because every one of those is a claim about bytes on disk. The
|
||||
/// window flow around the copy (the flush, the banner row, the open that follows) is the command's,
|
||||
/// not this type's.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file —
|
||||
/// everything a literal copy has to carry through untouched.
|
||||
@MainActor
|
||||
private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) {
|
||||
let fixture = try WriterFixture()
|
||||
let root = fixture.url(name)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
try fixture.item(name, Item.board)
|
||||
try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.item(
|
||||
"\(name)/\(Ident.lane1)/\(Ident.card2)",
|
||||
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.item(
|
||||
"\(name)/\(Ident.lane2)",
|
||||
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried"))
|
||||
try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8))
|
||||
|
||||
return (fixture, root)
|
||||
}
|
||||
|
||||
/// Every path under `root`, board-root-relative, hidden entries included — what "a literal copy"
|
||||
/// means as an assertion.
|
||||
private func tree(of root: URL) throws -> 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
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardDuplicator")
|
||||
struct BoardDuplicatorTests {
|
||||
|
||||
// MARK: The name ladder
|
||||
|
||||
@Test("The first duplicate is a 'copy' sibling, extension carried")
|
||||
func firstCopyIsNamedFinderStyle() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let destination = BoardDuplicator.copyDestination(for: board.root)
|
||||
|
||||
#expect(destination.lastPathComponent == "Roadmap copy.kanban")
|
||||
#expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path,
|
||||
"a sibling — where the user is already looking")
|
||||
}
|
||||
|
||||
@Test("An extension-less board has none to carry")
|
||||
func extensionlessBoardCopiesWithoutOne() throws {
|
||||
let board = try makeBoard(named: "Plain")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy")
|
||||
}
|
||||
|
||||
@Test("The collision ladder counts up from 2, one collision at a time")
|
||||
func collisionLadderCountsUp() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(first.lastPathComponent == "Roadmap copy.kanban")
|
||||
|
||||
let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(second.lastPathComponent == "Roadmap copy 2.kanban")
|
||||
|
||||
let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(third.lastPathComponent == "Roadmap copy 3.kanban")
|
||||
|
||||
// And duplicating a duplicate ladders off *its* name, exactly as Finder does.
|
||||
let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy")
|
||||
#expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban")
|
||||
}
|
||||
|
||||
@Test("Anything already wearing the name blocks it, folder or file alike")
|
||||
func anyExistingEntryBlocksTheName() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
// Not a board — just a file in the way. A duplicate must rename around it, never through it.
|
||||
try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8))
|
||||
|
||||
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban")
|
||||
}
|
||||
|
||||
// MARK: What lands in the copy
|
||||
|
||||
@Test("Every GUID is kept — the whole-board carve-out from the remint rule")
|
||||
func guidsArePreserved() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
|
||||
#expect(try tree(of: copy) == tree(of: board.root),
|
||||
"same identities, same nesting — a copied history keeps naming paths that exist")
|
||||
#expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Tombstoned lanes and cards are carried, trash included")
|
||||
func tombstonesAreCarried() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
let loaded = try BoardLoader.load(boardRoot: copy).model
|
||||
|
||||
let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 })
|
||||
#expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork")
|
||||
#expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it")
|
||||
|
||||
let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 })
|
||||
#expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted })
|
||||
}
|
||||
|
||||
@Test("The copy is byte-for-byte, strays and stale attribution included")
|
||||
func contentIsCopiedVerbatim() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
|
||||
func bytes(_ root: URL, _ relative: String) throws -> Data {
|
||||
try Data(contentsOf: root.appendingPathComponent(relative))
|
||||
}
|
||||
#expect(try bytes(copy, "index.md") == bytes(board.root, "index.md"))
|
||||
#expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md")
|
||||
== bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"),
|
||||
"created, unknown keys and modified-by all survive — nothing here reads a board file")
|
||||
#expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md"))
|
||||
}
|
||||
|
||||
@Test("The original is untouched by its own duplication")
|
||||
func originalSurvivesUnchanged() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
let before = try tree(of: board.root)
|
||||
|
||||
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
|
||||
#expect(try tree(of: board.root) == before)
|
||||
}
|
||||
|
||||
// MARK: Failure
|
||||
|
||||
@Test("A source that isn't there fails as a duplicate, naming the board")
|
||||
func missingSourceFailsInTheDuplicateVocabulary() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
let missing = board.fixture.url("Never.kanban")
|
||||
|
||||
let error = writeFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }
|
||||
|
||||
#expect(error?.operation == .duplicateBoard(title: "Never"),
|
||||
"the user pressed Duplicate; the banner must say so")
|
||||
#expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't duplicate 'Never'"))
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,76 @@ struct BoardRegistryTests {
|
||||
#expect(registry.recents().isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Clear Menu
|
||||
|
||||
@Test("Clear Menu empties the registry, and persists")
|
||||
func forgetAllEmptiesTheRegistry() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
let first = try makeBoard()
|
||||
defer { first.tearDown() }
|
||||
let second = try makeBoard()
|
||||
defer { second.tearDown() }
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
registry.recordOpen(of: first.root, displayName: "First")
|
||||
registry.recordOpen(of: second.root, displayName: "Second")
|
||||
#expect(registry.recents().count == 2)
|
||||
|
||||
registry.forgetAll()
|
||||
|
||||
#expect(registry.recents().isEmpty)
|
||||
#expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "Clear Menu persists")
|
||||
}
|
||||
|
||||
/// The equivalence 11-command-nexus.md's Clear Menu rests on: Finder clears a *menu*, and here
|
||||
/// the registry **is** the menu — so clearing it can only mean forgetting every record, and must
|
||||
/// leave the file in precisely the state that forgetting them one at a time would.
|
||||
@Test("Clear Menu is Forget applied to every row — same result, same file")
|
||||
func forgetAllMatchesForgettingEachRow() async throws {
|
||||
let wholesale = try RegistryStorage()
|
||||
defer { wholesale.tearDown() }
|
||||
let piecemeal = try RegistryStorage()
|
||||
defer { piecemeal.tearDown() }
|
||||
let first = try makeBoard()
|
||||
defer { first.tearDown() }
|
||||
let second = try makeBoard()
|
||||
defer { second.tearDown() }
|
||||
|
||||
func populate(_ storage: RegistryStorage) -> BoardRegistry {
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
registry.recordOpen(of: first.root, displayName: "First")
|
||||
registry.recordOpen(of: second.root, displayName: "Second")
|
||||
return registry
|
||||
}
|
||||
|
||||
let bulk = populate(wholesale)
|
||||
bulk.forgetAll()
|
||||
|
||||
let oneByOne = populate(piecemeal)
|
||||
for row in oneByOne.recents() {
|
||||
oneByOne.forget(id: row.record.id)
|
||||
}
|
||||
|
||||
#expect(bulk.recents().isEmpty)
|
||||
#expect(oneByOne.recents().isEmpty)
|
||||
#expect(
|
||||
try Data(contentsOf: wholesale.url) == Data(contentsOf: piecemeal.url),
|
||||
"the two paths leave byte-identical files — there is no state Clear Menu skips"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Clear Menu on an empty registry writes nothing")
|
||||
func forgetAllOnEmptyRegistryIsANoOp() async throws {
|
||||
let storage = try RegistryStorage()
|
||||
defer { storage.tearDown() }
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
registry.forgetAll()
|
||||
|
||||
#expect(try storage.entryNames().isEmpty, "an empty registry has nothing to clear and no file to write")
|
||||
}
|
||||
|
||||
// MARK: Persistence
|
||||
|
||||
@Test("Every mutation survives a reload of the file, dates included")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The welcome window's row derivation — 02-architecture.md § Launch and window lifecycle's
|
||||
/// row-level failure rule, which is the one part of that screen a test can hold to account:
|
||||
///
|
||||
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
|
||||
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
|
||||
/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations
|
||||
/// > proceed unaffected — never a launch-time modal chain, **never a silent drop**.
|
||||
///
|
||||
/// "Never a silent drop" is the clause with teeth: a failure that matches no row has to come out
|
||||
/// somewhere, and the only way to know it does is to ask the function that decides.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A record with nothing in it that matters except what a given test is about. The bookmark is empty
|
||||
/// because this function never resolves one — `RecentBoard` is constructed directly here, so the
|
||||
/// availability classification is an input rather than a filesystem outcome.
|
||||
private func record(
|
||||
name: String,
|
||||
at path: String,
|
||||
lanes: Int? = nil,
|
||||
cards: Int? = nil,
|
||||
opened: Date = Date()
|
||||
) -> BoardRecord {
|
||||
BoardRecord(
|
||||
bookmark: Data(),
|
||||
displayName: name,
|
||||
lastKnownPath: path,
|
||||
lastOpened: opened,
|
||||
laneCount: lanes,
|
||||
cardCount: cards
|
||||
)
|
||||
}
|
||||
|
||||
private func available(_ record: BoardRecord, at path: String? = nil) -> RecentBoard {
|
||||
.available(record, at: URL(fileURLWithPath: path ?? record.lastKnownPath, isDirectory: true))
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Suite("WelcomeRow")
|
||||
struct WelcomeRowTests {
|
||||
|
||||
// MARK: The three states
|
||||
|
||||
@Test("An available record with stamped counts is an ordinary row")
|
||||
func availableRow() throws {
|
||||
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban", lanes: 3, cards: 12))
|
||||
|
||||
let derived = WelcomeRow.derive(recents: [recent], failures: [])
|
||||
|
||||
#expect(derived.rows.count == 1)
|
||||
let row = try #require(derived.rows.first)
|
||||
#expect(row.displayName == "Roadmap")
|
||||
#expect(row.isAvailable)
|
||||
#expect(row.canOpen)
|
||||
#expect(row.canReveal)
|
||||
#expect(row.caption == .counts(lanes: 3, cards: 12))
|
||||
#expect(row.countsSummary == "3 lanes · 12 cards")
|
||||
#expect(derived.unmatched.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A record that has never been closed shows an em dash, not zeroes")
|
||||
func unstampedCountsShowAPlaceholder() throws {
|
||||
let recent = available(record(name: "Fresh", at: "/Boards/Fresh"))
|
||||
|
||||
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
||||
|
||||
#expect(row.caption == .counts(lanes: nil, cards: nil))
|
||||
#expect(row.countsSummary == "—", "zero is a claim; an unstamped record makes none")
|
||||
}
|
||||
|
||||
@Test("Counts are singular at one")
|
||||
func countsPluralize() {
|
||||
let recent = available(record(name: "Tiny", at: "/Boards/Tiny", lanes: 1, cards: 1))
|
||||
|
||||
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.countsSummary == "1 lane · 1 card")
|
||||
}
|
||||
|
||||
@Test("An unresolvable bookmark is a dimmed row with Open and Reveal off — never a missing row")
|
||||
func unavailableRow() throws {
|
||||
let recent = RecentBoard.unavailable(record(name: "Archive", at: "/Volumes/Gone/Archive", lanes: 4, cards: 9))
|
||||
|
||||
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
||||
|
||||
#expect(!row.isAvailable)
|
||||
#expect(!row.canOpen)
|
||||
#expect(!row.canReveal)
|
||||
#expect(row.caption == .unavailable)
|
||||
#expect(row.displayName == "Archive", "an orphan still says which board it was")
|
||||
}
|
||||
|
||||
// MARK: The failure join
|
||||
|
||||
@Test("A failure naming a row renders on that row instead of in a list of its own")
|
||||
func failureLandsOnItsRow() throws {
|
||||
let recent = available(record(name: "Broken", at: "/Boards/Broken.kanban", lanes: 2, cards: 5))
|
||||
let failure = LaunchFailure(path: "/Boards/Broken.kanban", message: "index.md: schema 7 is from a newer version")
|
||||
|
||||
let derived = WelcomeRow.derive(recents: [recent], failures: [failure])
|
||||
|
||||
let row = try #require(derived.rows.first)
|
||||
#expect(row.caption == .failed("index.md: schema 7 is from a newer version"))
|
||||
#expect(derived.unmatched.isEmpty, "the row carried it — it must not also appear in the fallback list")
|
||||
}
|
||||
|
||||
@Test("A failure outranks the counts and the unavailable state alike")
|
||||
func failureOutranksTheOtherCaptions() throws {
|
||||
let orphan = RecentBoard.unavailable(record(name: "Offline", at: "/Volumes/NAS/Offline", lanes: 2, cards: 2))
|
||||
let failure = LaunchFailure(path: "/Volumes/NAS/Offline", message: "This board is unavailable.")
|
||||
|
||||
let row = try #require(WelcomeRow.derive(recents: [orphan], failures: [failure]).rows.first)
|
||||
|
||||
#expect(row.caption == .failed("This board is unavailable."))
|
||||
#expect(!row.canOpen, "the caption changed; the row is still an orphan")
|
||||
}
|
||||
|
||||
@Test("A bookmark that followed a move matches a failure recorded at the older path")
|
||||
func failureMatchesEitherOfARecordsPaths() {
|
||||
// The record was last *seen* at the old path; its bookmark now resolves to the new one.
|
||||
let moved = RecentBoard.available(
|
||||
record(name: "Moved", at: "/Boards/Old.kanban"),
|
||||
at: URL(fileURLWithPath: "/Boards/New.kanban", isDirectory: true)
|
||||
)
|
||||
|
||||
let atOldPath = WelcomeRow.derive(
|
||||
recents: [moved],
|
||||
failures: [LaunchFailure(path: "/Boards/Old.kanban", message: "old")]
|
||||
)
|
||||
let atNewPath = WelcomeRow.derive(
|
||||
recents: [moved],
|
||||
failures: [LaunchFailure(path: "/Boards/New.kanban", message: "new")]
|
||||
)
|
||||
|
||||
#expect(atOldPath.rows.first?.caption == .failed("old"))
|
||||
#expect(atNewPath.rows.first?.caption == .failed("new"))
|
||||
#expect(atOldPath.unmatched.isEmpty)
|
||||
#expect(atNewPath.unmatched.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Paths are compared standardized, so two spellings of one board are one board")
|
||||
func pathsAreStandardizedBeforeMatching() {
|
||||
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban"))
|
||||
let failure = LaunchFailure(path: "/Boards/./Sub/../Roadmap.kanban", message: "couldn't read it")
|
||||
|
||||
let derived = WelcomeRow.derive(recents: [recent], failures: [failure])
|
||||
|
||||
#expect(derived.rows.first?.caption == .failed("couldn't read it"))
|
||||
#expect(derived.unmatched.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The newest of several failures for one board is the caption, and all of them are consumed")
|
||||
func newestFailureWinsAndOlderOnesAreNotOrphaned() {
|
||||
let recent = available(record(name: "Retried", at: "/Boards/Retried"))
|
||||
let failures = [
|
||||
LaunchFailure(path: "/Boards/Retried", message: "first attempt"),
|
||||
LaunchFailure(path: "/Boards/Retried", message: "second attempt"),
|
||||
]
|
||||
|
||||
let derived = WelcomeRow.derive(recents: [recent], failures: failures)
|
||||
|
||||
#expect(derived.rows.first?.caption == .failed("second attempt"), "the newest describes the file as it is now")
|
||||
#expect(derived.unmatched.isEmpty, "the older attempt must not resurface as if nothing had shown it")
|
||||
}
|
||||
|
||||
@Test("A failure naming no record keeps a fallback of its own — never a silent drop")
|
||||
func failureWithNoRowFallsBack() {
|
||||
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban"))
|
||||
let stray = LaunchFailure(path: "/Downloads/not-a-board", message: "index.md: no such file or directory")
|
||||
|
||||
let derived = WelcomeRow.derive(recents: [recent], failures: [stray])
|
||||
|
||||
#expect(derived.rows.count == 1)
|
||||
#expect(derived.rows.first?.caption == .counts(lanes: nil, cards: nil), "the unrelated row is untouched")
|
||||
#expect(derived.unmatched.map(\.message) == ["index.md: no such file or directory"])
|
||||
}
|
||||
|
||||
@Test("Other rows are unaffected by a failure on one of them")
|
||||
func failuresDoNotBleedBetweenRows() {
|
||||
let recents = [
|
||||
available(record(name: "Good", at: "/Boards/Good", lanes: 1, cards: 1)),
|
||||
available(record(name: "Bad", at: "/Boards/Bad")),
|
||||
]
|
||||
let failure = LaunchFailure(path: "/Boards/Bad", message: "boom")
|
||||
|
||||
let derived = WelcomeRow.derive(recents: recents, failures: [failure])
|
||||
|
||||
#expect(derived.rows[0].caption == .counts(lanes: 1, cards: 1))
|
||||
#expect(derived.rows[1].caption == .failed("boom"))
|
||||
}
|
||||
|
||||
// MARK: Order and naming
|
||||
|
||||
@Test("The derivation preserves the registry's order and never re-sorts")
|
||||
func orderIsTheRegistrys() {
|
||||
let recents = [
|
||||
available(record(name: "Third", at: "/Boards/C", opened: Date(timeIntervalSince1970: 3))),
|
||||
available(record(name: "First", at: "/Boards/A", opened: Date(timeIntervalSince1970: 1))),
|
||||
available(record(name: "Second", at: "/Boards/B", opened: Date(timeIntervalSince1970: 2))),
|
||||
]
|
||||
|
||||
let derived = WelcomeRow.derive(recents: recents, failures: [])
|
||||
|
||||
#expect(derived.rows.map(\.displayName) == ["Third", "First", "Second"],
|
||||
"the sort rule lives in BoardRegistry.recents() and must not be duplicated here")
|
||||
}
|
||||
|
||||
@Test("A record with no display name falls back to its folder name, extension stripped")
|
||||
func displayNameFallsBackToTheFolderName() {
|
||||
let recent = available(record(name: "", at: "/Boards/Untitled.kanban"))
|
||||
|
||||
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.displayName == "Untitled")
|
||||
}
|
||||
|
||||
@Test("The location line is the containing folder, not the board's own path")
|
||||
func locationIsTheContainingFolder() {
|
||||
let recent = available(record(name: "Roadmap", at: "/Boards/Work/Roadmap.kanban"))
|
||||
|
||||
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.location == "/Boards/Work")
|
||||
}
|
||||
|
||||
@Test("An unavailable row's location comes from where it was last seen")
|
||||
func unavailableLocationUsesLastKnownPath() {
|
||||
let recent = RecentBoard.unavailable(record(name: "Archive", at: "/Volumes/Gone/Boards/Archive"))
|
||||
|
||||
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.location == "/Volumes/Gone/Boards")
|
||||
}
|
||||
|
||||
@Test("An empty registry derives no rows and drops no failures")
|
||||
func emptyRecentsStillSurfaceFailures() {
|
||||
let stray = LaunchFailure(path: "/Downloads/whatever", message: "not a board")
|
||||
|
||||
let derived = WelcomeRow.derive(recents: [], failures: [stray])
|
||||
|
||||
#expect(derived.rows.isEmpty)
|
||||
#expect(derived.unmatched.count == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user