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]) } }