2026-07-10 16:03:51 -04:00
|
|
|
import XCTest
|
|
|
|
|
import Foundation
|
|
|
|
|
import ForgeGrade
|
|
|
|
|
import ForgeColor
|
|
|
|
|
@testable import ForgeCamera
|
|
|
|
|
|
|
|
|
|
/// Recording driver: captures pushes, declares capabilities.
|
|
|
|
|
actor RecordingDriver: CameraDriver {
|
|
|
|
|
nonisolated let capabilities: CameraCapabilities
|
|
|
|
|
private(set) var pushedLooks = [FlattenedLook]()
|
|
|
|
|
|
|
|
|
|
init(nativeCDL: Bool, lutSizes: [Int] = [17, 33]) {
|
|
|
|
|
capabilities = CameraCapabilities(
|
|
|
|
|
vendor: .simulated, supportsNativeCDL: nativeCDL,
|
|
|
|
|
lut3dSizes: lutSizes, metadataFields: [])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nonisolated var state: AsyncStream<CameraState> {
|
|
|
|
|
AsyncStream { _ in }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func connect() async throws {}
|
|
|
|
|
func disconnect() async {}
|
|
|
|
|
func push(look: FlattenedLook) async throws {
|
|
|
|
|
pushedLooks.append(look)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class GangTests: XCTestCase {
|
|
|
|
|
|
|
|
|
|
func makeGrade() -> GradeStack {
|
|
|
|
|
GradeStack(nodes: [
|
|
|
|
|
GradeNode(kind: .cdl(CDL(slope: SIMD3(1.2, 1, 0.9), offset: .zero, power: .one, saturation: 1.1))),
|
|
|
|
|
GradeNode(kind: .saturation(0.95)),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Push to ganged slots: each camera gets a look flattened per its own capabilities.
|
|
|
|
|
func testGangPushRespectsCapabilities() async throws {
|
|
|
|
|
let arriLike = RecordingDriver(nativeCDL: true)
|
|
|
|
|
let sonyLike = RecordingDriver(nativeCDL: false)
|
|
|
|
|
|
|
|
|
|
let gang = GradeGang(latticeSize: 17)
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "A")
|
|
|
|
|
await gang.link(slots: ["A", "B"])
|
|
|
|
|
await gang.register(slot: "A", driver: arriLike)
|
|
|
|
|
await gang.register(slot: "B", driver: sonyLike)
|
|
|
|
|
|
|
|
|
|
try await gang.pushGrade(from: "A")
|
|
|
|
|
|
|
|
|
|
let arriPushes = await arriLike.pushedLooks
|
|
|
|
|
let sonyPushes = await sonyLike.pushedLooks
|
|
|
|
|
XCTAssertEqual(arriPushes.count, 1)
|
|
|
|
|
XCTAssertEqual(sonyPushes.count, 1)
|
|
|
|
|
// ARRI-like gets split CDL; Sony-like full bake, no CDL.
|
|
|
|
|
XCTAssertNotNil(arriPushes[0].cdl)
|
|
|
|
|
XCTAssertNil(sonyPushes[0].cdl)
|
|
|
|
|
XCTAssertNotNil(sonyPushes[0].lut)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ganged edit propagates grade to linked slots.
|
|
|
|
|
func testGangPropagatesGrade() async throws {
|
|
|
|
|
let gang = GradeGang(latticeSize: 17)
|
|
|
|
|
await gang.link(slots: ["A", "B", "C"])
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "A")
|
|
|
|
|
|
|
|
|
|
let gradeB = await gang.grade(for: "B")
|
|
|
|
|
let gradeC = await gang.grade(for: "C")
|
|
|
|
|
XCTAssertEqual(gradeB?.nodes.count, 2)
|
|
|
|
|
XCTAssertEqual(gradeC?.nodes.count, 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unlink stops propagation.
|
|
|
|
|
func testUnlinkStopsPropagation() async throws {
|
|
|
|
|
let gang = GradeGang(latticeSize: 17)
|
|
|
|
|
await gang.link(slots: ["A", "B"])
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "A")
|
|
|
|
|
await gang.unlink(slot: "B")
|
|
|
|
|
|
|
|
|
|
// New grade on A only.
|
|
|
|
|
let solo = GradeStack(nodes: [GradeNode(kind: .saturation(0.5))])
|
|
|
|
|
await gang.setGrade(solo, for: "A")
|
|
|
|
|
|
|
|
|
|
let gradeA = await gang.grade(for: "A")
|
|
|
|
|
let gradeB = await gang.grade(for: "B")
|
|
|
|
|
XCTAssertEqual(gradeA?.nodes.count, 1)
|
|
|
|
|
XCTAssertEqual(gradeB?.nodes.count, 2) // stays on pre-unlink grade
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Per-slot override: editing an unlinked slot never touches others.
|
|
|
|
|
func testSoloEditIsolated() async throws {
|
|
|
|
|
let gang = GradeGang(latticeSize: 17)
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "A")
|
|
|
|
|
await gang.setGrade(GradeStack(nodes: []), for: "B")
|
|
|
|
|
|
|
|
|
|
let gradeA = await gang.grade(for: "A")
|
|
|
|
|
let gradeB = await gang.grade(for: "B")
|
|
|
|
|
XCTAssertEqual(gradeA?.nodes.count, 2)
|
|
|
|
|
XCTAssertEqual(gradeB?.nodes.count, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Push from an unregistered slot throws.
|
|
|
|
|
func testPushUnregisteredThrows() async {
|
|
|
|
|
let gang = GradeGang(latticeSize: 17)
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "X")
|
|
|
|
|
do {
|
|
|
|
|
try await gang.pushGrade(from: "X")
|
|
|
|
|
XCTFail("expected throw")
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:33:26 -04:00
|
|
|
// CDL-only camera (RED/RCP2, lut3dSizes empty): CDL-only grade pushes as bare CDL.
|
|
|
|
|
func testCDLOnlyCameraPushesCDLOnlyGrade() async throws {
|
|
|
|
|
let red = RecordingDriver(nativeCDL: true, lutSizes: [])
|
|
|
|
|
let gang = GradeGang(latticeSize: 33)
|
|
|
|
|
// Grade = single CDL node — representable on a CDL-only camera.
|
|
|
|
|
let grade = GradeStack(nodes: [
|
|
|
|
|
GradeNode(kind: .cdl(CDL(slope: SIMD3(1.3, 1, 1), offset: .zero, power: .one, saturation: 1.1))),
|
|
|
|
|
])
|
|
|
|
|
await gang.setGrade(grade, for: "R")
|
|
|
|
|
await gang.register(slot: "R", driver: red)
|
|
|
|
|
try await gang.pushGrade(from: "R")
|
|
|
|
|
|
|
|
|
|
let pushes = await red.pushedLooks
|
|
|
|
|
XCTAssertEqual(pushes.count, 1)
|
|
|
|
|
XCTAssertNotNil(pushes[0].cdl)
|
|
|
|
|
XCTAssertNil(pushes[0].lut)
|
|
|
|
|
XCTAssertEqual(pushes[0].cdl?.slope.x ?? 0, 1.3, accuracy: 1e-5)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CDL-only camera + grade with non-CDL nodes: hard error (camera can't show it).
|
|
|
|
|
func testCDLOnlyCameraRejectsComplexGrade() async throws {
|
|
|
|
|
let red = RecordingDriver(nativeCDL: true, lutSizes: [])
|
|
|
|
|
let gang = GradeGang(latticeSize: 33)
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "R") // CDL + saturation node
|
|
|
|
|
await gang.register(slot: "R", driver: red)
|
|
|
|
|
do {
|
|
|
|
|
try await gang.pushGrade(from: "R")
|
|
|
|
|
XCTFail("expected partial failure")
|
|
|
|
|
} catch let e as GangError {
|
|
|
|
|
guard case .partialFailure(let failures) = e else { return XCTFail("wrong error") }
|
|
|
|
|
XCTAssertEqual(failures.count, 1)
|
|
|
|
|
}
|
|
|
|
|
let pushes = await red.pushedLooks
|
|
|
|
|
XCTAssertTrue(pushes.isEmpty)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 16:03:51 -04:00
|
|
|
// Push failure on one camera doesn't block others (best-effort multi-push).
|
|
|
|
|
func testPushPartialFailure() async throws {
|
|
|
|
|
let good = RecordingDriver(nativeCDL: true)
|
|
|
|
|
let bad = RecordingDriver(nativeCDL: false, lutSizes: [99]) // rejects 17
|
|
|
|
|
|
|
|
|
|
let gang = GradeGang(latticeSize: 17)
|
|
|
|
|
await gang.link(slots: ["A", "B"])
|
|
|
|
|
await gang.setGrade(makeGrade(), for: "A")
|
|
|
|
|
await gang.register(slot: "A", driver: good)
|
|
|
|
|
await gang.register(slot: "B", driver: bad)
|
|
|
|
|
|
|
|
|
|
// bad driver: flatten will target unsupported size -> per-slot error collected.
|
|
|
|
|
do {
|
|
|
|
|
try await gang.pushGrade(from: "A")
|
|
|
|
|
XCTFail("expected partial failure to throw summary")
|
|
|
|
|
} catch let e as GangError {
|
|
|
|
|
guard case .partialFailure(let failures) = e else { return XCTFail("wrong error") }
|
|
|
|
|
XCTAssertEqual(failures.count, 1)
|
|
|
|
|
XCTAssertEqual(failures[0].slot, "B")
|
|
|
|
|
}
|
|
|
|
|
// Good camera still got its push.
|
|
|
|
|
let pushes = await good.pushedLooks
|
|
|
|
|
XCTAssertEqual(pushes.count, 1)
|
|
|
|
|
}
|
|
|
|
|
}
|