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,118 @@
import APIClient
import Foundation
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-26 · ProjectDetailViewModel phase + 400/404/500
/// DiffViewModel
///
/// fetch `forHost` `APIClient.projectDetail(path:)`
/// builder// APIClient VM
@MainActor
@Suite("ProjectDetailViewModel")
struct ProjectDetailViewModelTests {
private nonisolated static func detail(
sessions: [ProjectSessionRef] = [],
worktrees: [WorktreeInfo] = [],
hasClaudeMd: Bool = false
) -> ProjectDetail {
ProjectDetail(
name: "web-terminal", path: "/repos/web-terminal", isGit: true,
branch: "main", dirty: true, worktrees: worktrees,
sessions: sessions, hasClaudeMd: hasClaudeMd, claudeMd: nil
)
}
@Test("初始 .loadingload 成功 → .loadedsessions/worktrees/hasClaudeMd 透传)")
func loadSuccess() async throws {
let payload = Self.detail(
sessions: [ProjectSessionRef(
id: UUID(), title: "web-terminal", status: .working,
clientCount: 2, createdAt: 1, exited: false
)],
worktrees: [WorktreeInfo(
path: "/repos/wt", branch: "feat/x", head: "abc",
isMain: false, isCurrent: true, locked: nil, prunable: nil
)],
hasClaudeMd: true
)
let vm = ProjectDetailViewModel(path: payload.path, fetch: { payload })
#expect(vm.phase == .loading)
await vm.load()
#expect(vm.phase == .loaded(payload))
}
@Test(
"错误映射400→pathInvalid、404→notFound、500/解码/传输→unavailable",
arguments: [
(APIClientError.projectPathInvalid, ProjectDetailViewModel.Failure.pathInvalid),
(APIClientError.projectNotFound, .notFound),
(APIClientError.projectDetailUnavailable, .unavailable),
(APIClientError.invalidResponseBody, .unavailable),
]
)
func errorMapping(
error: APIClientError, expected: ProjectDetailViewModel.Failure
) async {
let vm = ProjectDetailViewModel(path: "/p", fetch: { throw error })
await vm.load()
#expect(vm.phase == .failed(expected))
}
@Test("传输层任意错误 → .unavailable可重试兜底绝不 crash")
func transportErrorFallsBackToUnavailable() async {
let vm = ProjectDetailViewModel(
path: "/p", fetch: { throw URLError(.notConnectedToInternet) }
)
await vm.load()
#expect(vm.phase == .failed(.unavailable))
}
@Test("重试路径failed 后再次 load 成功 → loaded")
func retryAfterFailure() async {
let flag = FailOnceFlag()
let payload = Self.detail()
let vm = ProjectDetailViewModel(path: payload.path, fetch: {
if await flag.consumeShouldFail() { throw APIClientError.projectDetailUnavailable }
return payload
})
await vm.load()
#expect(vm.phase == .failed(.unavailable))
await vm.load()
#expect(vm.phase == .loaded(payload))
}
@Test("failureCopy三种失败各有非空、互不相同的中文文案")
func failureCopyIsDistinct() {
let copies = [
ProjectDetailScreen.failureCopy(.pathInvalid),
ProjectDetailScreen.failureCopy(.notFound),
ProjectDetailScreen.failureCopy(.unavailable),
]
for copy in copies {
#expect(!copy.title.isEmpty)
#expect(!copy.detail.isEmpty)
}
#expect(Set(copies.map(\.title)).count == copies.count)
}
}
/// @Sendable fetch actor
private actor FailOnceFlag {
private var shouldFail = true
func consumeShouldFail() -> Bool {
defer { shouldFail = false }
return shouldFail
}
}