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.
176 lines
7.2 KiB
Swift
176 lines
7.2 KiB
Swift
import SwiftUI
|
||
import Testing
|
||
import UIKit
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// UX-A · Proves the FROZEN design system: the status mapping is complete and
|
||
/// "color + shape" (never color alone), the token scales are well-formed, and
|
||
/// the adaptive accent resolves distinctly in light vs dark. Pure logic — runs
|
||
/// on both iPhone and iPad sims with no rendering.
|
||
@Suite("DesignSystem")
|
||
struct DesignSystemTests {
|
||
|
||
// MARK: - StatusStyle: complete, distinct, color + shape
|
||
|
||
@Test("every status maps to a non-empty symbol + Chinese label")
|
||
func everyStatusHasSymbolAndLabel() {
|
||
for status in DisplayStatus.allCases {
|
||
let style = StatusStyle.style(for: status)
|
||
#expect(!style.symbolName.isEmpty, "\(status) has no symbol")
|
||
#expect(!style.label.isEmpty, "\(status) has no label")
|
||
}
|
||
}
|
||
|
||
@Test("status is never color-alone — all seven symbols are distinct")
|
||
func symbolsAreDistinctAcrossStatuses() {
|
||
let symbols = DisplayStatus.allCases.map { StatusStyle.style(for: $0).symbolName }
|
||
#expect(Set(symbols).count == DisplayStatus.allCases.count)
|
||
}
|
||
|
||
@Test("Chinese labels are all distinct")
|
||
func labelsAreDistinct() {
|
||
let labels = DisplayStatus.allCases.map { StatusStyle.style(for: $0).label }
|
||
#expect(Set(labels).count == DisplayStatus.allCases.count)
|
||
}
|
||
|
||
@Test("the critical trio working/waiting/stuck use distinct colors")
|
||
func criticalColorsAreDistinct() {
|
||
let working = rgba(StatusStyle.style(for: DisplayStatus.working).color)
|
||
let waiting = rgba(StatusStyle.style(for: DisplayStatus.waiting).color)
|
||
let stuck = rgba(StatusStyle.style(for: DisplayStatus.stuck).color)
|
||
#expect(!approxEqual(working, waiting))
|
||
#expect(!approxEqual(working, stuck))
|
||
#expect(!approxEqual(waiting, stuck))
|
||
}
|
||
|
||
/// T-iOS-34 **有意改写**:这三个 token 从"单值"变成了 scheme-adaptive
|
||
/// (深色 = web 原值不变,浅色 = 新增的可读变体,见 `Tokens.swift`)。原用例
|
||
/// 用 `UIColor(color).getRed(...)` 隐式按**测试进程当前 trait**(浅色)解析,
|
||
/// 于是量到了新的浅色值。断言的意图没变 —— "深色档必须逐字节等于桌面 web 的
|
||
/// 状态色" —— 只是现在必须**显式指定深色 trait** 才能表达这个意图。
|
||
/// 浅色档的取值与对比度门槛由 `AppThemeTests` 覆盖。
|
||
@Test("semantic status colors match the desktop/web status palette (dark scheme)")
|
||
func statusColorsMatchDirection() {
|
||
let dark = UITraitCollection(userInterfaceStyle: .dark)
|
||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, in: dark,
|
||
r: 70, g: 208, b: 127) // #46D07F web --green
|
||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, in: dark,
|
||
r: 245, g: 177, b: 76) // #F5B14C web --amber
|
||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, in: dark,
|
||
r: 255, g: 107, b: 107) // #FF6B6B web --red
|
||
}
|
||
|
||
@Test("the wire ClaudeStatus bridge covers all five cases")
|
||
func wireBridgeCoversEveryCase() {
|
||
let pairs: [(ClaudeStatus, DisplayStatus)] = [
|
||
(.working, .working), (.waiting, .waiting), (.idle, .idle),
|
||
(.stuck, .stuck), (.unknown, .unknown),
|
||
]
|
||
for (wire, expected) in pairs {
|
||
#expect(DisplayStatus(wire) == expected)
|
||
#expect(StatusStyle.style(for: wire) == StatusStyle.style(for: expected))
|
||
}
|
||
}
|
||
|
||
// MARK: - Token scales are well-formed
|
||
|
||
@Test("spacing scale is exactly 2·4·8·12·16·20·24 and strictly increasing")
|
||
func spacingScaleIsMonotonic() {
|
||
let scale: [CGFloat] = [
|
||
DS.Space.xs2, DS.Space.xs4, DS.Space.sm8,
|
||
DS.Space.md12, DS.Space.lg16, DS.Space.xl20, DS.Space.xxl24,
|
||
]
|
||
#expect(scale == [2, 4, 8, 12, 16, 20, 24])
|
||
#expect(isStrictlyIncreasing(scale))
|
||
}
|
||
|
||
@Test("radius scale is strictly increasing sm8 < md12 < lg16 < pill")
|
||
func radiusScaleIsMonotonic() {
|
||
let scale: [CGFloat] = [DS.Radius.sm8, DS.Radius.md12, DS.Radius.lg16, DS.Radius.pill]
|
||
#expect(scale == [8, 12, 16, 999])
|
||
#expect(isStrictlyIncreasing(scale))
|
||
}
|
||
|
||
@Test("opacity dimming tokens are within (0, 1)")
|
||
func opacityTokensAreFractions() {
|
||
for value in [DS.Opacity.stale, DS.Opacity.exited, DS.Opacity.pressed] {
|
||
#expect(value > 0 && value < 1)
|
||
}
|
||
}
|
||
|
||
@Test("minimum hit target meets the 44pt HIG floor")
|
||
func hitTargetMeetsHIG() {
|
||
#expect(DS.Layout.minHitTarget >= 44)
|
||
}
|
||
|
||
// MARK: - Motion honors Reduce Motion
|
||
|
||
@Test("motion collapses to nil under Reduce Motion, animates otherwise")
|
||
func motionGating() {
|
||
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: true) == nil)
|
||
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: false) != nil)
|
||
#expect(DS.Motion.fastDuration < DS.Motion.baseDuration)
|
||
}
|
||
|
||
// MARK: - Adaptive accent resolves for both schemes
|
||
|
||
@Test("accent is defined and distinct in light vs dark")
|
||
func accentIsAdaptive() {
|
||
let accent = DS.Palette.accentUIColor()
|
||
let dark = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark))
|
||
let light = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
|
||
|
||
let darkRGBA = rgba(dark)
|
||
let lightRGBA = rgba(light)
|
||
// Both fully opaque, and visibly different between schemes.
|
||
#expect(darkRGBA.a == 1)
|
||
#expect(lightRGBA.a == 1)
|
||
#expect(!approxEqual(darkRGBA, lightRGBA))
|
||
// Desktop amber gold: dark ≈ #E3A64A (--accent), light ≈ #C9892F (--accent-2).
|
||
assertRGBA(darkRGBA, r: 0xE3, g: 0xA6, b: 0x4A)
|
||
assertRGBA(lightRGBA, r: 0xC9, g: 0x89, b: 0x2F)
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
|
||
|
||
private func isStrictlyIncreasing(_ values: [CGFloat]) -> Bool {
|
||
zip(values, values.dropFirst()).allSatisfy { $0 < $1 }
|
||
}
|
||
|
||
private func rgba(_ color: Color) -> RGBA {
|
||
rgba(UIColor(color))
|
||
}
|
||
|
||
private 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)
|
||
}
|
||
|
||
private func approxEqual(_ lhs: RGBA, _ rhs: RGBA, tolerance: CGFloat = 0.02) -> Bool {
|
||
abs(lhs.r - rhs.r) < tolerance
|
||
&& abs(lhs.g - rhs.g) < tolerance
|
||
&& abs(lhs.b - rhs.b) < tolerance
|
||
}
|
||
|
||
private func assertColor(_ color: Color, r: Int, g: Int, b: Int) {
|
||
assertRGBA(rgba(color), r: r, g: g, b: b)
|
||
}
|
||
|
||
/// Same, but resolving an adaptive token in an explicit scheme.
|
||
private func assertColor(
|
||
_ color: Color, in traits: UITraitCollection, r: Int, g: Int, b: Int
|
||
) {
|
||
assertRGBA(rgba(UIColor(color).resolvedColor(with: traits)), r: r, g: g, b: b)
|
||
}
|
||
|
||
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
|
||
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
|
||
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
||
#expect(abs(value.b - CGFloat(b) / 255) < tolerance)
|
||
}
|
||
}
|