Files
web-terminal/ios/App/WebTermTests/DesignSystemTests.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

176 lines
7.2 KiB
Swift
Raw 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
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)
}
}