View ▸ Appearance (11-command-nexus.md): three radio-exclusive rows, app-wide, persisted, needing no window in front — the View menu's new last group. AppearanceStore owns the override's rules (absent key = Auto, lenient reads degrade to Auto, remove-at-default) with an injectable apply seam so test hosts never touch NSApp; the one real apply hands NSApp.appearance its answer in applicationDidFinishLaunching, the global side effect KanbanApp.init must not carry. The board toolbar gains its first .picker item — an NSMenuToolbarItem whose rows re-fetch their spec fresh, checkmark read at menu-open like every other menu row — and Appearance joins the search field as the second default item, centered beside it (03-board-ui.md ▸ Toolbar, ratified 2026-08-07). Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
167 lines
6.5 KiB
Swift
167 lines
6.5 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// An appearance store on a scratch defaults domain — the override is app-wide and persisted, so a
|
|
/// suite that used `.standard` would touch the developer's own appearance
|
|
/// (`BoardZoomTests.makeStore`'s reason). The apply seam defaults to a no-op so a test that is not
|
|
/// exercising it never touches `NSApp`.
|
|
@MainActor
|
|
private func makeStore(
|
|
seeding stored: String? = nil,
|
|
apply: @escaping (NSAppearance.Name?) -> Void = { _ in }
|
|
) -> (AppearanceStore, UserDefaults, () -> Void) {
|
|
let name = "dev.rzen.indie.Kanban.appearance-tests.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: name)!
|
|
if let stored { defaults.set(stored, forKey: AppPreferences.appearanceKey) }
|
|
return (AppearanceStore(defaults: defaults, apply: apply), defaults,
|
|
{ UserDefaults.standard.removePersistentDomain(forName: name) })
|
|
}
|
|
|
|
// MARK: - The pure resolver
|
|
|
|
/// `AppearanceStore.appearanceName(for:)` — no `NSApp`, no live application, provable with nothing but
|
|
/// the enum (`BoardZoomLadderTests`'s reason for testing `BoardZoom`'s pure functions on their own).
|
|
@Suite("Appearance ▸ the pure resolver")
|
|
struct AppearanceResolverTests {
|
|
|
|
@Test("Light resolves to aqua, dark to darkAqua, and Auto to nothing at all")
|
|
func resolvesToTheRightName() {
|
|
#expect(AppearanceStore.appearanceName(for: .light) == .aqua)
|
|
#expect(AppearanceStore.appearanceName(for: .dark) == .darkAqua)
|
|
#expect(AppearanceStore.appearanceName(for: nil) == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The persisted override
|
|
|
|
@Suite("Appearance ▸ the persisted override")
|
|
@MainActor
|
|
struct AppearanceStorePersistenceTests {
|
|
|
|
@Test("A fresh domain opens on Auto")
|
|
func freshDomainIsAuto() {
|
|
let (store, _, tearDown) = makeStore()
|
|
defer { tearDown() }
|
|
#expect(store.override == nil)
|
|
}
|
|
|
|
@Test("Light and Dark survive the trip through defaults")
|
|
func overrideRoundTrips() {
|
|
let (store, defaults, tearDown) = makeStore()
|
|
defer { tearDown() }
|
|
|
|
store.setOverride(.light)
|
|
#expect(AppearanceStore(defaults: defaults).override == .light)
|
|
|
|
store.setOverride(.dark)
|
|
#expect(AppearanceStore(defaults: defaults).override == .dark)
|
|
}
|
|
|
|
/// The remove-at-default idiom, `BoardZoomStore`'s neighbours already keep it: Auto is meant to
|
|
/// read as "no override on file" to anyone who inspects the domain, not as a third stored spelling
|
|
/// of the same thing.
|
|
@Test("Setting Auto removes the key rather than writing a third spelling of it")
|
|
func autoRemovesTheKey() {
|
|
let (store, defaults, tearDown) = makeStore(seeding: "light")
|
|
defer { tearDown() }
|
|
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")
|
|
|
|
store.setOverride(nil)
|
|
|
|
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == nil)
|
|
#expect(store.override == nil)
|
|
}
|
|
|
|
/// The trap this exists for: a hand edit, or a future build's spelling read by an older one, must
|
|
/// degrade rather than crash or silently misapply an override nobody asked for.
|
|
@Test("A stored string that is neither light nor dark degrades to Auto")
|
|
func unknownStringIsAuto() {
|
|
for stored in ["sepia", "", "Light", "LIGHT", "light "] {
|
|
let (store, _, tearDown) = makeStore(seeding: stored)
|
|
defer { tearDown() }
|
|
#expect(store.override == nil, "\"\(stored)\" must not resolve to an override")
|
|
}
|
|
}
|
|
|
|
@Test("Every case survives its own round trip through the raw value")
|
|
func everyCaseRoundTrips() {
|
|
for override in AppAppearance.allCases {
|
|
let (store, defaults, tearDown) = makeStore()
|
|
defer { tearDown() }
|
|
store.setOverride(override)
|
|
#expect(AppearanceStore(defaults: defaults).override == override)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The apply seam
|
|
|
|
/// The one write path — `setOverride` and `applyCurrent` — and the seam it hands its answer to,
|
|
/// proven with a recording closure rather than `NSApp` (`AppearanceStore.init`'s own reason for taking
|
|
/// one).
|
|
@Suite("Appearance ▸ the apply seam")
|
|
@MainActor
|
|
struct AppearanceApplySeamTests {
|
|
|
|
@Test("Setting an override invokes the apply seam with the resolved name, in order")
|
|
func setInvokesApply() {
|
|
var applied: [NSAppearance.Name?] = []
|
|
let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
|
|
defer { tearDown() }
|
|
|
|
store.setOverride(.light)
|
|
store.setOverride(.dark)
|
|
store.setOverride(nil)
|
|
|
|
#expect(applied == [.aqua, .darkAqua, nil])
|
|
}
|
|
|
|
/// `BoardZoomStore.setLevel`'s own guard, load-bearing for the same reason: `@Observable` notifies
|
|
/// on every assignment, equal or not, so an ungated write would hand the apply seam a repeat call
|
|
/// for a selection that never moved.
|
|
@Test("An unchanged value writes and applies nothing")
|
|
func unchangedValueIsANoOp() {
|
|
var applyCount = 0
|
|
let (store, defaults, tearDown) = makeStore(apply: { _ in applyCount += 1 })
|
|
defer { tearDown() }
|
|
|
|
store.setOverride(.light)
|
|
#expect(applyCount == 1)
|
|
store.setOverride(.light)
|
|
#expect(applyCount == 1, "the same value again must not re-apply")
|
|
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")
|
|
|
|
store.setOverride(nil)
|
|
#expect(applyCount == 2)
|
|
store.setOverride(nil)
|
|
#expect(applyCount == 2, "Auto set twice must not re-apply either")
|
|
}
|
|
|
|
/// Launch's whole job: `init` only reads, so nothing has applied yet until this is called.
|
|
@Test("applyCurrent re-applies the stored value, and reads nothing new")
|
|
func applyCurrentReappliesTheStoredValue() {
|
|
var applied: [NSAppearance.Name?] = []
|
|
let (store, _, tearDown) = makeStore(seeding: "dark", apply: { applied.append($0) })
|
|
defer { tearDown() }
|
|
#expect(applied.isEmpty, "construction alone must not touch the apply seam")
|
|
|
|
store.applyCurrent()
|
|
|
|
#expect(applied == [.darkAqua])
|
|
#expect(store.override == .dark, "applyCurrent hands out what init already resolved")
|
|
}
|
|
|
|
@Test("applyCurrent on a fresh Auto domain applies nil")
|
|
func applyCurrentOnAutoAppliesNil() {
|
|
var applied: [NSAppearance.Name?] = []
|
|
let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
|
|
defer { tearDown() }
|
|
|
|
store.applyCurrent()
|
|
|
|
#expect(applied == [nil])
|
|
}
|
|
}
|