import APIClient import Foundation import Testing @testable import WebTerm /// T-iOS-32 · worktree 生命周期(create / prune / remove)的 App 层归约。 /// /// RED 清单见 `docs/plans/ios-completion.md` §4(A 1–9、B 10–17、C 18–21、D 22–27)。 /// APIClient 侧的 builder/请求体/状态码映射已由 B1 单测覆盖(125 tests),本套只测 /// VM:校验先行、破坏性操作必须显式确认、服务器安全文案原样上浮。 @MainActor @Suite("WorktreeViewModel") struct WorktreeViewModelTests { // MARK: - A. 分支名校验(逐条镜像 public/projects.ts:982 validateBranchNameClient) @Test("空分支名 → 报错") func emptyBranchRejected() { #expect(WorktreeBranchRule.validate("") != nil) } @Test("长度:250 通过、251 报错") func lengthBoundary() { let ok = String(repeating: "a", count: 250) #expect(WorktreeBranchRule.validate(ok) == nil) #expect(WorktreeBranchRule.validate(ok + "a") != nil) } @Test( "非法形态一律报错(空格/控制字符/前导-/../.lock/特殊字符/@{/斜杠误用)", arguments: [ "feat x", "feat\tx", "feat\u{0}x", "feat\u{7f}x", "-feat", "feat..x", "feat.lock", "feat~x", "feat^x", "feat:x", "feat?x", "feat*x", "feat[x", "feat\\x", "feat@{1}", "/feat", "feat/", "feat//x", ] ) func invalidShapesRejected(branch: String) { #expect(WorktreeBranchRule.validate(branch) != nil, "\(branch) 应被拒绝") } @Test("合法分支名通过", arguments: ["feat/x", "v1.2-fix", "worktree-ios_32"]) func validNamesAccepted(branch: String) { #expect(WorktreeBranchRule.validate(branch) == nil) } // MARK: - B. 创建 @Test("非法分支名 → 一次请求都不发(校验在边界,先于网络)") func invalidBranchSkipsNetwork() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel(spy: spy) vm.branchText = "-bad" await vm.create() #expect(await spy.createCalls == 0) #expect(vm.createPhase != .creating) if case .failed(let message) = vm.createPhase { #expect(!message.isEmpty) } else { Issue.record("应为 .failed,实际 \(vm.createPhase)") } } @Test("成功 → 采用服务器返回的 canonical path/branch(不回显本地输入)") func createAdoptsServerTruth() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel( spy: spy, create: { _, _ in .ok(CreateWorktreeResult(path: "/repos/wt/feat-x", branch: "feat/x")) } ) vm.branchText = "feat/x" await vm.create() #expect(vm.createPhase == .created(path: "/repos/wt/feat-x", branch: "feat/x")) } @Test("base 留空 → 请求体 base 为 nil(服务器读作「从 HEAD 切」,绝不送空串)") func blankBaseBecomesNil() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel(spy: spy) vm.branchText = "feat/x" vm.baseText = " " await vm.create() #expect(await spy.lastBase == nil) } @Test("填了 base → 去空白后原样送出") func trimmedBaseIsSent() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel(spy: spy) vm.branchText = "feat/x" vm.baseText = " develop " await vm.create() #expect(await spy.lastBase == "develop") } @Test("403(kill-switch 或 Origin)→ 原样显示服务器安全文案") func rejectionSurfacesServerMessage() async { let vm = Self.makeViewModel( spy: WorktreeCallSpy(), create: { _, _ in .rejected(status: 403, message: "Worktrees are disabled.") } ) vm.branchText = "feat/x" await vm.create() #expect(vm.createPhase == .failed("Worktrees are disabled.")) } @Test("500 且服务器没给 message → 非空兜底中文文案") func rejectionWithoutMessageFallsBack() async { let vm = Self.makeViewModel( spy: WorktreeCallSpy(), create: { _, _ in .rejected(status: 500, message: nil) } ) vm.branchText = "feat/x" await vm.create() if case .failed(let message) = vm.createPhase { #expect(!message.isEmpty) #expect(message.contains("500")) } else { Issue.record("应为 .failed,实际 \(vm.createPhase)") } } @Test("429 → 限流专用文案,且不自动重试(只发一次)") func rateLimitedIsNotRetried() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel(spy: spy, create: { _, _ in .rateLimited }) vm.branchText = "feat/x" await vm.create() #expect(await spy.createCalls == 1) #expect(vm.createPhase == .failed(GitWriteFeedback.rateLimited)) } @Test("抛出 .unauthorized → 引导补令牌的话术(与 500 区分)") func unauthorizedUsesTokenCopy() async { let vm = Self.makeViewModel( spy: WorktreeCallSpy(), create: { _, _ in throw APIClientError.unauthorized } ) vm.branchText = "feat/x" await vm.create() #expect(vm.createPhase == .failed(APIClientError.unauthorized.message)) } @Test("创建进行中重复提交 → 只发一次请求") func duplicateCreateSendsOneRequest() async { let gate = WorktreeGate() let spy = WorktreeCallSpy() let vm = Self.makeViewModel( spy: spy, create: { _, _ in await gate.wait() return .ok(CreateWorktreeResult(path: "/repos/wt", branch: "feat/x")) } ) vm.branchText = "feat/x" let first = Task { await vm.create() } await Self.spin(until: { vm.createPhase == .creating }) await vm.create() // 忙态:应被吞掉 #expect(await spy.createCalls == 1) await gate.release() await first.value } // MARK: - C. prune(破坏性 → 调用方确认后才进 VM) @Test("pruned 为空 → 幂等文案,非错误态") func pruneEmptyIsIdempotent() async { let vm = Self.makeViewModel(spy: WorktreeCallSpy(), prune: { .ok(PruneWorktreesResult(pruned: [])) }) await vm.prune() #expect(vm.prunePhase == .done(WorktreeCopy.pruneNothing)) } @Test("pruned 有 2 条 → 文案带数量") func pruneReportsCount() async { let vm = Self.makeViewModel( spy: WorktreeCallSpy(), prune: { .ok(PruneWorktreesResult(pruned: ["worktrees/a", "worktrees/b"])) } ) await vm.prune() #expect(vm.prunePhase == .done(WorktreeCopy.pruneDone(2))) } @Test("prune 404 → 显示服务器文案") func pruneRejectionSurfacesMessage() async { let vm = Self.makeViewModel( spy: WorktreeCallSpy(), prune: { .rejected(status: 404, message: "Not a git repository.") } ) await vm.prune() #expect(vm.prunePhase == .failed("Not a git repository.")) } // MARK: - D. remove(两级确认) @Test("主 worktree / 已锁定 worktree → 不提供删除入口") func mainAndLockedAreNotRemovable() { let main = WorktreeInfo( path: "/repos/r", branch: "develop", head: "a", isMain: true, isCurrent: true, locked: nil, prunable: nil ) let locked = WorktreeInfo( path: "/repos/wt", branch: "feat/x", head: "b", isMain: false, isCurrent: false, locked: true, prunable: nil ) let plain = WorktreeInfo( path: "/repos/wt2", branch: "feat/y", head: "c", isMain: false, isCurrent: false, locked: false, prunable: true ) #expect(!WorktreeViewModel.canRemove(main)) #expect(!WorktreeViewModel.canRemove(locked)) #expect(WorktreeViewModel.canRemove(plain)) } @Test("第一次确认 → force: false") func firstRemoveIsNotForced() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel(spy: spy, remove: { _, _ in .ok(RemoveWorktreeResult(path: "/repos/wt")) }) await vm.remove(worktreePath: "/repos/wt") #expect(await spy.removeForceFlags == [false]) #expect(vm.removePhase == .removed(path: "/repos/wt")) } @Test("409(脏工作树)→ 进入二次确认,绝不自动 force") func dirtyTreeAsksBeforeForcing() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel( spy: spy, remove: { _, force in force ? .ok(RemoveWorktreeResult(path: "/repos/wt")) : .rejected(status: 409, message: "Worktree has uncommitted changes — force required.") } ) await vm.remove(worktreePath: "/repos/wt") #expect(await spy.removeForceFlags == [false]) #expect(vm.removePhase == .forceConfirming( path: "/repos/wt", message: "Worktree has uncommitted changes — force required." )) } @Test("二次确认取消 → 不发第二次请求") func cancellingForceSendsNothing() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel( spy: spy, remove: { _, _ in .rejected(status: 409, message: "dirty") } ) await vm.remove(worktreePath: "/repos/wt") vm.cancelForceRemove() #expect(await spy.removeForceFlags == [false]) #expect(vm.removePhase == .idle) } @Test("二次确认通过 → 第二次请求 force: true") func confirmingForceRetriesWithForce() async { let spy = WorktreeCallSpy() let vm = Self.makeViewModel( spy: spy, remove: { _, force in force ? .ok(RemoveWorktreeResult(path: "/repos/wt")) : .rejected(status: 409, message: "dirty") } ) await vm.remove(worktreePath: "/repos/wt") await vm.confirmForceRemove() #expect(await spy.removeForceFlags == [false, true]) #expect(vm.removePhase == .removed(path: "/repos/wt")) } @Test("400(非 409)→ 直接失败,不进 force 分支") func nonConflictRejectionNeverOffersForce() async { let vm = Self.makeViewModel( spy: WorktreeCallSpy(), remove: { _, _ in .rejected(status: 400, message: "Cannot remove the main worktree.") } ) await vm.remove(worktreePath: "/repos/r") #expect(vm.removePhase == .failed("Cannot remove the main worktree.")) } // MARK: - Fixtures private static func makeViewModel( spy: WorktreeCallSpy, create: (@Sendable (String, String?) async throws -> GitWriteOutcome)? = nil, prune: (@Sendable () async throws -> GitWriteOutcome)? = nil, remove: (@Sendable (String, Bool) async throws -> GitWriteOutcome)? = nil ) -> WorktreeViewModel { WorktreeViewModel(dependencies: WorktreeViewModel.Dependencies( create: { branch, base in await spy.recordCreate(base: base) if let create { return try await create(branch, base) } return .ok(CreateWorktreeResult(path: "/repos/wt", branch: branch)) }, prune: { await spy.recordPrune() if let prune { return try await prune() } return .ok(PruneWorktreesResult(pruned: [])) }, remove: { path, force in await spy.recordRemove(force: force) if let remove { return try await remove(path, force) } return .ok(RemoveWorktreeResult(path: path)) } )) } /// 有界自旋:等一个 MainActor 状态成立(永不无限挂起)。 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() } } }