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) } }