T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
314 lines
12 KiB
Swift
314 lines
12 KiB
Swift
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("未知 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: "<html><body>router admin</body></html>")
|
||
}
|
||
// 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"))
|
||
}
|
||
}
|