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) } }