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
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,197 @@
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)
}
}