2026-07-10 16:00:36 -04:00
|
|
|
import XCTest
|
|
|
|
|
import Foundation
|
|
|
|
|
import ForgeCamera
|
|
|
|
|
import ForgeGrade
|
|
|
|
|
import ForgeColor
|
|
|
|
|
import ForgeSim
|
|
|
|
|
@testable import ForgeCameraRED
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
/// Tests against verified RCP2 wire protocol:
|
|
|
|
|
/// WebSocket ws://<ip>: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).
|
2026-07-10 16:00:36 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// RED: native CDL yes, live LUT upload NO (RCP2 has no LUT-push op).
|
2026-07-10 16:00:36 -04:00
|
|
|
func testCapabilities() async {
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
XCTAssertEqual(driver.capabilities.vendor, .red)
|
|
|
|
|
XCTAssertTrue(driver.capabilities.supportsNativeCDL)
|
2026-07-11 00:33:26 -04:00
|
|
|
XCTAssertTrue(driver.capabilities.lut3dSizes.isEmpty)
|
2026-07-10 16:00:36 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// Connect: WS upgrade on /rcp + rcp_config handshake with client name.
|
|
|
|
|
func testConnectSendsRcpConfig() async throws {
|
2026-07-10 16:00:36 -04:00
|
|
|
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)
|
2026-07-11 00:33:26 -04:00
|
|
|
// 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")
|
2026-07-10 16:00:36 -04:00
|
|
|
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 {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// Rec state: RECORD_STATE cur.val 1 -> recording, clip name via CLIP_NAME.
|
2026-07-10 16:00:36 -04:00
|
|
|
func testRecStateEvents() async throws {
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
var iterator = driver.state.makeAsyncIterator()
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
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.
|
2026-07-10 16:00:36 -04:00
|
|
|
var sawRec = false
|
2026-07-11 00:33:26 -04:00
|
|
|
for _ in 0..<60 {
|
|
|
|
|
if let s = await iterator.next(), s.isRecording, s.clipName == "B001_C001_0710AB" {
|
2026-07-10 16:00:36 -04:00
|
|
|
sawRec = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
XCTAssertTrue(sawRec)
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
await sim.setParam("RECORD_STATE", value: .int(0))
|
2026-07-10 16:00:36 -04:00
|
|
|
var sawStop = false
|
|
|
|
|
for _ in 0..<30 {
|
|
|
|
|
if let s = await iterator.next(), !s.isRecording {
|
|
|
|
|
sawStop = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
XCTAssertTrue(sawStop)
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// Metadata via real param names: ISO, COLOR_TEMPERATURE, TINT, SENSOR_FRAME_RATE, TIMECODE.
|
2026-07-10 16:00:36 -04:00
|
|
|
func testMetadata() async throws {
|
2026-07-11 00:33:26 -04:00
|
|
|
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"))
|
2026-07-10 16:00:36 -04:00
|
|
|
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
var iterator = driver.state.makeAsyncIterator()
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// Wait for a state where ALL polled params have landed.
|
2026-07-10 16:00:36 -04:00
|
|
|
var got = false
|
2026-07-11 00:33:26 -04:00
|
|
|
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 {
|
2026-07-10 16:00:36 -04:00
|
|
|
got = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
XCTAssertTrue(got)
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// CDL push: 10 rcp_set ops (9 SOP channels + saturation) + CDL_ENABLE.
|
|
|
|
|
func testPushCDLSetsPerChannelParams() async throws {
|
2026-07-10 16:00:36 -04:00
|
|
|
let driver = await makeDriver()
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
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")
|
|
|
|
|
}
|
2026-07-10 16:00:36 -04:00
|
|
|
await driver.disconnect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// LUT in look -> rejected: RCP2 cannot upload LUTs live.
|
|
|
|
|
func testPushWithLutRejected() async throws {
|
2026-07-10 16:00:36 -04:00
|
|
|
let driver = await makeDriver()
|
|
|
|
|
try await driver.connect()
|
2026-07-11 00:33:26 -04:00
|
|
|
let look = FlattenedLook(cdl: .identity, lut: Lut3D.build(size: 17) { $0 * 0.9 }, latticeSize: 17)
|
2026-07-10 16:00:36 -04:00
|
|
|
do {
|
2026-07-11 00:33:26 -04:00
|
|
|
try await driver.push(look: look)
|
2026-07-10 16:00:36 -04:00
|
|
|
XCTFail("expected unsupportedLook")
|
|
|
|
|
} catch let e as CameraError {
|
|
|
|
|
guard case .unsupportedLook = e else { return XCTFail("wrong error") }
|
|
|
|
|
}
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
}
|
2026-07-11 00:33:26 -04:00
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
}
|
2026-07-10 16:00:36 -04:00
|
|
|
}
|