import XCTest import Foundation import ForgeCamera import ForgeGrade import ForgeColor import ForgeSim @testable import ForgeCameraRED /// Tests against verified RCP2 wire protocol: /// WebSocket ws://:9998/rcp, rcp_config handshake, rcp_get/rcp_set, /// responses {"id":..., "cur":{"val":...}}, per-channel CDL params, /// no live LUT upload (docs/research/red-rcp2-protocol.md). final class RedDriverTests: XCTestCase { var sim: RedSimulator! override func setUp() async throws { sim = RedSimulator() try await sim.start(port: 0) } override func tearDown() async throws { try? await sim.stop() sim = nil } func makeDriver(pollInterval: Duration = .milliseconds(20)) async -> RedDriver { let port = await sim.boundPort! return RedDriver(host: "127.0.0.1", port: port, pollInterval: pollInterval) } // RED: native CDL yes, live LUT upload NO (RCP2 has no LUT-push op). func testCapabilities() async { let driver = await makeDriver() XCTAssertEqual(driver.capabilities.vendor, .red) XCTAssertTrue(driver.capabilities.supportsNativeCDL) XCTAssertTrue(driver.capabilities.lut3dSizes.isEmpty) } // Connect: WS upgrade on /rcp + rcp_config handshake with client name. func testConnectSendsRcpConfig() async throws { let driver = await makeDriver() var iterator = driver.state.makeAsyncIterator() try await driver.connect() var connected = false for _ in 0..<5 { if let s = await iterator.next(), s.connection == .connected { connected = true break } } XCTAssertTrue(connected) // Config delivery is async post-upgrade; allow brief settle. var configsData: [Data] = [] for _ in 0..<20 { configsData = await sim.receivedConfigsData if !configsData.isEmpty { break } try await Task.sleep(for: .milliseconds(25)) } XCTAssertEqual(configsData.count, 1) guard let first = configsData.first else { await driver.disconnect() return } let config = try JSONSerialization.jsonObject(with: first) as? [String: Any] XCTAssertEqual(config?["type"] as? String, "rcp_config") let client = config?["client"] as? [String: Any] XCTAssertEqual(client?["name"] as? String, "Forge") await driver.disconnect() } func testConnectFailsOnDeadPort() async { let driver = RedDriver(host: "127.0.0.1", port: 1, pollInterval: .milliseconds(20)) do { try await driver.connect() XCTFail("expected throw") } catch {} } // Rec state: RECORD_STATE cur.val 1 -> recording, clip name via CLIP_NAME. func testRecStateEvents() async throws { let driver = await makeDriver() var iterator = driver.state.makeAsyncIterator() try await driver.connect() await sim.setParam("RECORD_STATE", value: .int(1)) await sim.setParam("CLIP_NAME", value: .string("B001_C001_0710AB")) // Params arrive one frame at a time — wait for a state with BOTH rec + clip. var sawRec = false for _ in 0..<60 { if let s = await iterator.next(), s.isRecording, s.clipName == "B001_C001_0710AB" { sawRec = true break } } XCTAssertTrue(sawRec) await sim.setParam("RECORD_STATE", value: .int(0)) var sawStop = false for _ in 0..<30 { if let s = await iterator.next(), !s.isRecording { sawStop = true break } } XCTAssertTrue(sawStop) await driver.disconnect() } // Metadata via real param names: ISO, COLOR_TEMPERATURE, TINT, SENSOR_FRAME_RATE, TIMECODE. func testMetadata() async throws { await sim.setParam("ISO", value: .int(1600)) await sim.setParam("COLOR_TEMPERATURE", value: .int(4500)) await sim.setParam("TINT", value: .int(3)) await sim.setParam("SENSOR_FRAME_RATE", value: .string("23.98")) await sim.setParam("TIMECODE", value: .string("15:30:00:00")) let driver = await makeDriver() var iterator = driver.state.makeAsyncIterator() try await driver.connect() // Wait for a state where ALL polled params have landed. var got = false for _ in 0..<60 { if let s = await iterator.next(), let md = s.metadata, md.exposureIndex == 1600, md.whiteBalance == 4500, md.tint == 3, md.timecode == "15:30:00:00", let fps = md.fps, abs(fps - 23.98) < 0.001 { got = true break } } XCTAssertTrue(got) await driver.disconnect() } // CDL push: 10 rcp_set ops (9 SOP channels + saturation) + CDL_ENABLE. func testPushCDLSetsPerChannelParams() async throws { let driver = await makeDriver() try await driver.connect() let cdl = CDL( slope: SIMD3(1.1, 1.0, 0.9), offset: SIMD3(0.02, 0.0, -0.01), power: SIMD3(0.95, 1.0, 1.05), saturation: 1.3) try await driver.push(look: FlattenedLook(cdl: cdl, lut: nil, latticeSize: 33)) // rcp_set frames are fire-and-forget; wait for sim to process all 11. var params: [String: RedSimulator.ParamValue] = [:] for _ in 0..<40 { params = await sim.params if params["CDL_SATURATION"] != nil, case .int(1)? = params["CDL_ENABLE"] { break } try await Task.sleep(for: .milliseconds(25)) } func f(_ key: String) -> Float? { if case .double(let d)? = params[key] { return Float(d) } if case .int(let i)? = params[key] { return Float(i) } return nil } XCTAssertEqual(f("CDL_SLOPE_RED") ?? 0, 1.1, accuracy: 1e-4) XCTAssertEqual(f("CDL_SLOPE_GREEN") ?? 0, 1.0, accuracy: 1e-4) XCTAssertEqual(f("CDL_SLOPE_BLUE") ?? 0, 0.9, accuracy: 1e-4) XCTAssertEqual(f("CDL_OFFSET_RED") ?? 0, 0.02, accuracy: 1e-4) XCTAssertEqual(f("CDL_OFFSET_BLUE") ?? 0, -0.01, accuracy: 1e-4) XCTAssertEqual(f("CDL_POWER_RED") ?? 0, 0.95, accuracy: 1e-4) XCTAssertEqual(f("CDL_POWER_BLUE") ?? 0, 1.05, accuracy: 1e-4) XCTAssertEqual(f("CDL_SATURATION") ?? 0, 1.3, accuracy: 1e-4) if case .int(let enabled)? = params["CDL_ENABLE"] { XCTAssertEqual(enabled, 1) } else { XCTFail("CDL_ENABLE not set") } await driver.disconnect() } // LUT in look -> rejected: RCP2 cannot upload LUTs live. func testPushWithLutRejected() async throws { let driver = await makeDriver() try await driver.connect() let look = FlattenedLook(cdl: .identity, lut: Lut3D.build(size: 17) { $0 * 0.9 }, latticeSize: 17) do { try await driver.push(look: look) XCTFail("expected unsupportedLook") } catch let e as CameraError { guard case .unsupportedLook = e else { return XCTFail("wrong error") } } await driver.disconnect() } // Identity LUT in look tolerated (flattener artifact), CDL still pushed. func testPushWithIdentityLutAccepted() async throws { let driver = await makeDriver() try await driver.connect() let cdl = CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1) let look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 17), latticeSize: 17) try await driver.push(look: look) var pushed = false for _ in 0..<40 { let params = await sim.params if case .double(let d)? = params["CDL_SLOPE_RED"], abs(Float(d) - 1.2) < 1e-4 { pushed = true break } try await Task.sleep(for: .milliseconds(25)) } XCTAssertTrue(pushed, "CDL not pushed") await driver.disconnect() } // Unsolicited camera notification (rec start w/o poll) surfaces in state stream. func testUnsolicitedNotification() async throws { let driver = await makeDriver(pollInterval: .seconds(10)) // poll effectively off var iterator = driver.state.makeAsyncIterator() try await driver.connect() // Drain the connected event. _ = await iterator.next() await sim.pushNotification(param: "RECORD_STATE", value: .int(1)) await sim.pushNotification(param: "CLIP_NAME", value: .string("B001_C009")) var sawRec = false for _ in 0..<10 { if let s = await iterator.next(), s.isRecording { sawRec = true break } } XCTAssertTrue(sawRec) await driver.disconnect() } }