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
119 lines
4.0 KiB
Swift
119 lines
4.0 KiB
Swift
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("初始 .loading;load 成功 → .loaded(sessions/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
|
||
}
|
||
}
|