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
This commit is contained in:
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
@@ -0,0 +1,242 @@
|
||||
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) == "删除")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user