App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
329 lines
12 KiB
Swift
329 lines
12 KiB
Swift
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<StageResult>)? = nil,
|
||
commit: (@Sendable (String) async throws -> GitWriteOutcome<CommitResult>)? = nil,
|
||
push: (@Sendable () async throws -> GitWriteOutcome<PushResult>)? = nil,
|
||
fetchRemote: (@Sendable () async throws -> GitWriteOutcome<FetchResult>)? = 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..<maxYields where !condition() {
|
||
await Task.yield()
|
||
}
|
||
}
|
||
}
|
||
|
||
private actor GitPanelSpy {
|
||
private(set) var detailCalls = 0
|
||
private(set) var stageCalls = 0
|
||
private(set) var commitCalls = 0
|
||
private(set) var pushCalls = 0
|
||
|
||
func recordDetail() { detailCalls += 1 }
|
||
func recordStage() { stageCalls += 1 }
|
||
func recordCommit() { commitCalls += 1 }
|
||
func recordPush() { pushCalls += 1 }
|
||
}
|
||
|
||
private actor GitPanelGate {
|
||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||
|
||
func wait() async {
|
||
await withCheckedContinuation { waiters.append($0) }
|
||
}
|
||
|
||
func release() {
|
||
let pending = waiters
|
||
waiters = []
|
||
pending.forEach { $0.resume() }
|
||
}
|
||
}
|