Files
web-terminal/ios/App/WebTermTests/ProjectsViewModelTests.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

290 lines
12 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 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)
}
}