Files
web-terminal/ios/App/WebTermTests/TimelineSheetTests.swift
Yaojia Wang 660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.

Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).

Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
2026-07-05 22:00:31 +02:00

200 lines
7.7 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 Foundation
import SwiftUI
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-24 · Timeline sheet线
///
/// Steps
/// - `/live-sessions/:id/events` 线** web**
/// public/timeline.ts:174-189 render()`slice(0, maxEvents)`
/// reversenewest-first
/// - class web `timelineIcon`public/timeline.ts:88-96
/// web CSS tl-icon-* iOS
/// SessionListScreen class fallback
/// 使 APIClient.decodeList
/// - timeline disabled `[]`src/server.ts:589-591
/// - fetch retry .loaded
/// - digest`forSession` sessionId
/// sheet events source
@MainActor
@Suite("TimelineSheet")
struct TimelineSheetTests {
// MARK: - Fixtures
private static func event(
at: Int, cls: String = "tool", label: String = "ran Bash"
) -> TimelineEvent {
TimelineEvent(at: at, class: cls, toolName: nil, label: label)
}
private struct FetchError: Error {}
/// source sessionId@Sendable actor
private actor SourceRecorder {
private(set) var ids: [UUID] = []
func record(_ id: UUID) { ids = ids + [id] }
}
// MARK: - Phase
@Test("初始 phase = .loadingload 前不渲染内容)")
func initialPhaseIsLoading() {
let vm = TimelineViewModel(fetch: { [] })
#expect(vm.phase == .loading)
}
@Test("load 成功:服务器 oldest-first → 展示 newest-first镜像 web reverse")
func loadReversesToNewestFirst() async {
let oldestFirst = [
Self.event(at: 1, label: "ran Bash"),
Self.event(at: 2, cls: "waiting", label: "waiting for approval"),
Self.event(at: 3, cls: "done", label: "finished"),
]
let vm = TimelineViewModel(fetch: { oldestFirst })
await vm.load()
#expect(vm.phase == .loaded(Array(oldestFirst.reversed())))
}
@Test("超过 maxEvents镜像 web slice(0,50) 再 reverse——先截前 50 条再反转")
func loadCapsThenReversesLikeWeb() async throws {
let sixty = (1...60).map { Self.event(at: $0) }
let vm = TimelineViewModel(fetch: { sixty })
await vm.load()
guard case .loaded(let shown) = vm.phase else {
Issue.record("期望 .loaded实际 \(vm.phase)")
return
}
#expect(shown.count == TimelineViewModel.maxEvents)
#expect(shown.first?.at == 50) // slice(0,50)
#expect(shown.last?.at == 1) //
}
@Test("maxEvents 钉住 web parity 值 50public/timeline.ts:20")
func maxEventsMirrorsWebDefault() {
#expect(TimelineViewModel.maxEvents == 50)
}
@Test("服务器回空数组timeline disabled→ .empty绝不是 .failed")
func emptyArrayIsEmptyStateNotError() async {
let vm = TimelineViewModel(fetch: { [] })
await vm.load()
#expect(vm.phase == .empty)
}
@Test("fetch 抛错 → .failed显式可重试错误态")
func fetchFailureBecomesFailedPhase() async {
let vm = TimelineViewModel(fetch: { throw FetchError() })
await vm.load()
#expect(vm.phase == .failed)
}
@Test("重试:失败后再次 load 成功 → .loaded错误态可恢复")
func retryAfterFailureRecovers() async {
let flag = SourceRecorder() // actor
let events = [Self.event(at: 7)]
let vm = TimelineViewModel(fetch: {
if await flag.ids.isEmpty {
await flag.record(UUID())
throw FetchError()
}
return events
})
await vm.load()
#expect(vm.phase == .failed)
await vm.load() // TimelineSheet
#expect(vm.phase == .loaded(events))
}
// MARK: - digestforSession
@Test("forSession(nil):会话尚未 adopted → 不构造 VM无 sheet 可出)")
func forSessionWithoutIdReturnsNil() {
let vm = TimelineViewModel.forSession(nil, source: { _ in [] })
#expect(vm == nil)
}
@Test("forSession(id)load 把 sessionId 原样传给 events source")
func forSessionPassesSessionIdThrough() async throws {
let expected = UUID()
let recorder = SourceRecorder()
let events = [Self.event(at: 42)]
let vm = try #require(TimelineViewModel.forSession(expected, source: { id in
await recorder.record(id)
return events
}))
await vm.load()
#expect(await recorder.ids == [expected])
#expect(vm.phase == .loaded(events))
}
// MARK: - class /
@Test("图标逐字镜像 web timelineIconpublic/timeline.ts:88-96", arguments: [
("tool", "🔧"),
("waiting", ""),
("done", ""),
("stuck", ""),
("user", "💬"),
])
func glyphMirrorsWebTimelineIcon(cls: String, expected: String) {
#expect(TimelineClassStyle.glyph(for: cls) == expected)
}
@Test("未知 class → fallback 图标(服务器不可信,映射必须全函数)")
func unknownClassFallsBackToNeutralGlyph() {
#expect(TimelineClassStyle.glyph(for: "future-class") == TimelineClassStyle.fallbackGlyph)
#expect(TimelineClassStyle.glyph(for: "") == TimelineClassStyle.fallbackGlyph)
}
@Test("颜色语义映射:全部走冻结 DS.Palette无裸色未知 → textSecondary")
func colorMappingIsSemantic() {
// waiting/stuck tokendone statusWorking
// tool/user timeline token UX finding
#expect(TimelineClassStyle.color(for: "waiting") == DS.Palette.statusWaiting)
#expect(TimelineClassStyle.color(for: "stuck") == DS.Palette.statusStuck)
#expect(TimelineClassStyle.color(for: "done") == DS.Palette.statusWorking)
#expect(TimelineClassStyle.color(for: "tool") == DS.Palette.timelineTool)
#expect(TimelineClassStyle.color(for: "user") == DS.Palette.timelineUser)
#expect(TimelineClassStyle.color(for: "future-class") == DS.Palette.textSecondary)
}
// MARK: - web formatHHMM24h HH:mm
@Test("timeLabel24h HH:mm固定时区下确定性输出")
func timeLabelIs24HourWallClock() throws {
let utc = try #require(TimeZone(identifier: "UTC"))
let onePM = (13 * 3_600 + 5 * 60) * 1_000 // 1970-01-01 13:05 UTC
#expect(TimelineRowFormat.timeLabel(atMs: 0, timeZone: utc) == "00:00")
#expect(TimelineRowFormat.timeLabel(atMs: onePM, timeZone: utc) == "13:05")
}
// MARK: -
@Test("空态与错误态文案存在、非空、且互不相同")
func emptyAndErrorCopyAreDistinct() {
#expect(!TimelineCopy.title.isEmpty)
#expect(!TimelineCopy.emptyTitle.isEmpty)
#expect(!TimelineCopy.loadFailed.isEmpty)
#expect(!TimelineCopy.retry.isEmpty)
#expect(TimelineCopy.emptyTitle != TimelineCopy.loadFailed)
}
}