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
290 lines
12 KiB
Swift
290 lines
12 KiB
Swift
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,不再打 /prefs(prefs 留在内存,镜像 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("toggleFavourite:PUT 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:展开只删自己的 key;favourites 与未知键原样保留")
|
||
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)
|
||
}
|
||
}
|