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