import Foundation import Testing import WireProtocol @testable import WebTerm /// T-iOS-27 · Diff 查看器 —— `DiffViewModel`(phase 状态机 + 行平铺)。 /// /// 覆盖面(Steps 测试先行): /// - `DiffResult{files,staged,truncated}` → 呈现:文件头 → hunk 头 → 行, /// 平铺为惰性列表的行模型(巨 diff 不合成单个 Text 块); /// - truncated → banner 数据位(空态与非空态都要携带); /// - staged/unstaged 切换 → 以新 staged 重新 fetch;同值不重复请求; /// - path 非法(400/404)→ 显式友好错误 phase,其余错误 → 可重试的 unavailable; /// - binary 文件短路(镜像 web renderDiffFile 的 early return)、renamed 路径标签。 @MainActor @Suite("DiffViewModel") struct DiffViewModelTests { // MARK: - Fixtures private nonisolated static func line(_ kind: DiffLineKind, _ text: String) -> DiffLine { DiffLine(kind: kind, text: text) } private nonisolated static func modifiedFile() -> DiffFile { DiffFile( oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified, added: 1, removed: 1, binary: false, hunks: [DiffHunk(header: "@@ -1,2 +1,2 @@", lines: [ line(.removed, "old"), line(.added, "new"), ])] ) } private nonisolated static func result( files: [DiffFile], staged: Bool = false, truncated: Bool = false ) -> DiffResult { DiffResult(files: files, staged: staged, truncated: truncated) } /// 记录每次 fetch 收到的 staged 参数(@Sendable 闭包内可变状态 → actor)。 private actor FetchRecorder { private(set) var stagedArgs: [Bool] = [] func record(_ staged: Bool) { stagedArgs = stagedArgs + [staged] } } // MARK: - Phase 状态机 @Test("初始:phase = .loading、staged = false(默认工作区视图)") func initialState() { let vm = DiffViewModel(fetch: { _ in Self.result(files: []) }) #expect(vm.phase == .loading) #expect(vm.staged == false) } @Test("load 成功(非空)→ .loaded,行序:文件头 → hunk 头 → 行,id 稳定递增") func loadFlattensRowsInOrder() async throws { let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()]) }) await vm.load() guard case .loaded(let presentation) = vm.phase else { Issue.record("期望 .loaded,实际 \(vm.phase)") return } #expect(presentation.truncated == false) #expect(presentation.rows.map(\.id) == [0, 1, 2, 3]) // Identifiable:惰性列表身份 #expect(presentation.rows.map(\.kind) == [ .fileHeader(DiffFileHeader( pathLabel: "src/a.ts", added: 1, removed: 1, status: .modified )), .hunkHeader("@@ -1,2 +1,2 @@"), .line(kind: .removed, text: "old"), .line(kind: .added, text: "new"), ]) } @Test("空 files → .empty(truncated 数据位透传)", arguments: [false, true]) func emptyFilesBecomeEmptyPhase(truncated: Bool) async { let vm = DiffViewModel(fetch: { _ in Self.result(files: [], truncated: truncated) }) await vm.load() #expect(vm.phase == .empty(truncated: truncated)) } @Test("truncated + 非空 → .loaded 且 presentation.truncated = true(banner 提示位)") func truncatedFlagSurvivesIntoPresentation() async throws { let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()], truncated: true) }) await vm.load() guard case .loaded(let presentation) = vm.phase else { Issue.record("期望 .loaded,实际 \(vm.phase)") return } #expect(presentation.truncated == true) } // MARK: - 行平铺规则(镜像 web renderDiffFile) @Test("binary 文件:文件头 + 二进制占位,hunk 全部短路(web early return 同款)") func binaryFileShortCircuitsHunks() { let binary = DiffFile( oldPath: "logo.png", newPath: "logo.png", status: .binary, added: 0, removed: 0, binary: true, hunks: [DiffHunk(header: "@@ junk @@", lines: [Self.line(.added, "x")])] ) let rows = DiffViewModel.makeRows(files: [binary]) #expect(rows.map(\.kind) == [ .fileHeader(DiffFileHeader( pathLabel: "logo.png", added: 0, removed: 0, status: .binary )), .binaryNotice, ]) } @Test("renamed 且新旧路径不同 → 路径标签 “old → new”(web 同款箭头)") func renamedFileShowsArrowLabel() { let renamed = DiffFile( oldPath: "old/name.ts", newPath: "new/name.ts", status: .renamed, added: 0, removed: 0, binary: false, hunks: [] ) let rows = DiffViewModel.makeRows(files: [renamed]) guard case .fileHeader(let header)? = rows.first?.kind else { Issue.record("期望 fileHeader,实际 \(String(describing: rows.first))") return } #expect(header.pathLabel == "old/name.ts → new/name.ts") } @Test("untracked(0 hunk、非 binary)→ 仅文件头行") func untrackedFileIsHeaderOnly() { let untracked = DiffFile( oldPath: "notes.md", newPath: "notes.md", status: .untracked, added: 0, removed: 0, binary: false, hunks: [] ) let rows = DiffViewModel.makeRows(files: [untracked]) #expect(rows.count == 1) #expect(rows.first?.kind == .fileHeader(DiffFileHeader( pathLabel: "notes.md", added: 0, removed: 0, status: .untracked ))) } // MARK: - 错误映射(400/404 → 友好错误,任务 Steps) @Test("DiffFetchError → Failure 映射:400/非法请求 → pathInvalid,404 → notFound,其余 → unavailable") func fetchErrorsMapToFailures() async { struct RandomError: Error {} let cases: [(any Error, DiffViewModel.Failure)] = [ (DiffFetchError.pathInvalid, .pathInvalid), (DiffFetchError.invalidRequest, .pathInvalid), (DiffFetchError.projectNotFound, .notFound), (DiffFetchError.invalidResponse, .unavailable), (DiffFetchError.unexpectedStatus(500), .unavailable), (RandomError(), .unavailable), ] for (thrown, expected) in cases { let vm = DiffViewModel(fetch: { _ in throw thrown }) await vm.load() #expect(vm.phase == .failed(expected)) } } @Test("重试:失败后再次 load 成功 → .loaded(错误态可恢复)") func retryAfterFailureRecovers() async { struct Boom: Error {} let recorder = FetchRecorder() let vm = DiffViewModel(fetch: { staged in await recorder.record(staged) if await recorder.stagedArgs.count == 1 { throw Boom() } return Self.result(files: [Self.modifiedFile()]) }) await vm.load() #expect(vm.phase == .failed(.unavailable)) await vm.load() // DiffScreen「重试」按钮走的就是这条路径 guard case .loaded = vm.phase else { Issue.record("期望 .loaded,实际 \(vm.phase)") return } } // MARK: - staged/unstaged 切换(re-fetch 语义) @Test("setStaged(true) → 以 staged=true 重新 fetch;同值再设 → 不重复请求") func setStagedRefetchesOnlyOnChange() async { let recorder = FetchRecorder() let vm = DiffViewModel(fetch: { staged in await recorder.record(staged) return Self.result(files: [], staged: staged) }) await vm.load() await vm.setStaged(true) await vm.setStaged(true) // 同值:必须是 no-op await vm.setStaged(false) #expect(vm.staged == false) #expect(await recorder.stagedArgs == [false, true, false]) } // MARK: - 用户可见文案(中文具名常量;空态 ≠ 错误态) @Test("文案常量非空且空态/错误态互不相同") func copyConstantsAreDistinct() { #expect(!DiffCopy.title.isEmpty) #expect(!DiffCopy.truncatedBanner.isEmpty) #expect(!DiffCopy.emptyTitle.isEmpty) #expect(!DiffCopy.binaryFile.isEmpty) #expect(!DiffCopy.retry.isEmpty) #expect(DiffCopy.emptyTitle != DiffCopy.failedUnavailable) #expect(DiffCopy.failedPathInvalid != DiffCopy.failedNotFound) } @Test("状态标签全函数映射(含全部六种 FileStatus,中文)") func statusLabelsAreTotalAndChinese() { let all: [DiffFileStatus] = [ .modified, .added, .deleted, .renamed, .binary, .untracked, ] for status in all { #expect(!DiffStatusStyle.label(for: status).isEmpty) } #expect(DiffStatusStyle.label(for: .added) == "新增") #expect(DiffStatusStyle.label(for: .deleted) == "删除") } }