2026-07-10 15:30:22 -04:00
|
|
|
|
import XCTest
|
2026-07-11 10:02:56 -04:00
|
|
|
|
import Foundation
|
2026-07-10 15:30:22 -04:00
|
|
|
|
import ForgeCamera
|
|
|
|
|
|
import ForgeGrade
|
|
|
|
|
|
import ForgeColor
|
|
|
|
|
|
import ForgeSim
|
|
|
|
|
|
@testable import ForgeCameraARRI
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
/// Tests against CAP-faithful simulator (binary TCP, MD5 challenge auth,
|
|
|
|
|
|
/// variable subscription — docs/research/arri-cap-protocol.md).
|
2026-07-10 15:30:22 -04:00
|
|
|
|
final class ArriDriverTests: XCTestCase {
|
|
|
|
|
|
|
|
|
|
|
|
var sim: ArriSimulator!
|
|
|
|
|
|
|
|
|
|
|
|
override func setUp() async throws {
|
2026-07-11 10:02:56 -04:00
|
|
|
|
sim = ArriSimulator(password: "arri")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
try await sim.start(port: 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override func tearDown() async throws {
|
|
|
|
|
|
try? await sim.stop()
|
|
|
|
|
|
sim = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
func makeDriver(password: String = "arri") async -> ArriDriver {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
let port = await sim.boundPort!
|
2026-07-11 10:02:56 -04:00
|
|
|
|
return ArriDriver(host: "127.0.0.1", port: port, password: password)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func testCapabilities() async {
|
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
|
XCTAssertEqual(driver.capabilities.vendor, .arri)
|
|
|
|
|
|
XCTAssertTrue(driver.capabilities.supportsNativeCDL)
|
|
|
|
|
|
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Full handshake: challenge -> MD5 password -> client name -> subscribed + connected.
|
|
|
|
|
|
func testConnectHandshake() async throws {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
|
var iterator = driver.state.makeAsyncIterator()
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
var connected = false
|
2026-07-11 10:02:56 -04:00
|
|
|
|
for _ in 0..<10 {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
if let s = await iterator.next(), s.connection == .connected {
|
|
|
|
|
|
connected = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
XCTAssertTrue(connected)
|
2026-07-11 10:02:56 -04:00
|
|
|
|
let names = await sim.clientNames
|
|
|
|
|
|
XCTAssertEqual(names, ["Forge"])
|
2026-07-10 15:30:22 -04:00
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Wrong password -> connectionFailed, never authenticated.
|
|
|
|
|
|
func testWrongPasswordRejected() async throws {
|
|
|
|
|
|
let driver = await makeDriver(password: "wrong")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
do {
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
XCTFail("expected throw")
|
2026-07-11 10:02:56 -04:00
|
|
|
|
} catch let e as CameraError {
|
|
|
|
|
|
guard case .connectionFailed(let msg) = e else { return XCTFail("wrong error") }
|
|
|
|
|
|
XCTAssertTrue(msg.contains("password"))
|
|
|
|
|
|
}
|
|
|
|
|
|
let names = await sim.clientNames
|
|
|
|
|
|
XCTAssertTrue(names.isEmpty)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
func testConnectFailsOnDeadPort() async {
|
|
|
|
|
|
let driver = ArriDriver(host: "127.0.0.1", port: 1)
|
|
|
|
|
|
do {
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
XCTFail("expected throw")
|
|
|
|
|
|
} catch {}
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Subscription events: rec start/stop with composed clip name RxxxCxxx.
|
2026-07-10 15:30:22 -04:00
|
|
|
|
func testRecStateEvents() async throws {
|
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
|
var iterator = driver.state.makeAsyncIterator()
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
await sim.setRecording(true, clipNumber: 7)
|
|
|
|
|
|
// Events arrive per-variable; wait for a state with BOTH rec + clip.
|
2026-07-10 15:30:22 -04:00
|
|
|
|
var sawRec = false
|
2026-07-11 10:02:56 -04:00
|
|
|
|
for _ in 0..<40 {
|
|
|
|
|
|
if let s = await iterator.next(), s.isRecording, s.clipName == "R001C007" {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
sawRec = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
XCTAssertTrue(sawRec)
|
|
|
|
|
|
|
|
|
|
|
|
await sim.setRecording(false)
|
|
|
|
|
|
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 10:02:56 -04:00
|
|
|
|
// Metadata via subscribed variables incl. BCD timecode decode.
|
|
|
|
|
|
func testMetadataEvents() async throws {
|
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
|
var iterator = driver.state.makeAsyncIterator()
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
|
|
|
|
|
|
await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "10:20:30:12")
|
|
|
|
|
|
var got = false
|
|
|
|
|
|
for _ in 0..<40 {
|
|
|
|
|
|
if let s = await iterator.next(), let md = s.metadata,
|
|
|
|
|
|
md.exposureIndex == 1280, md.whiteBalance == 3200,
|
|
|
|
|
|
md.tint == -2, md.timecode == "10:20:30:12",
|
|
|
|
|
|
let fps = md.fps, abs(fps - 25) < 0.01 {
|
|
|
|
|
|
got = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
XCTAssertTrue(got)
|
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CDL push -> SetVariable 0x0050 blob lands in sim with correct 10 floats.
|
|
|
|
|
|
func testPushCDL() 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))
|
|
|
|
|
|
|
|
|
|
|
|
let blob = await sim.lastCDL
|
|
|
|
|
|
XCTAssertNotNil(blob)
|
|
|
|
|
|
XCTAssertEqual(blob![0], 1.1, accuracy: 1e-5) // slope R
|
|
|
|
|
|
XCTAssertEqual(blob![2], 0.9, accuracy: 1e-5) // slope B
|
|
|
|
|
|
XCTAssertEqual(blob![3], 0.02, accuracy: 1e-5) // offset R
|
|
|
|
|
|
XCTAssertEqual(blob![5], -0.01, accuracy: 1e-5)
|
|
|
|
|
|
XCTAssertEqual(blob![6], 0.95, accuracy: 1e-5) // power R
|
|
|
|
|
|
XCTAssertEqual(blob![9], 1.3, accuracy: 1e-5) // sat
|
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CDL + LUT push: both land (LUT path experimental but sim accepts).
|
|
|
|
|
|
func testPushCDLAndLut() async throws {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
let look = FlattenedLook(
|
|
|
|
|
|
cdl: CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1),
|
|
|
|
|
|
lut: Lut3D.build(size: 17) { $0 * 0.9 },
|
|
|
|
|
|
latticeSize: 17)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
try await driver.push(look: look)
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
let cdl = await sim.lastCDL
|
|
|
|
|
|
XCTAssertEqual(cdl?[0] ?? 0, 1.2, accuracy: 1e-5)
|
|
|
|
|
|
let luts = await sim.lutUploads
|
|
|
|
|
|
XCTAssertEqual(luts.count, 1)
|
|
|
|
|
|
// U16 size prefix + 17³×3 floats.
|
|
|
|
|
|
XCTAssertEqual(luts[0].count, 2 + 17 * 17 * 17 * 3 * 4)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Unsupported LUT size rejected before wire.
|
2026-07-10 15:30:22 -04:00
|
|
|
|
func testUnsupportedLutSizeRejected() async throws {
|
|
|
|
|
|
let driver = await makeDriver()
|
|
|
|
|
|
try await driver.connect()
|
2026-07-11 10:02:56 -04:00
|
|
|
|
let look = FlattenedLook(cdl: nil, lut: Lut3D.build(size: 5) { $0 * 0.5 }, latticeSize: 5)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
do {
|
|
|
|
|
|
try await driver.push(look: look)
|
|
|
|
|
|
XCTFail("expected unsupportedLook")
|
2026-07-11 10:02:56 -04:00
|
|
|
|
} catch let e as CameraError {
|
|
|
|
|
|
guard case .unsupportedLook = e else { return XCTFail("wrong error") }
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Reconnect after sim restart on same port.
|
2026-07-10 15:30:22 -04:00
|
|
|
|
func testReconnectAfterSimRestart() async throws {
|
|
|
|
|
|
let port = await sim.boundPort!
|
2026-07-11 10:02:56 -04:00
|
|
|
|
let driver = ArriDriver(host: "127.0.0.1", port: port)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
|
|
|
|
|
|
try await sim.stop()
|
2026-07-11 10:02:56 -04:00
|
|
|
|
sim = ArriSimulator(password: "arri")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
try await sim.start(port: port)
|
|
|
|
|
|
|
|
|
|
|
|
try await driver.connect()
|
2026-07-11 10:02:56 -04:00
|
|
|
|
try await driver.push(look: FlattenedLook(
|
|
|
|
|
|
cdl: .identity, lut: nil, latticeSize: 33))
|
|
|
|
|
|
let blob = await sim.lastCDL
|
|
|
|
|
|
XCTAssertNotNil(blob)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
await driver.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|