// 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. import XCTest @testable import piRemote final class FrameCodecTests: XCTestCase { // ------------------------------------------------------------------------- // MARK: 1. BinaryFrame.decode // ------------------------------------------------------------------------- /// A 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)) } /// Large sequence numbers (UInt64.max) must round-trip through the header. func testBinaryFrameDecode_maxSeq() throws { // seq = UInt64.max = FF FF FF FF FF FF FF FF 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) } /// A multi-byte big-endian seq: 0x0000_0001_0000_0000 = 4_294_967_296 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 be rejected (returns nil). func testBinaryFrameDecode_tooShort_returnsNil() { let raw = Data([0x00, 0x01, 0x02]) XCTAssertNil(BinaryFrame.decode(raw)) } /// Exactly 8 bytes is valid (empty payload, seq depends on content). func testBinaryFrameDecode_exactlyEightBytes_emptyPayload() throws { let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A] // seq = 42 let frame = try XCTUnwrap(BinaryFrame.decode(Data(seqBytes))) XCTAssertEqual(frame.seq, 42) XCTAssertTrue(frame.data.isEmpty) } // ------------------------------------------------------------------------- // MARK: 2. ClientToServer JSON encoding // ------------------------------------------------------------------------- func testEncode_resume_withLastSeq() throws { let json = try FrameCodec.encode(.resume(lastSeq: 99)) let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] XCTAssertEqual(obj["type"] as? String, "resume") XCTAssertEqual(obj["lastSeq"] as? Int, 99) } func testEncode_resume_nilLastSeq_encodesNull() throws { let json = try FrameCodec.encode(.resume(lastSeq: nil)) // Verify "lastSeq" key exists and its value is JSON null. let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] XCTAssertEqual(obj["type"] as? String, "resume") // JSON null comes back as NSNull in Foundation. XCTAssertTrue(obj["lastSeq"] is NSNull, "Expected lastSeq to be null, got \(String(describing: obj["lastSeq"]))") } func testEncode_key() throws { let json = try FrameCodec.encode(.key(name: "escape")) XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#) } func testEncode_keys() throws { let json = try FrameCodec.encode(.keys(data: "hello world")) let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] XCTAssertEqual(obj["type"] as? String, "keys") XCTAssertEqual(obj["data"] as? String, "hello world") } func testEncode_paste() throws { let json = try FrameCodec.encode(.paste(data: "pasted text\nwith newline")) let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] XCTAssertEqual(obj["type"] as? String, "paste") XCTAssertEqual(obj["data"] as? String, "pasted text\nwith newline") } func testEncode_snapshotRequest() throws { let json = try FrameCodec.encode(.snapshotRequest) XCTAssertEqual(json, #"{"type":"snapshot-request"}"#) } // ------------------------------------------------------------------------- // 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_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 { 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) } 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 { 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 rather than silently produce garbage. func testDecode_unknownType_throws() { let payload = #"{"type":"tree","nodes":[]}"# XCTAssertThrowsError(try FrameCodec.decode(payload)) } // ------------------------------------------------------------------------- // MARK: 4. ResumeCursor (bonus — no I/O, uses in-memory UserDefaults suite) // ------------------------------------------------------------------------- func testResumeCursor_roundTrip() { let suiteName = "FrameCodecTests.\(UUID())" // Using a named suite isolates this test from real app defaults. let defaults = UserDefaults(suiteName: suiteName)! let cursor = ResumeCursor(defaults: defaults) XCTAssertNil(cursor.lastSeq(for: "s1"), "Fresh cursor should be nil") cursor.update(sessionId: "s1", seq: 999) XCTAssertEqual(cursor.lastSeq(for: "s1"), 999) cursor.update(sessionId: "s1", seq: UInt64.max) XCTAssertEqual(cursor.lastSeq(for: "s1"), UInt64.max) cursor.clear(sessionId: "s1") XCTAssertNil(cursor.lastSeq(for: "s1"), "Cursor should be nil after clear") // Cleanup: remove the test suite. defaults.removePersistentDomain(forName: suiteName) } func testResumeCursor_isolatedPerSession() { let suiteName = "FrameCodecTests.\(UUID())" let defaults = UserDefaults(suiteName: suiteName)! let cursor = ResumeCursor(defaults: defaults) cursor.update(sessionId: "alpha", seq: 10) cursor.update(sessionId: "beta", seq: 20) XCTAssertEqual(cursor.lastSeq(for: "alpha"), 10) XCTAssertEqual(cursor.lastSeq(for: "beta"), 20) cursor.clear(sessionId: "alpha") XCTAssertNil(cursor.lastSeq(for: "alpha")) XCTAssertEqual(cursor.lastSeq(for: "beta"), 20, "Clearing alpha must not affect beta") defaults.removePersistentDomain(forName: suiteName) } }