248 lines
10 KiB
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))")
|
|
}
|
|
}
|