Files
lanework/KanbanTests/BoardSettingsSheetTests.swift

208 lines
9.8 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// **The board settings sheet's two pure seams** (03-board-ui.md ▸ Board settings sheet, ruled
/// 2026-07-31 — the popover/sheet split; 04-interactions.md ▸ The map's configuration carve-out):
/// what the sheet holds for a board, and therefore whether the sheet exists for that board at all.
///
/// They are pinned here for `BoardGitSection.resolve`'s reason one surface over: the *inventory* is
/// the decision worth asserting and the SwiftUI that renders it is not. Every case below is a plain
/// value — no board on disk, no window, no session.
@Suite("Board settings sheet ▸ the sections")
struct BoardSettingsSectionTests {
@Test("Pro, mode none: the sheet is add-git and nothing else")
func modeNoneHoldsAddGit() {
// "add-git (mode none; opt-in init — 06)" — 03's own first entry for this surface, and the
// control that moved here out of the popover with the split.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .none) == [.git])
}
@Test("Pro, git mode: branch creation and the commit identity, in that order")
func gitModeHoldsCreationAndIdentity() {
// "branch creation (switching stays in the popover…)" and "commit identity name/email (06 —
// the visibility-scoped 2 s config re-read rides with the fields)".
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity])
}
@Test("Every section is reachable from some posture, and no posture invents one")
func theInventoryIsTotal() {
let offered = Set(
Tier.allCases.flatMap { tier in
BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(tier: tier, mode: $0) }
}
)
#expect(offered == Set(BoardSettingsSection.allCases))
}
@Test("Pro, unverifiable: the sheet has nothing to show — structurally like repo-nested")
func unverifiableHoldsNothing() {
// "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check
// can never be told apart from a repository actually being there, so add-git stays as
// unreachable here as it is on a genuinely nested board.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == [])
}
@Test("Pro, git mode with an unreadable repository: nothing setup-shaped applies either")
func anUnreadableRepositoryHoldsNothing() {
// **The corrupt-`.git` loud failure** (06 ▸ Rules, ruled 2026-07-31): both sections here are
// *writes* to a repository — a branch created in it, an identity written into its config —
// and there is no repository the app can open to write either into. Repo-nested's emptiness,
// reached one step further along.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true) == [])
// …and the mode is still `git` throughout: this is emptiness *within* git mode, never the
// fall to mode none that would let add-git be offered against an existing `.git`.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity])
}
@Test("The sections carry the headers VoiceOver navigates by")
func headersAreNamed() {
// 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver
// can navigate by". The strings are the surface's spoken structure, so they are stated once
// and pinned once.
#expect(BoardSettingsSection.git.title == "Git")
#expect(BoardSettingsSection.branch.title == "Branch")
#expect(BoardSettingsSection.commitIdentity.title == "Commit Identity")
}
}
/// **Where the sheet can be opened from, tier by mode** — the answer both doors validate on: Board ▸
/// Board Settings…'s `disabled` state, and whether the popover's git section renders its Board
/// Settings… row at all.
@Suite("Board settings sheet ▸ availability")
struct BoardSettingsAvailabilityTests {
@Test("The whole matrix: Pro on a none-or-git board, and nowhere else")
func theMatrix() {
// Pro, and a board whose mode leaves something to set up.
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .none))
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .git))
// **Pro, repo-nested**: "nothing setup-shaped can apply" — no add-git (06's prose, not a
// disabled button), no branch of ours to create, no repo-local config of ours to write. The
// popover's explanation stands and no door opens.
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested))
// **Pro, unverifiable**: the same unreachability, for the denial-not-absence reason — a
// denied ancestor check is never distinguishable from a repository actually being there.
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable))
// **Pro, git mode over a repository that will not open**: no door either, so neither the
// popover's Board Settings… row nor the menu command offers a surface with nothing on it
// (06 ▸ Rules, the corrupt-`.git` loud failure).
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true))
// **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and
// `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is
// swept for completeness rather than because it can vary.
for mode in BoardGitMode.allCases {
#expect(
!BoardSettingsAvailability.resolve(tier: .free, mode: mode),
"the free tier has no board settings sheet in any mode"
)
}
}
@Test("Reachable means exactly 'has something to show'")
func reachabilityIsTheInventory() {
// The derivation, not a coincidence: a surface whose whole job is hosting setup controls has
// no honest empty state, so the two answers are one answer. pro-m2's sections join the
// inventory and this identity keeps holding.
for tier in Tier.allCases {
for mode in BoardGitMode.allCases {
#expect(
BoardSettingsAvailability.resolve(tier: tier, mode: mode)
== !BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty
)
}
}
}
}
/// **The window's sheet flag** (`BoardSettingsPresentation`) — `BoardInfoPresentation`'s sibling,
/// with the one thing the popover flag does not carry: the session posture both doors validate on.
@MainActor
@Suite("Board settings sheet ▸ the window's presentation")
struct BoardSettingsPresentationTests {
@Test("It starts closed, unadopted, and unreachable")
func startsClosed() {
let presentation = BoardSettingsPresentation()
#expect(presentation.isPresented == false)
#expect(presentation.tier == .free)
#expect(presentation.git == nil)
// The free tier's posture is the safe default for a window whose session has not been adopted
// yet: an unreachable sheet, rather than a sheet with no sections in it.
#expect(presentation.isReachable == false)
#expect(presentation.sections.isEmpty)
}
@Test("An unreachable board's sheet refuses to present")
func presentRefusesWhereUnreachable() {
let presentation = BoardSettingsPresentation()
presentation.present()
#expect(presentation.isPresented == false, "the free tier has no sheet to open")
}
@Test("Two windows hold their own flags")
func perWindow() {
// `BoardInfoPresentation`'s rule, restated for the sheet: "two board windows each hold their
// own and can never toggle each other's".
let first = BoardSettingsPresentation()
let second = BoardSettingsPresentation()
first.isPresented = true
#expect(second.isPresented == false)
}
@Test("Adopting a Pro git session makes the sheet reachable, and dismissal is idempotent")
func adoptingASession() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
// Mode `none` — the add-git posture, which is the sheet's whole job on a board with no
// repository yet.
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(git.mode == .none)
let presentation = BoardSettingsPresentation()
presentation.adopt(tier: .pro, git: git)
#expect(presentation.isReachable)
#expect(presentation.sections == [.git])
presentation.present()
#expect(presentation.isPresented)
presentation.dismiss()
presentation.dismiss()
#expect(presentation.isPresented == false)
}
@Test("The free tier's session leaves both doors shut, .git on the folder or not")
func aFreeTierSessionIsUnreachable() 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))
// `compose` is the tier gate: the free tier gets no git state at all, so there is nothing for
// a settings sheet to be about even on a board carrying an inert `.git` — the popover's
// one-line Pro pointer is that board's whole story (12-editions.md).
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
let presentation = BoardSettingsPresentation()
presentation.adopt(tier: .free, git: nil)
#expect(presentation.isReachable == false)
presentation.present()
#expect(presentation.isPresented == false)
}
}