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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View 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 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) == "删除")
}
}