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 } }