Files
web-terminal/ios/App/WebTermTests/AppThemeTests.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
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.
2026-07-30 15:58:01 +02:00

354 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-34 · / /
/// ** token **doc §4 AE 124
///
/// " `.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:1WCAG 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 两档不同,且都仍是 washalpha < 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:121: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))
}
}