The popover's third tab settles (03 ▸ Git tab, 2026-08-07): BoardGitTabView takes the parked section — the six-posture matrix, the Pro pointer, the nested/unverifiable prose, the branch controls, the Board Settings… door — with the tab label doing the old "Git" header's naming work, and one new caption for the no-repository posture. BoardInfoTab.available drops the Git tab exactly where BoardGitSection resolves absent, carrying 12's absent-no-placeholder rule up to the tab strip; selection resets to Info per open, now a ruling. Daily face only — the remote block still joins with pro-m2. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
247 lines
12 KiB
Swift
247 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|