import Foundation import Testing @testable import Kanban /// **The popover's git slot, posture by posture** (03-board-ui.md ▸ Board popover ▸ Git tab; /// 06-history-undo.md ▸ Rules). /// /// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the board's /// **mode** precisely so the matrix is assertable — the views it selects are SwiftUI and stay /// untested. /// /// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07** (03's Git-tab note records the /// consequence): git left the paywall, so the two free-tier postures — `.absent` on an ordinary /// board and the `.proPointer` on one carrying an inert `.git`, both settled 2026-07-27 — described /// a gate that no longer exists and retired with it. With them went the `.git`-on-disk probe that /// fed them (`BoardGitNote.hasGitDirectory(at:)`) and the tab-strip membership filter that read /// `.absent` (`BoardInfoTab.available`, whose suite retired here the same day): detection now runs at /// every board open on every tier, and the mode is the one input left. @Suite("Board popover ▸ the git section's posture") struct BoardGitSectionTests { @Test("Mode none has no repository to describe, git mode shows the branch") func thePostureFollowsTheMode() { #expect(BoardGitSection.resolve(mode: .none) == .noRepository) #expect(BoardGitSection.resolve(mode: .git) == .branch) } @Test("A repo-nested board explains itself — setup is absent, not disabled") func repoNestedExplainsRatherThanDisables() { let section = BoardGitSection.resolve(mode: .repoNested) // The design is insistent here: "not a hidden 'add git' but a short explanation … the option // is absent because it *can't* apply, and the UI should teach that rather than look broken" // (06 ▸ Rules). A posture that rendered a disabled setup control would satisfy neither half — // and since the 2026-07-31 split the same sentence is what keeps the settings sheet itself // out of reach on such a board (`BoardSettingsAvailabilityTests`). #expect(section == .repoNested) #expect(section != .noRepository) } @Test("An unverifiable board gets its own posture — structurally like nested, never the same case") func unverifiableIsItsOwnPostureNotRepoNested() { // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check // is not a found repository, so the two must resolve to different cases even though both are // action-less, prose-only sections. let section = BoardGitSection.resolve(mode: .unverifiable) #expect(section == .unverifiable) #expect(section != .repoNested, "a denial is not a nesting") #expect(section != .noRepository) } /// **A board whose repository will not open keeps the git section it has** (06 ▸ Rules, the /// corrupt-`.git` loud failure, ruled 2026-07-31) — the *branch* section, held and explaining /// itself, never the mode-none posture that would imply a board with no history to have. /// /// The section case is deliberately blind to readability: what changes on such a board is what /// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation /// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The /// posture matrix stays a function of the mode alone. @Test("An unreadable repository is still the branch section — never the no-repository posture") func anUnreadableRepositoryKeepsTheBranchSection() { let section = BoardGitSection.resolve(mode: .git) #expect(section == .branch) #expect(section != .noRepository, "the board has a repository; it is unreadable, not absent") } @Test("Every posture is reachable, and none of them is two postures") func theMatrixIsTotal() { let resolved = Set(BoardGitMode.allCases.map { BoardGitSection.resolve(mode: $0) }) // One posture per mode, and every posture spoken for — which is also what makes the tab // strip's membership rule trivially true since the pivot: no mode resolves to nothing, so // the Git tab is never dropped (03-board-ui.md ▸ Board popover, the pivot note). #expect(resolved == Set(BoardGitSection.allCases)) #expect(resolved.count == BoardGitMode.allCases.count, "no two modes share a posture") } } /// **The tab strip carries every tab, always** (03-board-ui.md ▸ Board popover, the tabbed- /// popover paragraph as the 2026-08-07 pivot note leaves it): "the absent posture is unreachable and /// every board carries all three tabs. The membership rule stands structurally — the strip still asks /// the posture — it just never hears 'absent' anymore." (Three became four the same night: **Sync** /// joined as a standing placeholder, membership still `allCases`.) /// /// So there is no `available(…)` seam left to pin; what is worth keeping is the strip's *order*, /// which the popover's `Picker` takes from `allCases` and which the Git session fixed: Info first — /// it is the board's face and the tab selection resets to it on every open — then Theme, then Git, /// then the Sync placeholder at the end of the line, where a surface with nothing in it belongs. @Suite("Board popover ▸ the tab strip") struct BoardInfoTabStripTests { @Test("Info, Theme, Git, Sync — in that order, on every board") func theStripIsTheWholeSet() { #expect(BoardInfoTab.allCases == [.info, .theme, .git, .sync]) } @Test("The raw values are the segmented control's own labels") func labelsAreTheRawValues() { // The strip needs no separate label function, which is the only reason this enum is // `String`-backed at all. #expect(BoardInfoTab.allCases.map(\.rawValue) == ["Info", "Theme", "Git", "Sync"]) } } /// **The window-title widget's two strings** (03-board-ui.md ▸ Board popover, the card that widened /// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure /// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its mode /// input with. No disk I/O — the title half of this seam takes a raw `URL`, not a `BoardStore`, so a /// plain `/tmp/...` path is enough. /// /// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07.** The branch rule read `tier == /// .pro && mode == .git` until that day; git is tier-independent now, so a git-mode board shows its /// branch and nothing else does. @Suite("Board popover ▸ the widget's strings") struct BoardInfoTitlebarSummaryTests { private let root = URL(fileURLWithPath: "/tmp/My Board.board") @Test("The title is the on-disk title, falling back to the folder name sans extension") func titleFallsBackToTheFolderName() { let named = BoardInfoTitlebarSummary( snapshotTitle: "Sprint 12", rootURL: root, mode: .none, branch: nil ) #expect(named.title == "Sprint 12") let untitled = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, mode: .none, branch: nil ) #expect(untitled.title == "My Board") let emptyTitled = BoardInfoTitlebarSummary( snapshotTitle: "", rootURL: root, mode: .none, branch: nil ) #expect( emptyTitled.title == "My Board", "an empty title reads the same as no title at all — the window-title rule this seam restates" ) } @Test("The branch shows in git mode, once it has been read") func branchShowsInGitMode() { let summary = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, mode: .git, branch: "main" ) #expect(summary.branch == "main") } @Test("No other mode shows a branch, whatever the git state hands it") func onlyGitModeShowsABranch() { for mode in BoardGitMode.allCases where mode != .git { let summary = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, mode: mode, branch: "main" ) #expect( summary.branch == nil, "mode \(mode): a board the app manages no repository for must never surface a stray branch value" ) } } @Test("A git-mode board whose branch has not been read yet shows none, honestly") func unreadBranchShowsNone() { let summary = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, mode: .git, branch: nil ) #expect(summary.branch == nil) } }