merge: 78 unit tests (FrameCodec, ResumeCursor, Pairing, Keychain, Theme)
This commit is contained in:
commit
6b953008ce
|
|
@ -1,114 +1,136 @@
|
|||
// FrameCodecTests.swift
|
||||
// Unit tests for BinaryFrame, ClientToServer encoding, and ServerToClient decoding.
|
||||
//
|
||||
// All tests are pure (no network, no Starscream, no async) — FrameCodec.swift
|
||||
// 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
|
||||
// -------------------------------------------------------------------------
|
||||
// =========================================================================
|
||||
|
||||
/// A well-formed binary frame: 8-byte big-endian seq = 1, payload = "hello".
|
||||
/// 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 payload = Array("hello".utf8)
|
||||
let raw = Data(seqBytes + payload)
|
||||
|
||||
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
|
||||
XCTAssertEqual(frame.seq, 1)
|
||||
XCTAssertEqual(frame.seq, 1)
|
||||
XCTAssertEqual(frame.data, Data("hello".utf8))
|
||||
}
|
||||
|
||||
/// Large sequence numbers (UInt64.max) must round-trip through the header.
|
||||
/// UInt64.max must round-trip through the 8-byte big-endian 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
|
||||
/// 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.seq, 4_294_967_296)
|
||||
XCTAssertEqual(frame.data, Data([0xAB, 0xCD]))
|
||||
}
|
||||
|
||||
/// Frames shorter than 8 bytes must be rejected (returns nil).
|
||||
/// Frames shorter than 8 bytes must return nil (header incomplete).
|
||||
func testBinaryFrameDecode_tooShort_returnsNil() {
|
||||
let raw = Data([0x00, 0x01, 0x02])
|
||||
XCTAssertNil(BinaryFrame.decode(raw))
|
||||
XCTAssertNil(BinaryFrame.decode(Data([0x00, 0x01, 0x02])))
|
||||
}
|
||||
|
||||
/// Exactly 8 bytes is valid (empty payload, seq depends on content).
|
||||
/// 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 {
|
||||
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A] // seq = 42
|
||||
// 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
|
||||
// -------------------------------------------------------------------------
|
||||
// =========================================================================
|
||||
// MARK: 2. ClientToServer JSON encoding — IC-1 field names
|
||||
// =========================================================================
|
||||
|
||||
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 {
|
||||
func testEncode_resume_nilLastSeq_producesNull() throws {
|
||||
// IC-1 requires {"type":"resume","lastSeq":null}
|
||||
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"]))")
|
||||
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_key() throws {
|
||||
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"}"#)
|
||||
XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#,
|
||||
"Exact IC-1 encoding expected")
|
||||
}
|
||||
|
||||
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]
|
||||
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 world")
|
||||
XCTAssertEqual(obj["data"] as? String, "hello")
|
||||
}
|
||||
|
||||
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]
|
||||
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, "pasted text\nwith newline")
|
||||
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"}"#)
|
||||
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)
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .state(let value, let tool, let ts) = frame else {
|
||||
return XCTFail("Expected .state, got \(frame)")
|
||||
}
|
||||
|
|
@ -117,50 +139,62 @@ final class FrameCodecTests: XCTestCase {
|
|||
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)
|
||||
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)
|
||||
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)
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .state(let value, _, _) = frame else {
|
||||
return XCTFail("Expected .state, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(value, .awaitingInput)
|
||||
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)
|
||||
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(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)
|
||||
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(name, "my-session")
|
||||
XCTAssertEqual(description, "A test session")
|
||||
XCTAssertEqual(createdAt, "2026-05-15T10:00:00Z")
|
||||
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)
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .sessionMeta(let name, let description, _) = frame else {
|
||||
return XCTFail("Expected .sessionMeta, got \(frame)")
|
||||
}
|
||||
|
|
@ -170,60 +204,44 @@ final class FrameCodecTests: XCTestCase {
|
|||
|
||||
func testDecode_error() throws {
|
||||
let payload = #"{"type":"error","code":"auth_failed","message":"Invalid token"}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
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(code, "auth_failed")
|
||||
XCTAssertEqual(message, "Invalid token")
|
||||
}
|
||||
|
||||
/// Unknown type keys must throw rather than silently produce garbage.
|
||||
/// 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))
|
||||
XCTAssertThrowsError(try FrameCodec.decode(payload),
|
||||
"Unknown type discriminator must throw")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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)
|
||||
/// Malformed JSON must throw.
|
||||
func testDecode_malformedJSON_throws() {
|
||||
XCTAssertThrowsError(try FrameCodec.decode("not json at all"))
|
||||
}
|
||||
|
||||
func testResumeCursor_isolatedPerSession() {
|
||||
let suiteName = "FrameCodecTests.\(UUID())"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
let cursor = ResumeCursor(defaults: defaults)
|
||||
// =========================================================================
|
||||
// MARK: 4. Round-trip: encode ClientToServer → decode shape check
|
||||
// =========================================================================
|
||||
|
||||
cursor.update(sessionId: "alpha", seq: 10)
|
||||
cursor.update(sessionId: "beta", seq: 20)
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
XCTAssertEqual(cursor.lastSeq(for: "alpha"), 10)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20)
|
||||
// =========================================================================
|
||||
// MARK: Helpers
|
||||
// =========================================================================
|
||||
|
||||
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)
|
||||
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))")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
// KeychainTests.swift
|
||||
// Unit tests for the Keychain wrapper.
|
||||
//
|
||||
// IMPORTANT: Each test uses a unique key (never the production key
|
||||
// `Keychain.credentialKey`) so real app data is never touched.
|
||||
// All test keys are deleted in tearDown even if the test fails.
|
||||
//
|
||||
// Note: Keychain tests require the iOS simulator or a real device with
|
||||
// a valid entitlement. They will fail in headless environments where
|
||||
// the Security framework is unavailable.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class KeychainTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Unique per-run key prefix so parallel test runs don't collide.
|
||||
private var testKey: String { "test.keychain.\(name)" }
|
||||
|
||||
override func tearDown() {
|
||||
// Always clean up the test key to avoid polluting the Keychain.
|
||||
Keychain.shared.delete(key: testKey)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// A minimal `SidecarCredential` for testing.
|
||||
private func makeCredential(name: String = "test-pi") -> SidecarCredential {
|
||||
SidecarCredential(
|
||||
sidecarId: "sid-\(UUID().uuidString)",
|
||||
host: "192.168.1.100",
|
||||
port: 7777,
|
||||
bearerToken: "bearer-\(UUID().uuidString)",
|
||||
tlsFingerprint: "deadbeef1234",
|
||||
sidecarName: name,
|
||||
pairedAt: Date(timeIntervalSince1970: 1_716_000_000)
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. Save + load round-trip
|
||||
// =========================================================================
|
||||
|
||||
func testSaveAndLoad_credential_roundTrips() throws {
|
||||
let credential = makeCredential(name: "my-pi")
|
||||
|
||||
try Keychain.shared.save(credential, key: testKey)
|
||||
let loaded: SidecarCredential = try Keychain.shared.load(key: testKey)
|
||||
|
||||
XCTAssertEqual(loaded.sidecarId, credential.sidecarId)
|
||||
XCTAssertEqual(loaded.host, credential.host)
|
||||
XCTAssertEqual(loaded.port, credential.port)
|
||||
XCTAssertEqual(loaded.bearerToken, credential.bearerToken)
|
||||
XCTAssertEqual(loaded.tlsFingerprint, credential.tlsFingerprint)
|
||||
XCTAssertEqual(loaded.sidecarName, credential.sidecarName)
|
||||
// Date comparison within 1 second tolerance (JSON Date encoding).
|
||||
XCTAssertEqual(loaded.pairedAt.timeIntervalSince1970,
|
||||
credential.pairedAt.timeIntervalSince1970,
|
||||
accuracy: 1.0)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. Update (upsert)
|
||||
// =========================================================================
|
||||
|
||||
func testSave_overwritesPrevious_onLoad() throws {
|
||||
let first = makeCredential(name: "first-pi")
|
||||
let second = makeCredential(name: "second-pi")
|
||||
|
||||
try Keychain.shared.save(first, key: testKey)
|
||||
try Keychain.shared.save(second, key: testKey)
|
||||
|
||||
let loaded: SidecarCredential = try Keychain.shared.load(key: testKey)
|
||||
XCTAssertEqual(loaded.sidecarName, "second-pi",
|
||||
"Second save must overwrite the first")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. Missing key → notFound
|
||||
// =========================================================================
|
||||
|
||||
func testLoad_missingKey_throwsNotFound() {
|
||||
let missingKey = "test.keychain.definitely-absent-\(UUID())"
|
||||
defer { Keychain.shared.delete(key: missingKey) }
|
||||
|
||||
XCTAssertThrowsError(try Keychain.shared.load(key: missingKey) as SidecarCredential) { error in
|
||||
guard let keychainError = error as? KeychainError,
|
||||
case .notFound = keychainError else {
|
||||
XCTFail("Expected KeychainError.notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Delete clears entry
|
||||
// =========================================================================
|
||||
|
||||
func testDelete_clearsEntry() throws {
|
||||
try Keychain.shared.save(makeCredential(), key: testKey)
|
||||
|
||||
Keychain.shared.delete(key: testKey)
|
||||
|
||||
XCTAssertThrowsError(try Keychain.shared.load(key: testKey) as SidecarCredential,
|
||||
"After delete, load must throw") { error in
|
||||
guard let keychainError = error as? KeychainError,
|
||||
case .notFound = keychainError else {
|
||||
XCTFail("Expected KeychainError.notFound after delete, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testDelete_missingKey_isNoOp() {
|
||||
// Deleting a key that doesn't exist must not crash or throw.
|
||||
Keychain.shared.delete(key: "test.keychain.never-saved-\(UUID())")
|
||||
// No assertion needed — reaching this line means no crash.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. Production key is NOT used in tests
|
||||
// =========================================================================
|
||||
|
||||
func testProductionKeyIsUntouched() {
|
||||
// This test simply verifies that our test key is different from the
|
||||
// production credential key, so tests never corrupt real data.
|
||||
XCTAssertNotEqual(testKey, Keychain.credentialKey,
|
||||
"Test key must differ from the production credential key")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 6. Generic save/load with a simple Codable type
|
||||
// =========================================================================
|
||||
|
||||
private struct TestPayload: Codable, Equatable {
|
||||
let id: String
|
||||
let value: Int
|
||||
}
|
||||
|
||||
func testSaveAndLoad_simplePayload() throws {
|
||||
let key = "test.keychain.payload.\(UUID())"
|
||||
let payload = TestPayload(id: "x", value: 99)
|
||||
defer { Keychain.shared.delete(key: key) }
|
||||
|
||||
try Keychain.shared.save(payload, key: key)
|
||||
let loaded: TestPayload = try Keychain.shared.load(key: key)
|
||||
|
||||
XCTAssertEqual(loaded, payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// PairingTests.swift
|
||||
// Unit tests for PairingService.parseQR — IC-1 QR URL parsing.
|
||||
//
|
||||
// Format: pi-remote://<host>:<port>?pair=<pairingToken>&fp=<fingerprint>&name=<sidecarName>
|
||||
//
|
||||
// No network calls are made; parseQR is a pure function.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class PairingTests: XCTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. Happy-path parsing
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_canonical_parsesAllFields() throws {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
let result = try PairingService.parseQR(url)
|
||||
|
||||
XCTAssertEqual(result.host, "192.168.1.1")
|
||||
XCTAssertEqual(result.port, 7777)
|
||||
XCTAssertEqual(result.pairingToken, "abc")
|
||||
XCTAssertEqual(result.fingerprint, "deadbeef")
|
||||
XCTAssertEqual(result.name, "pi-remote")
|
||||
}
|
||||
|
||||
func testParseQR_hostOnly_differentPort() throws {
|
||||
let url = "pi-remote://pi.local:9000?pair=tok123&fp=aabbcc&name=mypi"
|
||||
let result = try PairingService.parseQR(url)
|
||||
|
||||
XCTAssertEqual(result.host, "pi.local")
|
||||
XCTAssertEqual(result.port, 9000)
|
||||
XCTAssertEqual(result.pairingToken, "tok123")
|
||||
XCTAssertEqual(result.fingerprint, "aabbcc")
|
||||
XCTAssertEqual(result.name, "mypi")
|
||||
}
|
||||
|
||||
func testParseQR_longFingerprint_parsedCorrectly() throws {
|
||||
let fp = String(repeating: "a1", count: 32) // 64-char SHA-256 hex
|
||||
let url = "pi-remote://10.0.0.1:7777?pair=t&fp=\(fp)&name=n"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.fingerprint, fp)
|
||||
}
|
||||
|
||||
func testParseQR_portBoundary_lowPort() throws {
|
||||
let url = "pi-remote://localhost:1?pair=t&fp=f&name=n"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.port, 1)
|
||||
}
|
||||
|
||||
func testParseQR_portBoundary_highPort() throws {
|
||||
let url = "pi-remote://localhost:65535?pair=t&fp=f&name=n"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.port, 65535)
|
||||
}
|
||||
|
||||
func testParseQR_nameWithSpaces_percentEncoded() throws {
|
||||
// Spaces encoded as %20 in the QR URL
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=tok&fp=fp&name=my%20pi"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.name, "my pi")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. Missing required parameters → throws
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_missingPairParam_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing 'pair' must throw")
|
||||
}
|
||||
|
||||
func testParseQR_missingFpParam_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=abc&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing 'fp' must throw")
|
||||
}
|
||||
|
||||
func testParseQR_missingNameParam_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=abc&fp=deadbeef"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing 'name' must throw")
|
||||
}
|
||||
|
||||
func testParseQR_noQueryParams_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"No query params must throw")
|
||||
}
|
||||
|
||||
func testParseQR_emptyPairValue_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Empty 'pair' value must throw")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. Wrong scheme → throws
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_httpsScheme_throws() {
|
||||
// Wrong scheme — sidecar only issues pi-remote:// URLs
|
||||
let url = "https://192.168.1.1:7777?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"https:// scheme must throw .invalidQR")
|
||||
}
|
||||
|
||||
func testParseQR_httpScheme_throws() {
|
||||
let url = "http://192.168.1.1:7777?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"http:// scheme must throw .invalidQR")
|
||||
}
|
||||
|
||||
func testParseQR_emptyString_throws() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR(""),
|
||||
"Empty string must throw")
|
||||
}
|
||||
|
||||
func testParseQR_randomString_throws() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR("not-a-url"),
|
||||
"Garbage string must throw")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Missing port → throws
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_missingPort_throws() {
|
||||
// No port specified — should throw because port is required
|
||||
let url = "pi-remote://192.168.1.1?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing port must throw .invalidQR")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. Error type is PairingError.invalidQR
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_wrongScheme_throwsInvalidQR() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR("https://host:7777?pair=x&fp=y&name=z")) { error in
|
||||
guard let pairingError = error as? PairingError,
|
||||
case .invalidQR = pairingError else {
|
||||
XCTFail("Expected PairingError.invalidQR, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testParseQR_missingParam_throwsInvalidQR() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR("pi-remote://host:7777?pair=x&fp=y")) { error in
|
||||
guard let pairingError = error as? PairingError,
|
||||
case .invalidQR = pairingError else {
|
||||
XCTFail("Expected PairingError.invalidQR, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
# Phase 2 Implementation Review Notes
|
||||
|
||||
**Reviewer:** T-2.1/2.2/2.3 Test Agent
|
||||
**Date:** 2026-05-15
|
||||
**Branch read:** `origin/feat/p2-t2-1-websocket`, `origin/feat/p2-t2-2-pairing`, `origin/feat/p2-t2-3-terminal`
|
||||
|
||||
---
|
||||
|
||||
## Branch Status at Discovery
|
||||
|
||||
| Branch | Appeared at attempt | Files |
|
||||
|--------|---|---|
|
||||
| `feat/p2-t2-2-pairing` | Attempt 1 (~0 min) | **EMPTY** — only base scaffold |
|
||||
| `feat/p2-t2-3-terminal` | Attempt 10 (~4.5 min) | Terminal + Auth (both T-2.2 and T-2.3 files) |
|
||||
| `feat/p2-t2-1-websocket` | Attempt 13 (~6 min) | Network layer + partial FrameCodecTests |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### `feat/p2-t2-1-websocket` — Network Layer
|
||||
|
||||
**Files:**
|
||||
- `Sources/Core/Network/FrameCodec.swift` — BinaryFrame, ClientToServer, ServerToClient, FrameCodec namespace
|
||||
- `Sources/Core/Network/ResumeCursor.swift` — UserDefaults-backed UInt64 cursor
|
||||
- `Sources/Core/Network/WebSocketClient.swift` — @MainActor Starscream wrapper with Combine subjects
|
||||
- `Tests/CoreTests/FrameCodecTests.swift` — Partial test file committed to this branch
|
||||
|
||||
**Quality:** High. IC-1 wire format is faithfully implemented. All JSON field names match the spec. BinaryFrame.decode correctly uses big-endian byte shifting.
|
||||
|
||||
### `feat/p2-t2-2-pairing` — EMPTY
|
||||
|
||||
This branch contains **no implementation files** — only the base Xcode scaffold. The pairing code was found on `feat/p2-t2-3-terminal` instead (see below).
|
||||
|
||||
### `feat/p2-t2-3-terminal` — Terminal + Auth (both T-2.2 and T-2.3)
|
||||
|
||||
**Files:**
|
||||
- `Sources/Core/Auth/Keychain.swift` — Generic Codable Keychain wrapper
|
||||
- `Sources/Core/Auth/Pairing.swift` — PairingService with parseQR and exchange
|
||||
- `Sources/Core/Auth/SidecarCredential.swift` — Codable credential model
|
||||
- `Sources/UI/Pairing/PairingFlowView.swift` — SwiftUI pairing flow
|
||||
- `Sources/UI/Pairing/QRScannerView.swift` — AVFoundation QR scanner
|
||||
- `Sources/UI/Terminal/TerminalTheme.swift` — ThemeColor + TerminalTheme + built-ins
|
||||
- `Sources/UI/Terminal/ThemeStore.swift` — @MainActor ObservableObject
|
||||
- `Sources/UI/Terminal/FontStore.swift` — @MainActor ObservableObject
|
||||
- `Sources/UI/Terminal/TerminalFont.swift` — Static font presets
|
||||
- `Sources/UI/Terminal/TerminalViewController.swift` — SwiftTerm wrapper
|
||||
- `Sources/UI/Terminal/TerminalViewRepresentable.swift` — UIViewControllerRepresentable
|
||||
|
||||
**Quality:** High. All types are Sendable-annotated correctly.
|
||||
|
||||
---
|
||||
|
||||
## IC-1 Compliance Issues
|
||||
|
||||
### ✅ All Field Names Correct
|
||||
|
||||
Every JSON field name in `FrameCodec.swift` matches IC-1:
|
||||
|
||||
| Frame | Type field | Extra fields | Status |
|
||||
|-------|-----------|--------------|--------|
|
||||
| resume | `"resume"` | `lastSeq` (null or UInt64) | ✅ |
|
||||
| key | `"key"` | `name` | ✅ |
|
||||
| keys | `"keys"` | `data` | ✅ |
|
||||
| paste | `"paste"` | `data` | ✅ |
|
||||
| snapshot-request | `"snapshot-request"` | none | ✅ |
|
||||
| state (server) | `"state"` | `value`, `tool?`, `ts` | ✅ |
|
||||
| snapshot (server) | `"snapshot"` | `seq`, `data` | ✅ |
|
||||
| session-meta (server) | `"session-meta"` | `name`, `description?`, `createdAt` | ✅ |
|
||||
| error (server) | `"error"` | `code`, `message` | ✅ |
|
||||
|
||||
### ✅ `awaiting-input` Raw Value Correct
|
||||
|
||||
`PiState.awaitingInput` has `rawValue = "awaiting-input"` (hyphenated), which is correct per IC-1. This is a common mistake point — the implementation got it right.
|
||||
|
||||
### ✅ `lastSeq: null` Encoding
|
||||
|
||||
`ClientToServer.resume(lastSeq: nil)` explicitly encodes JSON `null` (not omitting the key), which is what IC-1 requires. The comment in the code explains this intentional choice.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6 Concurrency Issues
|
||||
|
||||
### ⚠️ Minor: `FrameCodec.encoder/decoder` — static mutable-ish singletons
|
||||
|
||||
```swift
|
||||
// FrameCodec.swift
|
||||
private static let encoder: JSONEncoder = { ... }()
|
||||
private static let decoder = JSONDecoder()
|
||||
```
|
||||
|
||||
`JSONEncoder` and `JSONDecoder` conform to `Sendable` as of Swift 5.7/Foundation updates. These are `static let` (not `var`), so they are initialized once and never mutated. **No Swift 6 compile error is expected.** However, if the Foundation version on the CI runner predates the `Sendable` conformance, a warning may appear. Recommend verifying with `-strict-concurrency=complete` on the build server.
|
||||
|
||||
### ⚠️ `WebSocketClient` — `DelegateAdapter.owner` is a non-isolated var
|
||||
|
||||
From the partial view of `WebSocketClient.swift`:
|
||||
|
||||
```swift
|
||||
private let delegateAdapter = DelegateAdapter()
|
||||
// ...
|
||||
delegateAdapter.owner = self // set in init
|
||||
```
|
||||
|
||||
If `DelegateAdapter` stores `owner` as `weak var owner: WebSocketClient?` (not actor-isolated), and Starscream calls the delegate from a background thread, there could be a data race in Swift 6. The comment says callbacks are hopped through `Task { @MainActor in … }` but `owner` assignment itself may not be safe. **Needs audit** when the full file is reviewed during PR.
|
||||
|
||||
### ✅ `ThemeStore` and `FontStore` — `@MainActor` Correct
|
||||
|
||||
Both stores are `@MainActor final class ObservableObject`. Singleton access via `.shared` from non-actor-isolated contexts will require `await` in Swift 6. Tests in this repo mark their ThemeStore test functions with `@MainActor` to handle this correctly.
|
||||
|
||||
### ✅ `PairingService` — `Sendable` Struct
|
||||
|
||||
`PairingService` is a `struct Sendable`, and `parseQR` is a `static` function. No concurrency issues.
|
||||
|
||||
### ✅ `Keychain` — `final class Sendable`
|
||||
|
||||
The implementation declares `final class Keychain: Sendable`. Since all methods delegate to thread-safe `Security` framework APIs and there is no mutable stored state, this is correct. No issues.
|
||||
|
||||
### ⚠️ `Keychain.load` throws `.encodingFailed` on decode failure
|
||||
|
||||
```swift
|
||||
} catch {
|
||||
throw KeychainError.encodingFailed // misleading name
|
||||
}
|
||||
```
|
||||
|
||||
The error case `KeychainError.encodingFailed` is thrown when **decoding** fails (reading from Keychain, not writing). The name is misleading — it should ideally be `.decodingFailed`. This is a minor naming issue, not a correctness problem, but callers that pattern-match `.encodingFailed` might be confused.
|
||||
|
||||
---
|
||||
|
||||
## Structural Issues
|
||||
|
||||
### 🚨 `feat/p2-t2-2-pairing` Branch is Empty
|
||||
|
||||
The T-2.2 pairing agent pushed a branch with no implementation files. The Auth and Pairing UI code was implemented by the T-2.3 terminal agent instead. This means:
|
||||
|
||||
1. The pairing branch PR will be empty/trivially mergeable.
|
||||
2. The terminal branch PR contains code for both T-2.2 and T-2.3.
|
||||
3. **Recommendation:** Credit the terminal agent with T-2.2 work, or have the pairing agent cherry-pick the Auth files before their PR is reviewed.
|
||||
|
||||
### ⚠️ `TerminalFont` is not `Equatable`
|
||||
|
||||
`TerminalFont` is `Identifiable, Sendable` but **not `Equatable`**. This makes it harder to assert equality in tests and may cause issues if SwiftUI `ForEach` needs to diff fonts. Recommend adding `Equatable` conformance (or at least equality on `id`).
|
||||
|
||||
### ⚠️ `JetBrains Mono` font not yet bundled
|
||||
|
||||
Documented in the source (`T-2.12 deferred`). Tests must not assume `UIFont(name: "JetBrainsMono-Regular", size:)` succeeds — the fallback to system monospace is correct behavior for now.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for Merge Order
|
||||
|
||||
1. **`feat/p2-t2-1-websocket`** — Merge first. Pure Foundation, no UI dependencies. All IC-1 wire types defined here; T-2.2 and T-2.3 code may eventually import from this layer.
|
||||
|
||||
2. **`feat/p2-t2-3-terminal`** — Merge second. Contains both T-2.2 (Auth) and T-2.3 (Terminal) implementations. Depends on nothing from T-2.2's (empty) branch.
|
||||
|
||||
3. **`feat/p2-t2-2-pairing`** — Merge last (or close without merge). The branch is empty and the implementation already landed via T-2.3. If the pairing agent re-uses the branch to deliver a QR-scanning integration test or additional pairing UI, it can be rebased on top of T-2.3's merge.
|
||||
|
||||
4. **`feat/p2-tests`** (this branch) — Can merge any time after T-2.1; the tests import `piRemote` which will contain all source files after the three implementation branches are merged.
|
||||
|
||||
---
|
||||
|
||||
## Test Files Created
|
||||
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `FrameCodecTests.swift` | 21 | BinaryFrame.decode, ClientToServer encoding (all 5 cases), ServerToClient decoding (all 4 types), round-trip |
|
||||
| `ResumeCursorTests.swift` | 11 | Save/load, update overwrites, zero seq, UInt64.max, clear, session isolation |
|
||||
| `PairingTests.swift` | 15 | Valid URLs, missing params (3 cases), wrong scheme (2), empty/missing port, error type assertions |
|
||||
| `KeychainTests.swift` | 7 | Round-trip, upsert, missing→notFound, delete clears, delete no-op, production key not touched, generic Codable |
|
||||
| `ThemeTests.swift` | 18 | ansiColors count, color range, dark≠github, id values, background values, SwiftTerm conversion, ThemeStore select, Codable round-trip |
|
||||
|
||||
**Total: 72 unit tests across 5 files.**
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ResumeCursorTests.swift
|
||||
// Unit tests for ResumeCursor — persistence of IC-1 sequence numbers.
|
||||
//
|
||||
// Uses a named UserDefaults suite per test to stay completely isolated from
|
||||
// the real app defaults and from other test cases.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class ResumeCursorTests: XCTestCase {
|
||||
|
||||
// Each test creates its own defaults suite; tearDown removes it.
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var cursor: ResumeCursor!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "ResumeCursorTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
cursor = ResumeCursor(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. Initial state
|
||||
// =========================================================================
|
||||
|
||||
func testLastSeq_freshSession_returnsNil() {
|
||||
XCTAssertNil(cursor.lastSeq(for: "session-A"),
|
||||
"No stored cursor → must return nil")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. Save + load
|
||||
// =========================================================================
|
||||
|
||||
func testUpdateAndLoad_returnsStoredValue() {
|
||||
cursor.update(sessionId: "s1", seq: 42)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s1"), 42)
|
||||
}
|
||||
|
||||
func testUpdate_overwrites_previousValue() {
|
||||
cursor.update(sessionId: "s1", seq: 1)
|
||||
cursor.update(sessionId: "s1", seq: 999)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s1"), 999,
|
||||
"Second update must overwrite the first")
|
||||
}
|
||||
|
||||
func testUpdate_zeroSeq_isStoredAndDistinctFromMissing() {
|
||||
// Before update: nil
|
||||
XCTAssertNil(cursor.lastSeq(for: "s-zero"))
|
||||
// After update with 0: 0 (not nil)
|
||||
cursor.update(sessionId: "s-zero", seq: 0)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s-zero"), 0)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. UInt64 boundary values
|
||||
// =========================================================================
|
||||
|
||||
func testUpdate_maxUInt64_roundTrips() {
|
||||
cursor.update(sessionId: "s-max", seq: UInt64.max)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s-max"), UInt64.max,
|
||||
"UInt64.max must survive the Int64-bit-pattern round-trip")
|
||||
}
|
||||
|
||||
func testUpdate_largeValue_roundTrips() {
|
||||
let large: UInt64 = 1_000_000_000_000
|
||||
cursor.update(sessionId: "s-large", seq: large)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s-large"), large)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Clear
|
||||
// =========================================================================
|
||||
|
||||
func testClear_removesEntry() {
|
||||
cursor.update(sessionId: "s1", seq: 100)
|
||||
cursor.clear(sessionId: "s1")
|
||||
XCTAssertNil(cursor.lastSeq(for: "s1"),
|
||||
"After clear, lastSeq must return nil")
|
||||
}
|
||||
|
||||
func testClear_missingEntry_isNoOp() {
|
||||
// Clearing a key that was never written must not throw or crash.
|
||||
cursor.clear(sessionId: "never-written")
|
||||
XCTAssertNil(cursor.lastSeq(for: "never-written"))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. Session isolation
|
||||
// =========================================================================
|
||||
|
||||
func testIndependentSessions_doNotInterfere() {
|
||||
cursor.update(sessionId: "alpha", seq: 10)
|
||||
cursor.update(sessionId: "beta", seq: 20)
|
||||
|
||||
XCTAssertEqual(cursor.lastSeq(for: "alpha"), 10)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20)
|
||||
}
|
||||
|
||||
func testClearOneSession_doesNotAffectOther() {
|
||||
cursor.update(sessionId: "alpha", seq: 10)
|
||||
cursor.update(sessionId: "beta", seq: 20)
|
||||
|
||||
cursor.clear(sessionId: "alpha")
|
||||
|
||||
XCTAssertNil(cursor.lastSeq(for: "alpha"),
|
||||
"alpha should be nil after clear")
|
||||
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20,
|
||||
"beta must be unaffected by clearing alpha")
|
||||
}
|
||||
|
||||
func testThreeIndependentSessions() {
|
||||
let ids: [String] = ["s1", "s2", "s3"]
|
||||
let seqs: [UInt64] = [1, 2, 3]
|
||||
zip(ids, seqs).forEach { cursor.update(sessionId: $0, seq: $1) }
|
||||
zip(ids, seqs).forEach {
|
||||
XCTAssertEqual(cursor.lastSeq(for: $0), $1)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 6. Missing entry (never stored)
|
||||
// =========================================================================
|
||||
|
||||
func testMissingEntry_returnsNil() {
|
||||
// Confirm nil for a key that was never written.
|
||||
XCTAssertNil(cursor.lastSeq(for: "completely-absent"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
// ThemeTests.swift
|
||||
// Unit tests for TerminalTheme, ThemeColor, ThemeStore, and FontStore.
|
||||
//
|
||||
// These tests exercise built-in theme validity and store selection logic.
|
||||
// No UIKit rendering or simulator is required for the pure-struct tests;
|
||||
// ThemeStore / FontStore tests require @MainActor and a run loop.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class ThemeTests: XCTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. TerminalTheme — built-in theme invariants
|
||||
// =========================================================================
|
||||
|
||||
func testDark_hasExactly16AnsiColors() {
|
||||
XCTAssertEqual(TerminalTheme.dark.ansiColors.count, 16,
|
||||
".dark must have exactly 16 ANSI color entries")
|
||||
}
|
||||
|
||||
func testGitHub_hasExactly16AnsiColors() {
|
||||
XCTAssertEqual(TerminalTheme.github.ansiColors.count, 16,
|
||||
".github must have exactly 16 ANSI color entries")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. ThemeColor values are in the 0–255 range
|
||||
// =========================================================================
|
||||
// ThemeColor stores UInt8 components, so they are structurally bounded to
|
||||
// 0–255. These tests make the contract explicit and guard against future
|
||||
// refactors that might widen the type.
|
||||
|
||||
func testDark_allAnsiColorComponents_inRange() {
|
||||
for (index, color) in TerminalTheme.dark.ansiColors.enumerated() {
|
||||
assertColorInRange(color, label: "dark[\(index)]")
|
||||
}
|
||||
assertColorInRange(TerminalTheme.dark.foreground, label: "dark.foreground")
|
||||
assertColorInRange(TerminalTheme.dark.background, label: "dark.background")
|
||||
assertColorInRange(TerminalTheme.dark.cursor, label: "dark.cursor")
|
||||
}
|
||||
|
||||
func testGitHub_allAnsiColorComponents_inRange() {
|
||||
for (index, color) in TerminalTheme.github.ansiColors.enumerated() {
|
||||
assertColorInRange(color, label: "github[\(index)]")
|
||||
}
|
||||
assertColorInRange(TerminalTheme.github.foreground, label: "github.foreground")
|
||||
assertColorInRange(TerminalTheme.github.background, label: "github.background")
|
||||
assertColorInRange(TerminalTheme.github.cursor, label: "github.cursor")
|
||||
}
|
||||
|
||||
private func assertColorInRange(_ color: ThemeColor, label: String) {
|
||||
// UInt8 is intrinsically 0–255; the assertions below make the expectation readable.
|
||||
XCTAssertLessThanOrEqual(color.r, 255, "\(label).r out of range")
|
||||
XCTAssertLessThanOrEqual(color.g, 255, "\(label).g out of range")
|
||||
XCTAssertLessThanOrEqual(color.b, 255, "\(label).b out of range")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. .dark and .github are distinct themes
|
||||
// =========================================================================
|
||||
|
||||
func testDarkAndGitHub_haveDistinctIds() {
|
||||
XCTAssertNotEqual(TerminalTheme.dark.id, TerminalTheme.github.id,
|
||||
"Built-in themes must have unique IDs")
|
||||
}
|
||||
|
||||
func testDarkAndGitHub_haveDistinctBackgrounds() {
|
||||
// "Dark (Hacker)" has a pure-black background; GitHub Dark does not.
|
||||
XCTAssertNotEqual(TerminalTheme.dark.background, TerminalTheme.github.background,
|
||||
"Background colors must differ between dark and github themes")
|
||||
}
|
||||
|
||||
func testDarkAndGitHub_areNotEqual() {
|
||||
XCTAssertNotEqual(TerminalTheme.dark, TerminalTheme.github,
|
||||
".dark and .github must be distinct themes")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. TerminalTheme identities
|
||||
// =========================================================================
|
||||
|
||||
func testDark_hasExpectedId() {
|
||||
XCTAssertEqual(TerminalTheme.dark.id, "dark")
|
||||
}
|
||||
|
||||
func testGitHub_hasExpectedId() {
|
||||
XCTAssertEqual(TerminalTheme.github.id, "github")
|
||||
}
|
||||
|
||||
func testDark_backgroundIsBlack() {
|
||||
let bg = TerminalTheme.dark.background
|
||||
XCTAssertEqual(bg.r, 0x00, "Dark background R must be 0x00")
|
||||
XCTAssertEqual(bg.g, 0x00, "Dark background G must be 0x00")
|
||||
XCTAssertEqual(bg.b, 0x00, "Dark background B must be 0x00")
|
||||
}
|
||||
|
||||
func testGitHub_backgroundMatchesSpec() {
|
||||
// GitHub Dark background: #0d1117
|
||||
let bg = TerminalTheme.github.background
|
||||
XCTAssertEqual(bg.r, 0x0D, "GitHub background R must be 0x0D")
|
||||
XCTAssertEqual(bg.g, 0x11, "GitHub background G must be 0x11")
|
||||
XCTAssertEqual(bg.b, 0x17, "GitHub background B must be 0x17")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. TerminalTheme.toSwiftTermColor conversion
|
||||
// =========================================================================
|
||||
|
||||
func testToSwiftTermColor_white_mapsTo65535() {
|
||||
// 255 × 257 = 65535 (= UInt16.max)
|
||||
let white = ThemeColor(r: 255, g: 255, b: 255)
|
||||
let stColor = TerminalTheme.dark.toSwiftTermColor(white)
|
||||
XCTAssertEqual(stColor.red, 65535)
|
||||
XCTAssertEqual(stColor.green, 65535)
|
||||
XCTAssertEqual(stColor.blue, 65535)
|
||||
}
|
||||
|
||||
func testToSwiftTermColor_black_mapsToZero() {
|
||||
let black = ThemeColor(r: 0, g: 0, b: 0)
|
||||
let stColor = TerminalTheme.dark.toSwiftTermColor(black)
|
||||
XCTAssertEqual(stColor.red, 0)
|
||||
XCTAssertEqual(stColor.green, 0)
|
||||
XCTAssertEqual(stColor.blue, 0)
|
||||
}
|
||||
|
||||
func testSwiftTermAnsiColors_has16Entries() {
|
||||
XCTAssertEqual(TerminalTheme.dark.swiftTermAnsiColors.count, 16)
|
||||
XCTAssertEqual(TerminalTheme.github.swiftTermAnsiColors.count, 16)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 6. ThemeStore — selection and persistence
|
||||
// =========================================================================
|
||||
|
||||
@MainActor
|
||||
func testThemeStore_available_containsBothBuiltins() {
|
||||
let store = ThemeStore.shared
|
||||
let ids = store.available.map(\.id)
|
||||
XCTAssertTrue(ids.contains("dark"), "Available themes must include 'dark'")
|
||||
XCTAssertTrue(ids.contains("github"), "Available themes must include 'github'")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testThemeStore_select_updatesCurrent() {
|
||||
let store = ThemeStore.shared
|
||||
let before = store.current
|
||||
|
||||
// Select whichever theme is NOT currently active.
|
||||
let next = store.available.first(where: { $0.id != before.id })!
|
||||
store.select(next)
|
||||
XCTAssertEqual(store.current.id, next.id,
|
||||
"select(_:) must update current immediately")
|
||||
|
||||
// Restore original selection so we don't bleed state into other tests.
|
||||
store.select(before)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 7. TerminalTheme Codable round-trip
|
||||
// =========================================================================
|
||||
|
||||
func testTerminalTheme_codable_roundTrips() throws {
|
||||
let theme = TerminalTheme.dark
|
||||
let data = try JSONEncoder().encode(theme)
|
||||
let loaded = try JSONDecoder().decode(TerminalTheme.self, from: data)
|
||||
XCTAssertEqual(loaded, theme, "TerminalTheme must survive a JSON round-trip")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue