import APIClient import Foundation import Testing @testable import WebTerm /// C2 · git 面板 VM(RED 清单 47–51 + 分区独立降级)。 /// /// 依赖全部以闭包注入(与 ProjectDetailViewModel/DiffViewModel 同一纪律)—— /// 路由构建/状态码映射已在 APIClient 包内测过,本套只测 VM 归约: /// 服务器安全文案原样上浮、写操作忙态去重、每个分区独立失败不拖垮整屏。 @MainActor @Suite("GitPanelViewModel") struct GitPanelViewModelTests { private nonisolated static let path = "/repos/web-terminal" private nonisolated static let nowMs: Double = 1_785_412_800_000 private nonisolated static func detail( sync: SyncState? = SyncState( upstream: "origin/develop", ahead: 2, behind: 0, lastFetchMs: 1_785_412_500_000 ), dirtyCount: Int? = 3 ) -> ProjectDetail { ProjectDetail( name: "web-terminal", path: path, isGit: true, branch: "develop", dirty: true, worktrees: [], sessions: [], hasClaudeMd: false, claudeMd: nil, dirtyCount: dirtyCount, sync: sync ) } // MARK: - 加载与分区降级 @Test("load 成功 → 同步带/分支/日志/改动列表就位") func loadPopulatesEverySection() async { let vm = Self.makeViewModel( log: { GitLogResult(commits: [ CommitLogEntry(hash: "abc1234", at: 1_785_412_000_000, subject: "feat: x", unpushed: true), ], truncated: false, upstream: "origin/develop") }, changes: { staged in staged ? [] : [Self.changedFile("src/a.ts")] } ) await vm.load() #expect(vm.isLoaded) #expect(vm.branch == "develop") #expect(vm.band?.head == .tracking(upstream: "origin/develop", ahead: 2, behind: 0)) #expect(vm.band?.dirty == .changed(3)) #expect(vm.log?.commits.count == 1) #expect(vm.unstaged.map(\.displayPath) == ["src/a.ts"]) #expect(vm.staged.isEmpty) } @Test("日志失败不拖垮面板:其余分区照常,日志区显示自己的错误") func logFailureIsContained() async { let vm = Self.makeViewModel(log: { throw APIClientError.gitDataUnavailable }) await vm.load() #expect(vm.isLoaded) #expect(vm.band != nil) #expect(vm.logErrorMessage == APIClientError.gitDataUnavailable.message) } @Test("详情失败 → 状态区错误 + 无同步带(绝不编造 ↑↓)") func detailFailureLeavesNoBand() async { let vm = Self.makeViewModel(detail: { throw APIClientError.projectNotFound }) await vm.load() #expect(vm.band == nil) #expect(vm.stateErrorMessage == APIClientError.projectNotFound.message) } @Test("PR 单独加载(gh 是一次外网调用,不阻塞面板首屏)") func prLoadsSeparately() async { let vm = Self.makeViewModel(pr: { PrStatus(availability: .ok, number: 7, title: "t") }) await vm.load() #expect(vm.pr == nil) await vm.loadPullRequest() #expect(vm.pr?.number == 7) } @Test("gh 未安装/未登录是 200 + 非 ok availability,不是错误") func degradedGhIsNotAnError() async { let vm = Self.makeViewModel(pr: { PrStatus(availability: .notInstalled) }) await vm.loadPullRequest() #expect(vm.pr?.availability == .notInstalled) #expect(vm.errorMessage == nil) } // MARK: - 写操作文案(47–49) @Test("stage 被拒 → 服务器安全文案原样显示") func stageRejectionSurfacesServerMessage() async { let vm = Self.makeViewModel( stage: { _, _ in .rejected(status: 403, message: "Git operations are disabled.") } ) await vm.setStaged(Self.changedFile("src/a.ts"), staged: true) #expect(vm.errorMessage == "Git operations are disabled.") } @Test("commit 成功 → 清空输入框 + 短 sha 提示") func commitSuccessClearsDraft() async { let vm = Self.makeViewModel(commit: { _ in .ok(CommitResult(commit: "abc1234")) }) vm.commitMessage = "fix: 修一下" await vm.commit() #expect(vm.commitMessage.isEmpty) #expect(vm.noticeMessage?.contains("abc1234") == true) #expect(vm.errorMessage == nil) } @Test("commit 409(无暂存)→ 保留输入框内容,显示服务器文案") func commitConflictKeepsDraft() async { let vm = Self.makeViewModel( commit: { _ in .rejected(status: 409, message: "Nothing staged to commit.") } ) vm.commitMessage = "fix: 修一下" await vm.commit() #expect(vm.commitMessage == "fix: 修一下") #expect(vm.errorMessage == "Nothing staged to commit.") } @Test("空提交信息 → 一次请求都不发") func blankCommitMessageSkipsNetwork() async { let spy = GitPanelSpy() let vm = Self.makeViewModel(spy: spy) vm.commitMessage = " " await vm.commit() #expect(await spy.commitCalls == 0) #expect(vm.errorMessage == GitPanelCopy.commitMessageRequired) } @Test("push 401 是主机 git 凭据问题 → 用服务器文案,不与访问令牌 401 混淆") func pushAuthIsNotTheAccessTokenGate() async { let vm = Self.makeViewModel( push: { .rejected(status: 401, message: "Push authentication required on the host.") } ) await vm.push() #expect(vm.errorMessage == "Push authentication required on the host.") #expect(vm.errorMessage != APIClientError.unauthorized.message) } @Test("429 → 限流文案,且不自动重试") func rateLimitedIsNotRetried() async { let spy = GitPanelSpy() let vm = Self.makeViewModel(spy: spy, push: { .rateLimited }) await vm.push() #expect(await spy.pushCalls == 1) #expect(vm.errorMessage == GitWriteFeedback.rateLimited) } @Test("fetch 成功 → 重新读取详情(lastFetchMs 由服务器给,客户端绝不自己往前拨)") func fetchReloadsStateFromServer() async { let spy = GitPanelSpy() let vm = Self.makeViewModel(spy: spy, fetchRemote: { .ok(FetchResult(remote: "origin", lastFetchMs: Self.nowMs)) }) await vm.load() let loadsAfterFirstLoad = await spy.detailCalls await vm.fetchRemote() #expect(await spy.detailCalls == loadsAfterFirstLoad + 1) } // MARK: - 忙态去重(50) @Test("写操作进行中重复点击 → 只发一次请求") func busyDeduplicatesWrites() async { let gate = GitPanelGate() let spy = GitPanelSpy() let vm = Self.makeViewModel( spy: spy, push: { await gate.wait() return .ok(PushResult(branch: "develop", remote: "origin")) } ) let first = Task { await vm.push() } await Self.spin(until: { vm.busy == .push }) await vm.push() #expect(await spy.pushCalls == 1) await gate.release() await first.value #expect(vm.busy == nil) } // MARK: - 重命名文件的暂存路径(51) @Test("重命名 → 同时送 oldPath 与 newPath(否则删除侧不会被暂存)") func renameStagesBothPaths() { let file = DiffFile( oldPath: "src/old.ts", newPath: "src/new.ts", status: .renamed, added: 1, removed: 1, binary: false, hunks: [] ) let changed = GitPanelViewModel.ChangedFile.make(from: file) #expect(changed.stagePaths == ["src/old.ts", "src/new.ts"]) #expect(changed.displayPath.contains("src/new.ts")) } @Test("普通修改 → 只送 newPath") func modifiedStagesOnePath() { let file = DiffFile( oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified, added: 2, removed: 0, binary: false, hunks: [] ) let changed = GitPanelViewModel.ChangedFile.make(from: file) #expect(changed.stagePaths == ["src/a.ts"]) } // MARK: - Fixtures private nonisolated static func changedFile(_ path: String) -> GitPanelViewModel.ChangedFile { GitPanelViewModel.ChangedFile( displayPath: path, stagePaths: [path], status: .modified, added: 1, removed: 0 ) } private static func makeViewModel( spy: GitPanelSpy = GitPanelSpy(), detail: (@Sendable () async throws -> ProjectDetail)? = nil, log: (@Sendable () async throws -> GitLogResult)? = nil, pr: (@Sendable () async throws -> PrStatus)? = nil, changes: (@Sendable (Bool) async throws -> [GitPanelViewModel.ChangedFile])? = nil, stage: (@Sendable ([String], Bool) async throws -> GitWriteOutcome)? = nil, commit: (@Sendable (String) async throws -> GitWriteOutcome)? = nil, push: (@Sendable () async throws -> GitWriteOutcome)? = nil, fetchRemote: (@Sendable () async throws -> GitWriteOutcome)? = nil ) -> GitPanelViewModel { GitPanelViewModel( path: path, dependencies: GitPanelViewModel.Dependencies( detail: { await spy.recordDetail() if let detail { return try await detail() } return Self.detail() }, log: { if let log { return try await log() } return GitLogResult(commits: [], truncated: false, upstream: "origin/develop") }, pr: { if let pr { return try await pr() } return PrStatus(availability: .noPr) }, changes: { staged in if let changes { return try await changes(staged) } return [] }, stage: { files, isStaging in await spy.recordStage() if let stage { return try await stage(files, isStaging) } return .ok(StageResult(staged: isStaging, count: files.count)) }, commit: { message in await spy.recordCommit() if let commit { return try await commit(message) } return .ok(CommitResult(commit: "abc1234")) }, push: { await spy.recordPush() if let push { return try await push() } return .ok(PushResult(branch: "develop", remote: "origin")) }, fetchRemote: { if let fetchRemote { return try await fetchRemote() } return .ok(FetchResult(remote: "origin", lastFetchMs: nowMs)) }, nowMs: { nowMs } ) ) } private static func spin( until condition: @MainActor () -> Bool, maxYields: Int = 1_000 ) async { for _ in 0..] = [] func wait() async { await withCheckedContinuation { waiters.append($0) } } func release() { let pending = waiters waiters = [] pending.forEach { $0.resume() } } }