T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
198 lines
7.4 KiB
Swift
198 lines
7.4 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("颜色语义映射:对齐 SessionListScreen 状态色约定;未知 → secondary")
|
||
func colorMappingIsSemantic() {
|
||
#expect(TimelineClassStyle.color(for: "waiting") == .orange) // 列表 waiting 同色
|
||
#expect(TimelineClassStyle.color(for: "stuck") == .red) // 列表 stuck 同色
|
||
#expect(TimelineClassStyle.color(for: "done") == .green)
|
||
#expect(TimelineClassStyle.color(for: "tool") == .blue)
|
||
#expect(TimelineClassStyle.color(for: "user") == .purple)
|
||
#expect(TimelineClassStyle.color(for: "future-class") == .secondary)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|