The app learns Appearance — Auto, Light, Dark from the View menu and a toolbar pull-down
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
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -841,6 +841,7 @@ struct UndoCommandSurfaceTests {
|
||||
store: store,
|
||||
search: BoardSearchPresentation(),
|
||||
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
|
||||
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
|
||||
session: DragSession()
|
||||
)
|
||||
let provider = FakeHistoryProvider()
|
||||
@@ -899,6 +900,7 @@ struct UndoCommandSurfaceTests {
|
||||
store: store,
|
||||
search: BoardSearchPresentation(),
|
||||
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!),
|
||||
appearance: AppearanceStore(defaults: UserDefaults(suiteName: zoomDomain + ".appearance")!, apply: { _ in }),
|
||||
session: DragSession()
|
||||
)
|
||||
let provider = FakeHistoryProvider()
|
||||
@@ -944,6 +946,7 @@ struct UndoCommandSurfaceTests {
|
||||
store: store,
|
||||
search: BoardSearchPresentation(),
|
||||
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
|
||||
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
|
||||
session: DragSession()
|
||||
)
|
||||
|
||||
|
||||
@@ -41,16 +41,32 @@ private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
|
||||
return store
|
||||
}
|
||||
|
||||
/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom
|
||||
/// store and a drag session with nothing in flight.
|
||||
/// An appearance store on a scratch defaults domain, apply seam stubbed out — this suite's subject is
|
||||
/// the toolbar item, not the store itself (`AppearanceTests.swift` owns that), so nothing here should
|
||||
/// touch `NSApp` (`makeZoom`'s reason, turned toward AppKit).
|
||||
@MainActor
|
||||
private func makeAppearance() -> AppearanceStore {
|
||||
let name = "dev.rzen.indie.Kanban.toolbar-tests.appearance.\(UUID().uuidString)"
|
||||
return AppearanceStore(defaults: UserDefaults(suiteName: name)!, apply: { _ in })
|
||||
}
|
||||
|
||||
/// The board catalog, with the three collaborators every test here supplies the same way: a fresh
|
||||
/// zoom store, a fresh appearance store, and a drag session with nothing in flight.
|
||||
@MainActor
|
||||
private func boardSpecs(
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation = BoardSearchPresentation(),
|
||||
zoom: BoardZoomStore? = nil,
|
||||
appearance: AppearanceStore? = nil,
|
||||
session: DragSession = DragSession()
|
||||
) -> [ToolbarItemSpec] {
|
||||
BoardToolbar.specs(store: store, search: search, zoom: zoom ?? makeZoom(), session: session)
|
||||
BoardToolbar.specs(
|
||||
store: store,
|
||||
search: search,
|
||||
zoom: zoom ?? makeZoom(),
|
||||
appearance: appearance ?? makeAppearance(),
|
||||
session: session
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The vocabulary
|
||||
@@ -99,16 +115,16 @@ struct ToolbarVocabularyTests {
|
||||
@Suite("Toolbar ▸ the board window")
|
||||
struct BoardToolbarTests {
|
||||
|
||||
@Test("The default set is the search field, trailing, and nothing else")
|
||||
func defaultsAreTheSearchFieldAlone() {
|
||||
// "Board window default: the search field, nothing else — trailing, the one default item;
|
||||
// the titlebar stays clean." The flexible space ahead of it is what "trailing" means to
|
||||
// NSToolbar, so the *items* in the default set are exactly one.
|
||||
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch])
|
||||
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
|
||||
@Test("The default set is the search field and Appearance, trailing, and nothing else")
|
||||
func defaultsAreTheSearchFieldAndAppearance() {
|
||||
// "Board window default: the search field and Appearance — both centered, immediately after
|
||||
// the field." The flexible space ahead of them is what keeps the pair off the leading edge, so
|
||||
// the *items* in the default set are exactly two.
|
||||
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch, .boardAppearance])
|
||||
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch, .boardAppearance])
|
||||
}
|
||||
|
||||
@Test("The catalog is 03's seven commands plus the field — and the board popover is not in it")
|
||||
@Test("The catalog is 03's eight commands plus the field — and the board popover is not in it")
|
||||
func catalogIsTheDesignsInventory() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -116,8 +132,8 @@ struct BoardToolbarTests {
|
||||
let specs = boardSpecs(store: store)
|
||||
|
||||
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
||||
// Show Trash" — plus the search field, which is a catalog item too (a user who removes it can
|
||||
// put it back).
|
||||
// Show Trash, Appearance" — plus the search field, which is a catalog item too (a user who
|
||||
// removes it can put it back).
|
||||
#expect(specs.map(\.identifier) == [
|
||||
.boardNewCard,
|
||||
.boardNewLane,
|
||||
@@ -126,12 +142,13 @@ struct BoardToolbarTests {
|
||||
.boardUndo,
|
||||
.boardRedo,
|
||||
.boardShowTrash,
|
||||
.boardAppearance,
|
||||
.boardSearch,
|
||||
])
|
||||
// "The board popover deliberately has no toolbar item — the window-title widget is its
|
||||
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
|
||||
// pinned by the exact-inventory assertion above and stated again here.
|
||||
#expect(specs.count == 8)
|
||||
#expect(specs.count == 9)
|
||||
}
|
||||
|
||||
/// The zoom pair is catalog-only — "the titlebar's default stays the search field alone"
|
||||
@@ -221,7 +238,7 @@ struct BoardToolbarTests {
|
||||
let specs = boardSpecs(store: store)
|
||||
|
||||
#expect(specs.map(\.label) == [
|
||||
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Search",
|
||||
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Appearance", "Search",
|
||||
])
|
||||
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels —
|
||||
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar
|
||||
@@ -275,7 +292,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let search = BoardSearchPresentation()
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||
|
||||
// 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange,
|
||||
// system overflow and icon/text display options".
|
||||
@@ -304,7 +321,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let search = BoardSearchPresentation()
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||
|
||||
#expect(search.focusField == nil, "nothing to focus until the item exists")
|
||||
|
||||
@@ -339,7 +356,7 @@ struct BoardToolbarTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), session: DragSession())
|
||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||
|
||||
let item = try #require(controller.toolbar(
|
||||
controller.toolbar,
|
||||
@@ -373,7 +390,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let search = BoardSearchPresentation()
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||
|
||||
_ = controller.toolbar(
|
||||
controller.toolbar,
|
||||
@@ -409,6 +426,45 @@ struct BoardToolbarTests {
|
||||
#expect(!store.transient.isTrashVisible)
|
||||
#expect(showTrash.isOn == false)
|
||||
}
|
||||
|
||||
/// The picker's whole state contract, `showTrashTogglesTheQuasiLane`'s shape turned toward
|
||||
/// `.picker`: no single `isOn`, so the checkmark and the write path are the item's own
|
||||
/// `selected`/`select` closures rather than `.isOn`/`.activate()`.
|
||||
@Test("Appearance is a picker whose selected row tracks the store and whose rows write it")
|
||||
func appearanceTracksTheStore() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let appearance = makeAppearance()
|
||||
let specs = boardSpecs(store: store, appearance: appearance)
|
||||
let item = try #require(specs.spec(.boardAppearance))
|
||||
|
||||
#expect(item.isOn == nil, "a picker has no single on-state")
|
||||
#expect(item.isEnabled, "always enabled — no board state gates an appearance override")
|
||||
|
||||
guard case let .picker(options, selected, select) = item.behavior else {
|
||||
Issue.record("Appearance is not a picker")
|
||||
return
|
||||
}
|
||||
#expect(options.map(\.title) == ["Auto", "Light", "Dark"])
|
||||
#expect(selected() == 0, "Auto by default, like the store's own nil override")
|
||||
|
||||
select(1)
|
||||
#expect(appearance.override == .light, "the row drives the store's own setter")
|
||||
#expect(selected() == 1)
|
||||
|
||||
select(2)
|
||||
#expect(appearance.override == .dark)
|
||||
#expect(selected() == 2)
|
||||
|
||||
select(0)
|
||||
#expect(appearance.override == nil)
|
||||
#expect(selected() == 0)
|
||||
|
||||
// `.activate()` is a no-op for a picker — firing lives in the dropdown's own rows.
|
||||
item.activate()
|
||||
#expect(appearance.override == nil, "activate() does not move the selection")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card window's toolbar
|
||||
|
||||
Reference in New Issue
Block a user