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. Its 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(), icon: String? = nil, iconColor: String? = nil ) -> BoardRecord { BoardRecord( bookmark: Data(), 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) } }