import Foundation import Testing @testable import Kanban /// **The board popover's git-note detection** (12-editions.md ▸ The free tier and `.git`, ruled /// 2026-07-27): the free tier's whole git story is one line, shown only on a board that carries an /// inert `.git`. The seam /// that decides *whether* to show it — `BoardGitNote.hasGitDirectory(at:)` — is a pure, read-only /// `FileManager` check, pinned here against real bytes on disk. Everything else about the note (its /// wording, its placement in `BoardInfoView`) is SwiftUI rendering and deliberately untested. struct BoardInfoPopoverTests { @Test("A board with a .git at its root reports true") func trueForABoardWithGit() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) #expect(BoardGitNote.hasGitDirectory(at: fixture.root)) } @Test("An ordinary board with no .git reports false") func falseForAnOrdinaryBoard() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) #expect(!BoardGitNote.hasGitDirectory(at: fixture.root)) } } /// **The popover's git slot, posture by posture** (03-board-ui.md ▸ Board popover; 06-history-undo.md /// ▸ Rules; 12-editions.md ▸ The free tier and `.git`). /// /// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the tier and the /// board's mode precisely so the matrix is assertable — the views it selects are SwiftUI and stay /// untested, exactly as the note's wording and placement do above. @Suite("Board popover ▸ the git section's posture") struct BoardGitSectionTests { @Test("The free tier: absent on an ordinary board, a Pro pointer on a board carrying an inert .git") func theFreeTierIsContextual() { #expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: false) == .absent) #expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer) } @Test("Pro: mode none has no repository to describe, git mode shows the branch") func proFollowsTheMode() { #expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .noRepository) #expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch) } @Test("A repo-nested board explains itself — setup is absent, not disabled") func repoNestedExplainsRatherThanDisables() { let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false) // 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(tier: .pro, mode: .unverifiable, hasGitDirectory: false) #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 tier and the mode alone. @Test("An unreadable repository is still the branch section — never the no-repository posture") func anUnreadableRepositoryKeepsTheBranchSection() { let section = BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) #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( Tier.allCases.flatMap { tier in BoardGitMode.allCases.flatMap { mode in [true, false].map { BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: $0) } } } ) #expect(resolved == Set(BoardGitSection.allCases)) } } /// **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 /// tier/mode inputs 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. @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, tier: .free, mode: .none, branch: nil ) #expect(named.title == "Sprint 12") let untitled = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, tier: .free, mode: .none, branch: nil ) #expect(untitled.title == "My Board") let emptyTitled = BoardInfoTitlebarSummary( snapshotTitle: "", rootURL: root, tier: .free, 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 under Pro, in git mode, once it has been read") func branchShowsForProGitMode() { let summary = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: "main" ) #expect(summary.branch == "main") } @Test("The free tier never shows a branch, whatever the mode or the git state hands it") func theFreeTierNeverShowsABranch() { for mode in BoardGitMode.allCases { let summary = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, tier: .free, mode: mode, branch: "main" ) #expect(summary.branch == nil, "detection never runs under the free tier, so a stray branch value must never surface") } } @Test("An inert .git under Pro — mode none, repo-nested, or unverifiable — never shows a branch") func inertGitNeverShowsABranch() { #expect( BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil ) #expect( BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch == nil ) #expect( BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .unverifiable, branch: "main").branch == nil ) } @Test("A git-mode board whose branch has not been read yet shows none, honestly") func unreadBranchShowsNone() { let summary = BoardInfoTitlebarSummary( snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: nil ) #expect(summary.branch == nil) } }