import XCTest @testable import ForgeColor final class CDLTests: XCTestCase { // Identity CDL passes pixels through untouched. func testIdentityPassthrough() { let cdl = CDL.identity let px = SIMD3(0.18, 0.5, 0.9) let out = cdl.apply(px) XCTAssertEqual(out.x, 0.18, accuracy: 1e-6) XCTAssertEqual(out.y, 0.5, accuracy: 1e-6) XCTAssertEqual(out.z, 0.9, accuracy: 1e-6) } // Hand-computed SOP vector. // in=0.5, slope=2, offset=0.1, power=1 -> 0.5*2+0.1 = 1.1 func testSlopeOffset() { let cdl = CDL( slope: SIMD3(2, 2, 2), offset: SIMD3(0.1, 0.1, 0.1), power: SIMD3(1, 1, 1), saturation: 1) let out = cdl.apply(SIMD3(0.5, 0.5, 0.5)) XCTAssertEqual(out.x, 1.1, accuracy: 1e-6) XCTAssertEqual(out.y, 1.1, accuracy: 1e-6) XCTAssertEqual(out.z, 1.1, accuracy: 1e-6) } // Power applies after slope+offset: (0.25*1+0)^0.5 = 0.5 func testPower() { let cdl = CDL( slope: .one, offset: .zero, power: SIMD3(0.5, 0.5, 0.5), saturation: 1) let out = cdl.apply(SIMD3(0.25, 0.25, 0.25)) XCTAssertEqual(out.x, 0.5, accuracy: 1e-6) } // Per-channel independence. func testPerChannel() { let cdl = CDL( slope: SIMD3(1, 2, 0.5), offset: SIMD3(0, 0.1, -0.05), power: SIMD3(1, 1, 2), saturation: 1) let out = cdl.apply(SIMD3(0.4, 0.4, 0.4)) XCTAssertEqual(out.x, 0.4, accuracy: 1e-6) // 0.4*1+0 XCTAssertEqual(out.y, 0.9, accuracy: 1e-6) // 0.4*2+0.1 XCTAssertEqual(out.z, pow(0.4 * 0.5 - 0.05, 2), accuracy: 1e-6) // (0.15)^2 } // ASC: value after SOP clamped to >= 0 before power (avoid NaN on negatives). func testNegativePrePowerClamp() { let cdl = CDL( slope: .one, offset: SIMD3(-1, -1, -1), power: SIMD3(0.5, 0.5, 0.5), saturation: 1) let out = cdl.apply(SIMD3(0.5, 0.5, 0.5)) // 0.5-1 = -0.5 -> clamp 0 -> 0^0.5 = 0 XCTAssertEqual(out.x, 0, accuracy: 1e-6) XCTAssertFalse(out.x.isNaN) } // Power=1 keeps negatives (linear passthrough, no clamp needed) per common practice: // clamp only applies to the pow() branch. Spec-conformant tools clamp pre-power always; // we clamp always for determinism. func testClampAlwaysPrePower() { let cdl = CDL(slope: .one, offset: SIMD3(-1, -1, -1), power: .one, saturation: 1) let out = cdl.apply(SIMD3(0.5, 0.5, 0.5)) XCTAssertEqual(out.x, 0, accuracy: 1e-6) } // Saturation 0 -> pure luma gray using Rec709 weights (0.2126, 0.7152, 0.0722). func testSaturationZeroGivesLumaGray() { let cdl = CDL(slope: .one, offset: .zero, power: .one, saturation: 0) let px = SIMD3(1, 0, 0) let out = cdl.apply(px) let luma: Float = 0.2126 XCTAssertEqual(out.x, luma, accuracy: 1e-6) XCTAssertEqual(out.y, luma, accuracy: 1e-6) XCTAssertEqual(out.z, luma, accuracy: 1e-6) } // Saturation 1 is no-op on chroma. func testSaturationOneNoOp() { let cdl = CDL(slope: .one, offset: .zero, power: .one, saturation: 1) let px = SIMD3(0.7, 0.2, 0.4) let out = cdl.apply(px) XCTAssertEqual(out.x, 0.7, accuracy: 1e-6) XCTAssertEqual(out.y, 0.2, accuracy: 1e-6) XCTAssertEqual(out.z, 0.4, accuracy: 1e-6) } // Saturation 2 doubles distance from luma: out = luma + 2*(in - luma) func testSaturationBoost() { let cdl = CDL(slope: .one, offset: .zero, power: .one, saturation: 2) let px = SIMD3(0.6, 0.3, 0.3) let luma: Float = 0.2126 * 0.6 + 0.7152 * 0.3 + 0.0722 * 0.3 let out = cdl.apply(px) XCTAssertEqual(out.x, luma + 2 * (0.6 - luma), accuracy: 1e-5) XCTAssertEqual(out.y, luma + 2 * (0.3 - luma), accuracy: 1e-5) } // Identity constant is actually identity params. func testIdentityConstants() { XCTAssertEqual(CDL.identity.slope, SIMD3(1, 1, 1)) XCTAssertEqual(CDL.identity.offset, SIMD3(0, 0, 0)) XCTAssertEqual(CDL.identity.power, SIMD3(1, 1, 1)) XCTAssertEqual(CDL.identity.saturation, 1) XCTAssertTrue(CDL.identity.isIdentity) XCTAssertFalse(CDL(slope: SIMD3(1.1, 1, 1), offset: .zero, power: .one, saturation: 1).isIdentity) } }