import XCTest import ForgeColor @testable import ForgeGrade final class GradeStackTests: XCTestCase { // Empty stack = identity. func testEmptyStackIdentity() { let stack = GradeStack(nodes: []) let px = SIMD3(0.3, 0.5, 0.7) XCTAssertEqual(stack.evaluate(px), px) } // Single CDL node applies CDL math. func testSingleCDLNode() { let cdl = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) let stack = GradeStack(nodes: [GradeNode(kind: .cdl(cdl))]) let out = stack.evaluate(SIMD3(0.25, 0.25, 0.25)) XCTAssertEqual(out.x, 0.5, accuracy: 1e-6) } // Ordering matters: CDL(x2) then LUT(half) != LUT(half) then CDL(x2) on clamping LUT. func testOrderingMatters() { let double = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) // LUT clamps at 1: input 0.8 doubled -> 1.6 -> LUT sample clamps to 1 -> 0.5 let halfLut = Lut3D.build(size: 17) { $0 * 0.5 } let cdlFirst = GradeStack(nodes: [ GradeNode(kind: .cdl(double)), GradeNode(kind: .lut3d(halfLut)), ]) let lutFirst = GradeStack(nodes: [ GradeNode(kind: .lut3d(halfLut)), GradeNode(kind: .cdl(double)), ]) let px = SIMD3(0.8, 0.8, 0.8) let a = cdlFirst.evaluate(px) // 0.8*2=1.6 clamp-> lut(1.0)=0.5 let b = lutFirst.evaluate(px) // lut(0.8)=0.4 -> *2 = 0.8 XCTAssertEqual(a.x, 0.5, accuracy: 1e-4) XCTAssertEqual(b.x, 0.8, accuracy: 1e-4) } // Bypassed node skipped. func testBypassSkipsNode() { let double = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) var node = GradeNode(kind: .cdl(double)) node.isEnabled = false let stack = GradeStack(nodes: [node]) let px = SIMD3(0.25, 0.25, 0.25) XCTAssertEqual(stack.evaluate(px), px) } // Saturation node. func testSaturationNode() { let stack = GradeStack(nodes: [GradeNode(kind: .saturation(0))]) let out = stack.evaluate(SIMD3(1, 0, 0)) XCTAssertEqual(out.x, 0.2126, accuracy: 1e-5) XCTAssertEqual(out.y, 0.2126, accuracy: 1e-5) } // Curves node. func testCurvesNode() { var set = CurveSet.identity set.master = Curve(points: [.init(x: 0, y: 0), .init(x: 1, y: 0.5)]) let stack = GradeStack(nodes: [GradeNode(kind: .curves(set))]) let out = stack.evaluate(SIMD3(1, 1, 1)) XCTAssertEqual(out.x, 0.5, accuracy: 1e-5) } // Input transform node decodes camera log to linear Rec709. func testInputTransformNode() { let cs = ColorSpace.arriLogC4AWG4 let encodedGray = SIMD3(repeating: cs.transfer.encode(0.18)) let stack = GradeStack(nodes: [GradeNode(kind: .inputTransform(.arriLogC4AWG4))]) let out = stack.evaluate(encodedGray) XCTAssertEqual(out.x, 0.18, accuracy: 0.002) } // Output transform node: linear -> Rec709 display (2.4 gamma). func testOutputTransformNode() { let stack = GradeStack(nodes: [GradeNode(kind: .outputTransform(.rec709Display))]) let out = stack.evaluate(SIMD3(0.18, 0.18, 0.18)) XCTAssertEqual(out.x, pow(0.18, 1 / 2.4), accuracy: 1e-4) } // Full stack on known vector: input LogC4 -> CDL gain -> output 2.4 gamma. func testFullStackKnownVector() { let cs = ColorSpace.arriLogC4AWG4 let gain = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) let stack = GradeStack(nodes: [ GradeNode(kind: .inputTransform(.arriLogC4AWG4)), GradeNode(kind: .cdl(gain)), GradeNode(kind: .outputTransform(.rec709Display)), ]) let encodedGray = SIMD3(repeating: cs.transfer.encode(0.18)) let out = stack.evaluate(encodedGray) let expected = pow(0.36, 1 / 2.4) // 0.18 * 2 -> display gamma XCTAssertEqual(out.x, Float(expected), accuracy: 0.003) } // Node IDs unique + stable. func testNodeIDsUnique() { let a = GradeNode(kind: .saturation(1)) let b = GradeNode(kind: .saturation(1)) XCTAssertNotEqual(a.id, b.id) } }