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