import SwiftUI import Testing import UIKit @testable import WebTerm /// T-iOS-34 · 主题(跟随系统 / 深色 / 浅色)—— 模型、持久化、有效配色解析, /// 以及**浅色通路的 token 审计**(doc §4 A–E 条目 1–24)。 /// /// 审计的核心不是"把 `.preferredColorScheme(.dark)` 解锁",而是:每个语义色在 /// 浅色档下都要有**能看清**的值。故 D 组用 WCAG 对比度(非文本 UI 组件门槛 /// 1.4.11 = 3:1;终端正文按 AAA = 7:1)逐档量化,深色档同时**逐字节钉死**旧值 /// 保证零回归。 @MainActor @Suite("AppTheme") struct AppThemeTests { // MARK: - A. 主题模型 @Test("colorScheme:.system 交给系统(nil),.dark/.light 强制自身") func colorSchemePerCase() { #expect(AppTheme.system.colorScheme == nil) #expect(AppTheme.dark.colorScheme == .dark) #expect(AppTheme.light.colorScheme == .light) } @Test("三档 label / SF Symbol 非空且互不相同") func labelsAndSymbolsAreDistinct() { let labels = AppTheme.allCases.map(\.label) let symbols = AppTheme.allCases.map(\.symbolName) #expect(labels.allSatisfy { !$0.isEmpty }) #expect(symbols.allSatisfy { !$0.isEmpty }) #expect(Set(labels).count == AppTheme.allCases.count) #expect(Set(symbols).count == AppTheme.allCases.count) } @Test("allCases 顺序 = 跟随系统 → 深色 → 浅色(选择器直接用它,不重排)") func caseOrderIsPickerOrder() { #expect(AppTheme.allCases == [.system, .dark, .light]) } @Test("rawValue 白名单:只认三个小写标识") func rawValueWhitelist() { #expect(AppTheme(rawValue: "system") == .system) #expect(AppTheme(rawValue: "dark") == .dark) #expect(AppTheme(rawValue: "light") == .light) #expect(AppTheme(rawValue: "") == nil) #expect(AppTheme(rawValue: "Dark") == nil) #expect(AppTheme(rawValue: "solarized") == nil) } // MARK: - B. 持久化 @Test("未设置 → .dark(默认必须等于今天硬锁的深色,零回归)") func decodeMissingIsDark() { #expect(AppThemeCodec.decode(nil) == .dark) #expect(AppThemeCodec.fallback == .dark) } @Test("脏值 / 空串 / 大小写不符 → .dark,不崩、不落 system") func decodeGarbageIsDark() { #expect(AppThemeCodec.decode("solarized") == .dark) #expect(AppThemeCodec.decode("") == .dark) #expect(AppThemeCodec.decode("DARK") == .dark) #expect(AppThemeCodec.decode("\u{0}light") == .dark) } @Test("三档 encode → decode 往返恒等") func codecRoundTrips() { for theme in AppTheme.allCases { #expect(AppThemeCodec.decode(AppThemeCodec.encode(theme)) == theme) } } @Test("空 defaults → .dark,且首次读不写盘") func storeStartsDarkWithoutWriting() { let defaults = FakeThemeDefaults() let store = ThemeStore(defaults: defaults) #expect(store.theme == .dark) #expect(defaults.writeCount == 0) } @Test("select(.light) → 落盘 \"light\",同一 defaults 新建 store 读回(跨启动)") func selectPersistsAcrossStores() { let defaults = FakeThemeDefaults() let store = ThemeStore(defaults: defaults) store.select(.light) #expect(store.theme == .light) #expect(defaults.values[ThemeStore.storageKey] == "light") #expect(ThemeStore(defaults: defaults).theme == .light) } @Test("select 同一档两次 → 只写一次") func repeatedSelectWritesOnce() { let defaults = FakeThemeDefaults() let store = ThemeStore(defaults: defaults) store.select(.light) store.select(.light) #expect(defaults.writeCount == 1) } @Test("defaults 里是脏值 → 读作 .dark,且不动其它键") func dirtyStoredValueDegradesToDark() { let defaults = FakeThemeDefaults(values: [ ThemeStore.storageKey: "; rm -rf ~", "unrelated.key": "keep-me", ]) let store = ThemeStore(defaults: defaults) #expect(store.theme == .dark) #expect(defaults.values["unrelated.key"] == "keep-me") } // MARK: - C. 有效配色解析 @Test("resolvedScheme:.system 透传系统值,.dark/.light 无视系统") func resolvedScheme() { #expect(AppTheme.system.resolvedScheme(system: .light) == .light) #expect(AppTheme.system.resolvedScheme(system: .dark) == .dark) #expect(AppTheme.dark.resolvedScheme(system: .light) == .dark) #expect(AppTheme.light.resolvedScheme(system: .dark) == .light) } // MARK: - D. 浅色通路 token 审计 /// 受审的 5 个语义色(状态三色 + 时间线两色)——它们原本都是单值深色专用。 private static let semanticColors: [(name: String, color: Color)] = [ ("statusWorking", DS.Palette.statusWorking), ("statusWaiting", DS.Palette.statusWaiting), ("statusStuck", DS.Palette.statusStuck), ("timelineTool", DS.Palette.timelineTool), ("timelineUser", DS.Palette.timelineUser), ] @Test("5 个语义色在 light 与 dark 下解析值不同(真有浅色变体)") func semanticColorsAreAdaptive() { for (name, color) in Self.semanticColors { let dark = UIColor(color).resolved(.dark) let light = UIColor(color).resolved(.light) #expect(!ColorMath.approxEqual(dark, light), "\(name) 在两档下相同 → 没有浅色变体") } } @Test("深色档逐字节不变(#46D07F/#F5B14C/#FF6B6B/#5E9EFF/#AF7BFF)") func darkSchemeValuesUnchanged() { let expected: [(Color, UInt32)] = [ (DS.Palette.statusWorking, 0x46D0_7F), (DS.Palette.statusWaiting, 0xF5B1_4C), (DS.Palette.statusStuck, 0xFF6B_6B), (DS.Palette.timelineTool, 0x5E9E_FF), (DS.Palette.timelineUser, 0xAF7B_FF), ] for (color, hex) in expected { let resolved = UIColor(color).resolved(.dark) #expect( ColorMath.matchesHex(resolved, hex), "深色档漂移:实际 \(ColorMath.hexString(resolved))" ) } } @Test("两档下语义色对该档 systemBackground 的对比度 ≥ 3:1(WCAG 1.4.11)") func semanticColorsMeetNonTextContrast() { for style in [UIUserInterfaceStyle.dark, .light] { let background = UIColor.systemBackground.resolved(style) for (name, color) in Self.semanticColors { let ratio = ColorMath.contrast(UIColor(color).resolved(style), background) #expect(ratio >= 3, "\(name) 在 \(style == .dark ? "dark" : "light") 只有 \(ratio):1") } } } @Test("light 档 working/waiting/stuck 仍两两可辨") func criticalTrioStaysDistinctInLight() { let working = UIColor(DS.Palette.statusWorking).resolved(.light) let waiting = UIColor(DS.Palette.statusWaiting).resolved(.light) let stuck = UIColor(DS.Palette.statusStuck).resolved(.light) #expect(!ColorMath.approxEqual(working, waiting)) #expect(!ColorMath.approxEqual(working, stuck)) #expect(!ColorMath.approxEqual(waiting, stuck)) } @Test("accentSoft 两档不同,且都仍是 wash(alpha < 0.3)") func accentSoftIsAdaptiveWash() { let soft = UIColor(DS.Palette.accentSoft) let dark = soft.resolved(.dark) let light = soft.resolved(.light) #expect(!ColorMath.approxEqual(dark, light)) #expect(ColorMath.rgba(dark).a < 0.3) #expect(ColorMath.rgba(light).a < 0.3) } @Test("onAccent 两档相同(金填充恒需深墨),与 accent 对比 ≥ 4.5:1") func onAccentIsFixedAndReadable() { let ink = UIColor(DS.Palette.onAccent) #expect(ColorMath.approxEqual(ink.resolved(.dark), ink.resolved(.light))) for style in [UIUserInterfaceStyle.dark, .light] { let ratio = ColorMath.contrast( ink.resolved(style), DS.Palette.accentUIColor().resolved(style) ) #expect(ratio >= 4.5, "onAccent/accent 在 \(style.rawValue) 只有 \(ratio):1") } } // MARK: - E. 终端画布跟随主题 @Test("dark 档终端 = #100F0D / #ECE9E3(桌面 web --bg/--text,零回归)") func terminalDarkMatchesDesktop() { let colors = TerminalPalette.colors(for: .dark) #expect(ColorMath.matchesHex(colors.background, 0x100F_0D), "实际 \(ColorMath.hexString(colors.background))") #expect(ColorMath.matchesHex(colors.foreground, 0xECE9_E3), "实际 \(ColorMath.hexString(colors.foreground))") } @Test("light 档终端 = #F6F7F9 / #1A1D24(镜像 web THEMES.light)") func terminalLightMirrorsWeb() { let colors = TerminalPalette.colors(for: .light) #expect(ColorMath.matchesHex(colors.background, 0xF6F7_F9), "实际 \(ColorMath.hexString(colors.background))") #expect(ColorMath.matchesHex(colors.foreground, 0x1A1D_24), "实际 \(ColorMath.hexString(colors.foreground))") } @Test("两档终端 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA)") func terminalContrastIsAAA() { for scheme in [ColorScheme.dark, .light] { let colors = TerminalPalette.colors(for: scheme) let ratio = ColorMath.contrast(colors.foreground, colors.background) #expect(ratio >= 7, "终端 \(scheme) 对比度只有 \(ratio):1") } } @Test("caret / selection 用该档 accent 解析值") func terminalCaretFollowsAccent() { for (scheme, style) in [(ColorScheme.dark, UIUserInterfaceStyle.dark), (ColorScheme.light, UIUserInterfaceStyle.light)] { let colors = TerminalPalette.colors(for: scheme) let accent = DS.Palette.accentUIColor().resolved(style) #expect(ColorMath.approxEqual(colors.caret, accent)) #expect(ColorMath.approxEqual(colors.selection, accent)) } } @Test("terminalBackgroundUIColor()/ForegroundUIColor() 是动态 UIColor(两档不同)") func terminalTokensAreDynamic() { for token in [DS.Palette.terminalBackgroundUIColor(), DS.Palette.terminalForegroundUIColor()] { #expect(!ColorMath.approxEqual(token.resolved(.dark), token.resolved(.light))) } } @Test("SwiftUI 侧 terminalBackground/Foreground 同样跟随主题(既有调用点不退化)") func terminalSwiftUITokensStayDynamic() { for color in [DS.Palette.terminalBackground, DS.Palette.terminalForeground] { let bridged = UIColor(color) #expect(!ColorMath.approxEqual(bridged.resolved(.dark), bridged.resolved(.light))) } } } // MARK: - Fake defaults /// `ThemeDefaults` 测试替身:记录写次数("首次读不写盘"、"重复 select 只写一次" /// 两条断言都靠它),并保留其它键以证明 store 不会越界清理。 final class FakeThemeDefaults: ThemeDefaults { private(set) var values: [String: String] private(set) var writeCount = 0 init(values: [String: String] = [:]) { self.values = values } func themeRaw(forKey key: String) -> String? { values[key] } func setThemeRaw(_ value: String, forKey key: String) { values = values.merging([key: value]) { _, new in new } writeCount += 1 } } // MARK: - Color math (WCAG) /// 颜色解析 + WCAG 相对亮度/对比度。放在测试侧:生产代码不需要算对比度, /// 但审计必须**量化**("看着还行"不是验收)。公式取 WCAG 2.1 定义。 enum ColorMath { typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) static func rgba(_ color: UIColor) -> RGBA { var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 color.getRed(&r, green: &g, blue: &b, alpha: &a) return (r, g, b, a) } static func approxEqual(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.02) -> Bool { let left = rgba(lhs), right = rgba(rhs) return abs(left.r - right.r) < tolerance && abs(left.g - right.g) < tolerance && abs(left.b - right.b) < tolerance && abs(left.a - right.a) < tolerance } /// WCAG 相对亮度(sRGB → 线性)。 static func luminance(_ color: UIColor) -> CGFloat { let components = rgba(color) func linear(_ channel: CGFloat) -> CGFloat { channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) } return 0.2126 * linear(components.r) + 0.7152 * linear(components.g) + 0.0722 * linear(components.b) } /// WCAG 对比度(1:1…21:1),顺序无关。 static func contrast(_ lhs: UIColor, _ rhs: UIColor) -> CGFloat { let a = luminance(lhs), b = luminance(rhs) let (lighter, darker) = a > b ? (a, b) : (b, a) return (lighter + 0.05) / (darker + 0.05) } /// 逐字节(±1/255)比对一个具体色与 `0xRRGGBB`。 static func matchesHex(_ color: UIColor, _ hex: UInt32, tolerance: CGFloat = 0.004) -> Bool { let components = rgba(color) let expected = ( r: CGFloat((hex >> 16) & 0xFF) / 255, g: CGFloat((hex >> 8) & 0xFF) / 255, b: CGFloat(hex & 0xFF) / 255 ) return abs(components.r - expected.r) < tolerance && abs(components.g - expected.g) < tolerance && abs(components.b - expected.b) < tolerance } /// 失败时把实际值印成 `#RRGGBB`,便于一眼看出差多少。 static func hexString(_ color: UIColor) -> String { let components = rgba(color) let value = (Int(components.r * 255) << 16) | (Int(components.g * 255) << 8) | Int(components.b * 255) return String(format: "#%06X", value) } } extension UIColor { /// 按指定外观解析动态色(审计的基本动作)。 func resolved(_ style: UIUserInterfaceStyle) -> UIColor { resolvedColor(with: UITraitCollection(userInterfaceStyle: style)) } }