import Foundation import Testing import TestSupport import WireProtocol import APIClient /// T-iOS-8 · 响应模型解码。服务器是**不可信输入源**(plan §4): /// - `LiveSessionInfo` 镜像 src/types.ts:246-256,`telemetry` 可缺失/为 null; /// - 未知 `status` → `.unknown`(新状态值不得让运行中的会话从列表里消失); /// - 畸形条目丢弃、整体不 crash;非数组 body → 显式错误(探针的"端口对吗?"信号); /// - `TimelineEvent` 经 WireProtocol 的 `decodeList` 帮助器,未知 `class` 该条丢弃。 struct ModelDecodingTests { private static let base = "http://192.168.1.5:3000" private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" private static let fullSessionJSON = """ {"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":2,\ "status":"working","exited":false,"cwd":"/Users/dev/proj","cols":120,"rows":40,\ "telemetry":{"contextUsedPct":42.5,"costUsd":1.25,"linesAdded":10,"linesRemoved":2,\ "model":"Opus","effort":"high",\ "pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\ "rate":{"fiveHourPct":12.5,"sevenDayPct":40.0},"at":1720000005000}} """ private static let noTelemetrySessionJSON = """ {"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":0,\ "status":"idle","exited":false,"cwd":null,"cols":80,"rows":24} """ private func makeClient(_ http: FakeHTTPTransport) throws -> APIClient { let url = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: url)) return APIClient(endpoint: endpoint, http: http) } private func routeURL(_ path: String) throws -> URL { try #require(URL(string: Self.base + path)) } private func fetchSessions(body: String) async throws -> [LiveSessionInfo] { let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data(body.utf8)) return try await client.liveSessions() } // MARK: - LiveSessionInfo @Test("LiveSessionInfo 全字段样本解码(src/types.ts:246-256 形状,含完整 telemetry)") func liveSessionInfoDecodesFullSample() async throws { // Act let sessions = try await fetchSessions(body: "[\(Self.fullSessionJSON)]") // Assert #expect(sessions.count == 1) let session = try #require(sessions.first) #expect(session.id == UUID(uuidString: Self.sessionIdString)) #expect(session.createdAt == 1_720_000_000_000) #expect(session.clientCount == 2) #expect(session.status == .working) #expect(session.exited == false) #expect(session.cwd == "/Users/dev/proj") #expect(session.cols == 120) #expect(session.rows == 40) let telemetry = try #require(session.telemetry) #expect(telemetry.contextUsedPct == 42.5) #expect(telemetry.costUsd == 1.25) #expect(telemetry.linesAdded == 10) #expect(telemetry.linesRemoved == 2) #expect(telemetry.model == "Opus") #expect(telemetry.effort == "high") #expect(telemetry.pr?.number == 7) #expect(telemetry.rate?.fiveHourPct == 12.5) #expect(telemetry.at == 1_720_000_005_000) // 与 memberwise 构造的期望值整体相等(Equatable 全字段往返) let expected = LiveSessionInfo( id: try #require(UUID(uuidString: Self.sessionIdString)), createdAt: 1_720_000_000_000, clientCount: 2, status: .working, exited: false, cwd: "/Users/dev/proj", cols: 120, rows: 40, telemetry: StatusTelemetry( contextUsedPct: 42.5, costUsd: 1.25, linesAdded: 10, linesRemoved: 2, model: "Opus", effort: "high", pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"), rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 40.0), at: 1_720_000_005_000 ) ) #expect(session == expected) } @Test("LiveSessionInfo telemetry 缺失/cwd 为 null → 解码为 nil,其余字段完整") func liveSessionInfoToleratesMissingTelemetryAndNullCwd() async throws { // Act let sessions = try await fetchSessions(body: "[\(Self.noTelemetrySessionJSON)]") // Assert let session = try #require(sessions.first) #expect(session.telemetry == nil) #expect(session.cwd == nil) #expect(session.status == .idle) #expect(session.cols == 80) #expect(session.rows == 24) } @Test("LiveSessionInfo.lastOutputAt 可选字段(T-iOS-37/23):在场→解出;缺席(旧服务器)→nil;坏类型→nil 不丢条目") func lastOutputAtDecodesAsOptional() async throws { // Arrange: 在场 / 缺席 / 坏类型(不可信服务器)三个条目。 let present = Self.noTelemetrySessionJSON .replacingOccurrences(of: #""createdAt":1720000000000"#, with: #""createdAt":1720000000000,"lastOutputAt":1720000007000"#) let wrongType = Self.fullSessionJSON .replacingOccurrences(of: #""createdAt":1720000000000"#, with: #""createdAt":1720000000000,"lastOutputAt":"soon""#) // Act let sessions = try await fetchSessions( body: "[\(present),\(Self.noTelemetrySessionJSON),\(wrongType)]" ) // Assert #expect(sessions.count == 3) #expect(sessions[0].lastOutputAt == 1_720_000_007_000) #expect(sessions[1].lastOutputAt == nil) #expect(sessions[2].lastOutputAt == nil) } @Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)") func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws { // Arrange let body = Self.noTelemetrySessionJSON .replacingOccurrences(of: #""status":"idle""#, with: #""status":"hyperdrive""#) // Act let sessions = try await fetchSessions(body: "[\(body)]") // Assert #expect(sessions.count == 1) #expect(sessions.first?.status == .unknown) } @Test("畸形条目(非对象/坏 UUID/缺必填)逐条丢弃,合法条目保留,不 crash") func malformedEntriesAreDroppedWhileValidOnesSurvive() async throws { // Arrange let badUUID = Self.noTelemetrySessionJSON .replacingOccurrences(of: Self.sessionIdString, with: "not-a-uuid") let body = "[\(Self.fullSessionJSON),42,\(badUUID),{\"id\":\"x\"}]" // Act let sessions = try await fetchSessions(body: body) // Assert #expect(sessions.count == 1) #expect(sessions.first?.id == UUID(uuidString: Self.sessionIdString)) } @Test("非数组 body(HTML/对象)→ invalidResponseBody 显式错误(探针的 端口对吗 信号)") func nonArrayBodyThrowsInvalidResponseBody() async throws { // Act + Assert — HTML(路由器管理页之类) await #expect(throws: APIClientError.invalidResponseBody) { _ = try await self.fetchSessions(body: "router admin") } // Act + Assert — JSON 但不是数组 await #expect(throws: APIClientError.invalidResponseBody) { _ = try await self.fetchSessions(body: #"{"error":"nope"}"#) } } // MARK: - TimelineEvent(经 WireProtocol helper) @Test("TimelineEvent 未知 class 值 → 该条丢弃不 crash(服务器视为不可信)") func unknownTimelineClassEntriesAreDroppedNotCrashed() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) let body = """ [{"at":1,"class":"tool","toolName":"Bash","label":"ran Bash"},\ {"at":2,"class":"quantum","label":"??"},\ {"at":3,"class":"waiting","label":"waiting for approval"}] """ await http.queueSuccess( url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"), body: Data(body.utf8) ) // Act let events = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString))) // Assert #expect(events.count == 2) #expect(events.map(\.class) == ["tool", "waiting"]) #expect(events.first?.toolName == "Bash") } @Test("events 404 → sessionNotFound") func events404ThrowsSessionNotFound() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess( url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"), status: 404 ) // Act + Assert await #expect(throws: APIClientError.sessionNotFound) { _ = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString))) } } // MARK: - SessionPreview / UiConfig @Test("SessionPreview 全字段解码(GET /live-sessions/:id/preview 形状)") func sessionPreviewDecodesAllFields() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) let body = #"{"id":"\#(Self.sessionIdString)","cols":100,"rows":30,"data":"hello"}"# await http.queueSuccess( url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), body: Data(body.utf8) ) // Act let preview = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString))) // Assert let expected = SessionPreview( id: try #require(UUID(uuidString: Self.sessionIdString)), cols: 100, rows: 30, data: "hello" ) #expect(preview == expected) } @Test("preview 200 但 body 非预期形状 → invalidResponseBody") func previewGarbageBodyThrowsInvalidResponseBody() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess( url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), body: Data("not json".utf8) ) // Act + Assert await #expect(throws: APIClientError.invalidResponseBody) { _ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString))) } } @Test("preview 404 → sessionNotFound") func preview404ThrowsSessionNotFound() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess( url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), status: 404 ) // Act + Assert await #expect(throws: APIClientError.sessionNotFound) { _ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString))) } } @Test("UiConfig 解码 allowAutoMode(GET /config/ui,src/types.ts:503)") func uiConfigDecodesAllowAutoMode() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess( url: try routeURL("/config/ui"), body: Data(#"{"allowAutoMode":true}"#.utf8) ) // Act let config = try await client.uiConfig() // Assert #expect(config == UiConfig(allowAutoMode: true)) } @Test("uiConfig 200 但 body 非预期形状 → invalidResponseBody") func uiConfigGarbageBodyThrowsInvalidResponseBody() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess(url: try routeURL("/config/ui"), body: Data("[]".utf8)) // Act + Assert await #expect(throws: APIClientError.invalidResponseBody) { _ = try await client.uiConfig() } } @Test("liveSessions 非 200(500) → unexpectedStatus(500)") func liveSessions500ThrowsUnexpectedStatus() async throws { // Arrange let http = FakeHTTPTransport() let client = try makeClient(http) await http.queueSuccess(url: try routeURL("/live-sessions"), status: 500) // Act + Assert await #expect(throws: APIClientError.unexpectedStatus(500)) { _ = try await client.liveSessions() } } // MARK: - 话术 @Test("decisionRejected 话术明示 token 已过期/已被处理(RED 清单:403 → token 过期话术)") func decisionRejectedMessageMentionsTokenExpiry() { #expect(APIClientError.decisionRejected.message.contains("过期")) } @Test("APIClientError 每个 case 都有非空 UI 话术(plan §4:错误处理全面显式)") func everyAPIClientErrorCaseHasNonEmptyMessage() { // Arrange let allCases: [APIClientError] = [ .invalidRequest, .invalidResponseBody, .sessionNotFound, .forbidden, .decisionRejected, .rateLimited, .unexpectedStatus(500), ] // Act + Assert for errorCase in allCases { #expect(!errorCase.message.isEmpty) } #expect(APIClientError.unexpectedStatus(500).message.contains("500")) } }