Files
web-terminal/ios/App/WebTermTests/TimelineSheetTests.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
2026-07-05 16:15:57 +02:00

198 lines
7.4 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("颜色语义映射:对齐 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 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)
}
}