pi-remote-ios/Tests/CoreTests/FrameCodecTests.swift

248 lines
10 KiB
Swift

// FrameCodecTests.swift
// Unit tests for BinaryFrame, ClientToServer encoding, and ServerToClient decoding.
//
// All tests are pure (no network, no Starscream, no async). FrameCodec.swift
// imports only Foundation, making this test target dependency-free.
//
// IC-1 spec reference: docs/PHASE-2-ios-mvp.md §Wire Protocol
import XCTest
@testable import piRemote
final class FrameCodecTests: XCTestCase {
// =========================================================================
// MARK: 1. BinaryFrame.decode
// =========================================================================
/// Well-formed binary frame: 8-byte big-endian seq = 1, payload = "hello".
func testBinaryFrameDecode_knownBytes() throws {
// seq = 1 in big-endian: 00 00 00 00 00 00 00 01
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]
let payload = Array("hello".utf8)
let raw = Data(seqBytes + payload)
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
XCTAssertEqual(frame.seq, 1)
XCTAssertEqual(frame.data, Data("hello".utf8))
}
/// UInt64.max must round-trip through the 8-byte big-endian header.
func testBinaryFrameDecode_maxSeq() throws {
let seqBytes: [UInt8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
let raw = Data(seqBytes) // empty payload is valid
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
XCTAssertEqual(frame.seq, UInt64.max)
XCTAssertTrue(frame.data.isEmpty)
}
/// seq = 0x0000_0001_0000_0000 = 4_294_967_296 verifies big-endian byte order.
func testBinaryFrameDecode_bigEndianOrdering() throws {
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
let raw = Data(seqBytes + [0xAB, 0xCD])
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
XCTAssertEqual(frame.seq, 4_294_967_296)
XCTAssertEqual(frame.data, Data([0xAB, 0xCD]))
}
/// Frames shorter than 8 bytes must return nil (header incomplete).
func testBinaryFrameDecode_tooShort_returnsNil() {
XCTAssertNil(BinaryFrame.decode(Data([0x00, 0x01, 0x02])))
}
/// Empty data must also return nil (0 bytes < 8-byte minimum).
func testBinaryFrameDecode_emptyData_returnsNil() {
XCTAssertNil(BinaryFrame.decode(Data()))
}
/// Exactly 8 bytes is valid: empty payload, seq extracted from header.
func testBinaryFrameDecode_exactlyEightBytes_emptyPayload() throws {
// seq = 42 = 0x2A
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A]
let frame = try XCTUnwrap(BinaryFrame.decode(Data(seqBytes)))
XCTAssertEqual(frame.seq, 42)
XCTAssertTrue(frame.data.isEmpty)
}
// =========================================================================
// MARK: 2. ClientToServer JSON encoding IC-1 field names
// =========================================================================
func testEncode_resume_nilLastSeq_producesNull() throws {
// IC-1 requires {"type":"resume","lastSeq":null}
let json = try FrameCodec.encode(.resume(lastSeq: nil))
let obj = try asDict(json)
XCTAssertEqual(obj["type"] as? String, "resume", "type field must be 'resume'")
XCTAssertTrue(obj["lastSeq"] is NSNull,
"lastSeq must be JSON null, got: \(String(describing: obj["lastSeq"]))")
}
func testEncode_resume_withLastSeq42() throws {
// IC-1 requires {"type":"resume","lastSeq":42}
let json = try FrameCodec.encode(.resume(lastSeq: 42))
let obj = try asDict(json)
XCTAssertEqual(obj["type"] as? String, "resume")
XCTAssertEqual(obj["lastSeq"] as? Int, 42)
}
func testEncode_key_escape() throws {
// IC-1 requires {"type":"key","name":"escape"}
let json = try FrameCodec.encode(.key(name: "escape"))
XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#,
"Exact IC-1 encoding expected")
}
func testEncode_keys_data() throws {
// IC-1 requires {"type":"keys","data":"hello"}
let json = try FrameCodec.encode(.keys(data: "hello"))
let obj = try asDict(json)
XCTAssertEqual(obj["type"] as? String, "keys")
XCTAssertEqual(obj["data"] as? String, "hello")
}
func testEncode_paste_data() throws {
// IC-1 requires {"type":"paste","data":"text"}
let json = try FrameCodec.encode(.paste(data: "text"))
let obj = try asDict(json)
XCTAssertEqual(obj["type"] as? String, "paste")
XCTAssertEqual(obj["data"] as? String, "text")
}
func testEncode_snapshotRequest() throws {
// IC-1 requires {"type":"snapshot-request"}
let json = try FrameCodec.encode(.snapshotRequest)
XCTAssertEqual(json, #"{"type":"snapshot-request"}"#,
"Exact IC-1 encoding expected")
}
/// Multi-line paste must survive encoding without key truncation.
func testEncode_paste_multilineData() throws {
let json = try FrameCodec.encode(.paste(data: "line1\nline2"))
let obj = try asDict(json)
XCTAssertEqual(obj["data"] as? String, "line1\nline2")
}
// =========================================================================
// MARK: 3. ServerToClient JSON decoding
// =========================================================================
func testDecode_state_idle() throws {
let payload = #"{"type":"state","value":"idle","ts":1716000000}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, let tool, let ts) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .idle)
XCTAssertNil(tool)
XCTAssertEqual(ts, 1_716_000_000)
}
func testDecode_state_thinking() throws {
let payload = #"{"type":"state","value":"thinking","ts":1}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, _, _) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .thinking)
}
func testDecode_state_tool_withToolName() throws {
let payload = #"{"type":"state","value":"tool","tool":"bash","ts":42}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, let tool, let ts) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .tool)
XCTAssertEqual(tool, "bash")
XCTAssertEqual(ts, 42)
}
func testDecode_state_awaitingInput() throws {
// IC-1 raw value is "awaiting-input" (hyphenated)
let payload = #"{"type":"state","value":"awaiting-input","ts":0}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, _, _) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .awaitingInput,
"PiState must map 'awaiting-input' → .awaitingInput")
}
func testDecode_snapshot() throws {
let payload = #"{"type":"snapshot","seq":1234,"data":"SGVsbG8="}"#
let frame = try FrameCodec.decode(payload)
guard case .snapshot(let seq, let data) = frame else {
return XCTFail("Expected .snapshot, got \(frame)")
}
XCTAssertEqual(seq, 1234)
XCTAssertEqual(data, "SGVsbG8=")
}
func testDecode_sessionMeta_withDescription() throws {
let payload = #"{"type":"session-meta","name":"my-session","description":"A test session","createdAt":"2026-05-15T10:00:00Z"}"#
let frame = try FrameCodec.decode(payload)
guard case .sessionMeta(let name, let description, let createdAt) = frame else {
return XCTFail("Expected .sessionMeta, got \(frame)")
}
XCTAssertEqual(name, "my-session")
XCTAssertEqual(description, "A test session")
XCTAssertEqual(createdAt, "2026-05-15T10:00:00Z")
}
func testDecode_sessionMeta_withoutDescription() throws {
// description is optional per IC-1
let payload = #"{"type":"session-meta","name":"bare","createdAt":"2026-01-01T00:00:00Z"}"#
let frame = try FrameCodec.decode(payload)
guard case .sessionMeta(let name, let description, _) = frame else {
return XCTFail("Expected .sessionMeta, got \(frame)")
}
XCTAssertEqual(name, "bare")
XCTAssertNil(description)
}
func testDecode_error() throws {
let payload = #"{"type":"error","code":"auth_failed","message":"Invalid token"}"#
let frame = try FrameCodec.decode(payload)
guard case .error(let code, let message) = frame else {
return XCTFail("Expected .error, got \(frame)")
}
XCTAssertEqual(code, "auth_failed")
XCTAssertEqual(message, "Invalid token")
}
/// Unknown type keys must throw a `DecodingError`, not silently return garbage.
func testDecode_unknownType_throws() {
let payload = #"{"type":"tree","nodes":[]}"#
XCTAssertThrowsError(try FrameCodec.decode(payload),
"Unknown type discriminator must throw")
}
/// Malformed JSON must throw.
func testDecode_malformedJSON_throws() {
XCTAssertThrowsError(try FrameCodec.decode("not json at all"))
}
// =========================================================================
// MARK: 4. Round-trip: encode ClientToServer decode shape check
// =========================================================================
/// Encode a .key frame and verify it can be re-parsed by JSONSerialization.
func testRoundtrip_encode_isValidJSON() throws {
let json = try FrameCodec.encode(.key(name: "up"))
// Must not throw i.e., the output is valid JSON.
XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(json.utf8)))
}
// =========================================================================
// MARK: Helpers
// =========================================================================
private func asDict(_ json: String) throws -> [String: Any] {
let obj = try JSONSerialization.jsonObject(with: Data(json.utf8))
return try XCTUnwrap(obj as? [String: Any],
"Expected top-level JSON object, got \(type(of: obj))")
}
}