feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe

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
This commit is contained in:
Yaojia Wang
2026-07-04 21:53:41 +02:00
parent 2ab93c9682
commit 95438cdc12
32 changed files with 3772 additions and 4 deletions

View File

@@ -0,0 +1,313 @@
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"))
}
}