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 `BoardGitTabView`) is SwiftUI rendering and deliberately untested. /// /// Since the 2026-08-07 tab restructure this same answer decides one thing more: whether the popover /// carries a Git tab at all on a free board (`BoardInfoTabAvailabilityTests` below). 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 tab strip's membership** (03-board-ui.md ▸ Board popover, the tabbed-popover paragraph — /// the Git session's ruling, 2026-08-07): the Git tab joins the strip only where the git section has /// something true to say, so a free-tier board with no `.git` shows Info | Theme alone. /// /// `BoardInfoTab.available` is pinned here for the reason `BoardGitSection.resolve` is pinned above: /// it is the whole decision, pure in its three inputs, and the segmented `Picker` it feeds is SwiftUI /// and stays untested. What these tests are really about is that membership **is** the posture rather /// than a second reading of it — the last one walks the entire input matrix and asserts the tab's /// presence against `resolve` itself, so the strip cannot drift from the surface it labels. @Suite("Board popover ▸ the tab strip's membership") struct BoardInfoTabAvailabilityTests { @Test("A free board with no .git shows Info and Theme alone — never a standing Git tab") func theFreeTierDropsTheGitTab() { // 12-editions.md's "absent, no placeholder" carried up to the tab strip: a Git tab on every // free board would be the standing ad for Pro that 12 forbids. #expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: false) == [.info, .theme]) } @Test("A free board carrying an inert .git earns the tab — it has the Pro pointer to say") func anInertGitEarnsTheTab() { #expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: true) == [.info, .theme, .git]) } @Test("Pro shows the Git tab under every mode — each of the four postures is a tab surface") func proAlwaysShowsTheTab() { for mode in BoardGitMode.allCases { for hasGit in [true, false] { #expect( BoardInfoTab.available(tier: .pro, mode: mode, hasGitDirectory: hasGit) == [.info, .theme, .git], "Pro, mode \(mode): the tab is the posture's home, and Git is always last" ) } } } @Test("The tab is present exactly where the git section is not absent, across the whole matrix") func membershipDelegatesToThePosture() { for tier in Tier.allCases { for mode in BoardGitMode.allCases { for hasGit in [true, false] { let tabs = BoardInfoTab.available(tier: tier, mode: mode, hasGitDirectory: hasGit) let isAbsent = BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGit) == .absent #expect( tabs.contains(.git) == !isAbsent, "\(tier)/\(mode)/git=\(hasGit): membership is the posture, not a second reading of it" ) #expect( Array(tabs.prefix(2)) == [.info, .theme], "Info and Theme stand on every board, in that order — Info is the board's face" ) #expect(tabs.count == Set(tabs).count, "no tab appears twice") } } } } } /// **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) } }