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

230 lines
9.7 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.
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)
}
}