rainbow-dragon/Tests/ForgeCameraREDTests/RedDriverTests.swift

233 lines
8.8 KiB
Swift
Raw Normal View History

import XCTest
import Foundation
import ForgeCamera
import ForgeGrade
import ForgeColor
import ForgeSim
@testable import ForgeCameraRED
/// 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).
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()
}
}