Files
web-terminal/ios/App/WebTermTests/DiffViewModelTests.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

243 lines
9.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 returnrenamed
@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 → .emptytruncated 数据位透传)", 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 = truebanner 提示位)")
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("untracked0 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/非法请求 → pathInvalid404 → 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) == "删除")
}
}