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 { 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 {} } // 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) } // 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) } }