App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
354 lines
14 KiB
Swift
354 lines
14 KiB
Swift
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))
|
||
}
|
||
}
|