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,289 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-26 · ProjectsViewModel + favourites/collapse /prefs
///
/// Steps
/// - load`GET /projects` + `GET /prefs` + /
/// - **merge **PUT body web/
/// UiPrefs unknown-key-preserving API PUT blob
/// =
/// - prefs ** PUT**
///
/// - PUT +
/// - web renderGrid searching
/// - "" ProjectOpenRequest{cwd + claude\r}
///
/// HTTP `APIClient` over `FakeHTTPTransport`/
@MainActor
@Suite("ProjectsViewModel")
struct ProjectsViewModelTests {
private nonisolated static let base = "http://192.168.1.5:3000"
private nonisolated static let projectsJSON = """
[{"name":"Billo.Platform.api","path":"/repos/api","isGit":true,"branch":"main",\
"dirty":true,"lastActiveMs":200,"sessions":[]},\
{"name":"Billo.Platform.web","path":"/repos/web","isGit":true,\
"lastActiveMs":100,"sessions":[]}]
"""
/// `webOnly` merge
private nonisolated static let prefsJSON = """
{"favourites":["/repos/web"],"collapsed":{"Billo.Platform":true},\
"webOnly":{"theme":"dark"}}
"""
// MARK: - Harness
private struct Fixture {
let http: FakeHTTPTransport
let host: HostRegistry.Host
let viewModel: ProjectsViewModel
let projectsURL: URL
let prefsURL: URL
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
let http = FakeHTTPTransport()
return Fixture(
http: http,
host: host,
viewModel: ProjectsViewModel(host: host, http: http),
projectsURL: try #require(URL(string: Self.base + "/projects")),
prefsURL: try #require(URL(string: Self.base + "/prefs"))
)
}
private func loadHappyPath(_ fixture: Fixture) async throws {
await fixture.http.queueSuccess(
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
)
await fixture.http.queueSuccess(
url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8)
)
await fixture.viewModel.load()
}
private func recordedPutBodies(_ fixture: Fixture) async throws -> [[String: Any]] {
let requests = await fixture.http.recordedRequests
return try requests
.filter { $0.httpMethod == "PUT" }
.map { request in
let body = try #require(request.httpBody)
let json = try JSONSerialization.jsonObject(with: body)
return try #require(json as? [String: Any])
}
}
// MARK: - load
@Test("load 成功:项目解码 + prefs 应用(收藏排序、折叠状态、分组)")
func loadAppliesProjectsAndPrefs() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
#expect(fixture.viewModel.hasLoadedOnce)
#expect(fixture.viewModel.fetchErrorMessage == nil)
#expect(fixture.viewModel.prefsErrorMessage == nil)
#expect(fixture.viewModel.favourites == ["/repos/web"])
#expect(fixture.viewModel.collapsedGroups == ["Billo.Platform": true])
// namespace web
let groups = fixture.viewModel.groups
#expect(groups.count == 1)
#expect(groups[0].key == "Billo.Platform")
#expect(groups[0].projects.map(\.path) == ["/repos/web", "/repos/api"])
#expect(fixture.viewModel.isCollapsed(groups[0]))
}
@Test("load/projects 传输失败 → 显式错误文案;重试成功后清除且列表可用")
func loadProjectsFailureIsExplicitAndRetryable() async throws {
let fixture = try makeFixture()
await fixture.http.queueFailure(
url: fixture.projectsURL, error: URLError(.notConnectedToInternet)
)
await fixture.http.queueSuccess(
url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8)
)
await fixture.viewModel.load()
#expect(fixture.viewModel.fetchErrorMessage != nil)
#expect(fixture.viewModel.hasLoadedOnce == false)
await fixture.http.queueSuccess(
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
)
await fixture.viewModel.refresh()
#expect(fixture.viewModel.fetchErrorMessage == nil)
#expect(fixture.viewModel.hasLoadedOnce)
#expect(fixture.viewModel.groups.isEmpty == false)
}
@Test("refresh 只重取 /projects不再打 /prefsprefs 留在内存,镜像 web init")
func refreshDoesNotRefetchPrefs() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
await fixture.http.queueSuccess(
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
)
await fixture.viewModel.refresh()
let prefsGets = await fixture.http.recordedRequests.filter {
$0.url == fixture.prefsURL && $0.httpMethod == "GET"
}
#expect(prefsGets.count == 1)
}
// MARK: - prefs clobber 线
@Test("prefs 加载失败:项目照常显示 + 明确文案;随后 toggle 只改本地、绝不 PUT")
func prefsLoadFailureNeverPuts() async throws {
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
)
await fixture.http.queueFailure(
url: fixture.prefsURL, error: URLError(.timedOut)
)
await fixture.viewModel.load()
#expect(fixture.viewModel.prefsErrorMessage == ProjectsCopy.prefsLoadFailed)
#expect(fixture.viewModel.groups.isEmpty == false)
await fixture.viewModel.toggleFavourite(path: "/repos/api")
#expect(fixture.viewModel.favourites == ["/repos/api"]) //
let puts = await fixture.http.recordedRequests.filter { $0.httpMethod == "PUT" }
#expect(puts.isEmpty) // PUT = blob
}
// MARK: - favourites / collapse THE merge
@Test("toggleFavouritePUT body 保留未知键 webOnly + collapsed 原样 + Origin 铁律")
func toggleFavouritePreservesUnknownKeysInPutBody() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
let echo = """
{"favourites":["/repos/web","/repos/api"],"collapsed":{"Billo.Platform":true}}
"""
await fixture.http.queueSuccess(
method: "PUT", url: fixture.prefsURL, body: Data(echo.utf8)
)
await fixture.viewModel.toggleFavourite(path: "/repos/api")
let bodies = try await recordedPutBodies(fixture)
#expect(bodies.count == 1)
let body = try #require(bodies.first)
#expect(body["favourites"] as? [String] == ["/repos/web", "/repos/api"])
#expect(body["collapsed"] as? [String: Bool] == ["Billo.Platform": true])
#expect(body["webOnly"] as? [String: String] == ["theme": "dark"]) //
let put = try #require(
await fixture.http.recordedRequests.first { $0.httpMethod == "PUT" }
)
#expect(
put.value(forHTTPHeaderField: "Origin") == fixture.host.endpoint.originHeader
)
// echo
#expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"])
#expect(fixture.viewModel.prefsSyncErrorMessage == nil)
}
@Test("toggleFavourite 取消收藏:从数组移除,其余顺序不变")
func toggleFavouriteRemoves() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
await fixture.http.queueSuccess(
method: "PUT", url: fixture.prefsURL,
body: Data(#"{"favourites":[],"collapsed":{"Billo.Platform":true}}"#.utf8)
)
await fixture.viewModel.toggleFavourite(path: "/repos/web")
let body = try #require(try await recordedPutBodies(fixture).first)
#expect(body["favourites"] as? [String] == [])
}
@Test("toggleCollapsed展开只删自己的 keyfavourites 与未知键原样保留")
func toggleCollapsedRemovesOnlyItsKey() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
await fixture.http.queueSuccess(
method: "PUT", url: fixture.prefsURL,
body: Data(#"{"favourites":["/repos/web"],"collapsed":{}}"#.utf8)
)
await fixture.viewModel.toggleCollapsed(key: "Billo.Platform") //
let body = try #require(try await recordedPutBodies(fixture).first)
#expect(body["collapsed"] as? [String: Bool] == [:])
#expect(body["favourites"] as? [String] == ["/repos/web"])
#expect(body["webOnly"] as? [String: String] == ["theme": "dark"])
#expect(fixture.viewModel.collapsedGroups == [:])
}
@Test("PUT 失败:本地改动保留 + 同步失败文案(下次 toggle 重试)")
func putFailureKeepsLocalChangeAndSurfacesError() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
await fixture.http.queueFailure(
method: "PUT", url: fixture.prefsURL, error: URLError(.timedOut)
)
await fixture.viewModel.toggleFavourite(path: "/repos/api")
#expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"])
#expect(fixture.viewModel.prefsSyncErrorMessage != nil)
}
// MARK: -
@Test("isCollapsed搜索中强制展开结果绝不藏在折叠 caret 后)")
func searchingForcesSectionsOpen() async throws {
let fixture = try makeFixture()
try await loadHappyPath(fixture)
let group = try #require(fixture.viewModel.groups.first)
#expect(fixture.viewModel.isCollapsed(group))
fixture.viewModel.searchText = "api"
let searched = try #require(fixture.viewModel.groups.first)
#expect(fixture.viewModel.isCollapsed(searched) == false)
}
// MARK: -
@Test("requestOpenClaude绝对路径 → OpenRequest{host,cwd,claude\\r}")
func requestOpenClaudeMintsRequest() async throws {
let fixture = try makeFixture()
fixture.viewModel.requestOpenClaude(cwd: "/repos/api")
let request = try #require(fixture.viewModel.openRequest)
#expect(request.host == fixture.host)
#expect(request.cwd == "/repos/api")
#expect(request.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
#expect(ProjectLaunch.claudeBootstrapInput == "claude\r") // Enter \r \n
}
@Test("requestOpenClaude相对路径拒绝服务器数据不可信→ 无请求 + 文案")
func requestOpenClaudeRejectsRelativePath() async throws {
let fixture = try makeFixture()
fixture.viewModel.requestOpenClaude(cwd: "repos/api")
#expect(fixture.viewModel.openRequest == nil)
#expect(fixture.viewModel.openErrorMessage == ProjectsCopy.openClaudeInvalidPath)
}
}