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
265 lines
11 KiB
Swift
265 lines
11 KiB
Swift
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. It holds **no grant
|
|
/// slot** 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(),
|
|
icon: String? = nil,
|
|
iconColor: String? = nil
|
|
) -> BoardRecord {
|
|
BoardRecord(
|
|
displayName: name,
|
|
lastKnownPath: path,
|
|
lastOpened: opened,
|
|
laneCount: lanes,
|
|
cardCount: cards,
|
|
icon: icon,
|
|
iconColor: iconColor
|
|
)
|
|
}
|
|
|
|
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's cached icon and iconColor ride straight through to its row")
|
|
func iconAndIconColorPassThrough() throws {
|
|
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban", icon: "star.fill", iconColor: "fern"))
|
|
|
|
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
|
|
|
#expect(row.icon == "star.fill")
|
|
#expect(row.iconColor == "fern")
|
|
}
|
|
|
|
@Test("A record with no cached icon carries nil through to its row — the renderer's default to draw")
|
|
func noIconIsNilNotAGuess() throws {
|
|
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban"))
|
|
|
|
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
|
|
|
#expect(row.icon == nil)
|
|
#expect(row.iconColor == nil)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|