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/.
200 lines
7.7 KiB
Swift
200 lines
7.7 KiB
Swift
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)` 先截断
|
||
/// 再 reverse,newest-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 = .loading(load 前不渲染内容)")
|
||
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 值 50(public/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: - digest「展开」入口的装配缝(forSession 工厂)
|
||
|
||
@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 timelineIcon(public/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 复用状态 token(与列表同义);done 复用 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 formatHHMM:24h 墙钟 HH:mm)
|
||
|
||
@Test("timeLabel:24h 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)
|
||
}
|
||
}
|