//! Golden tests: every case in `tests/golden/*.json` was produced by the //! Python reference (`tools/golden.py` -> HPCS `tm30.py` -> colour-science //! 0.4.7). Every output field is compared; NaN (encoded as `null`) must //! match NaN. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde_json::Value; /// Target agreement: relative difference (|a-b| / max(|a|,|b|)). const REL_TOL: f64 = 1e-9; /// Absolute floor for quantities that are naturally ~0 (Duv of Planckian /// sources, hue / chroma shifts close to zero), where a relative measure is /// meaningless. Far below any displayed precision. const ABS_TOL: f64 = 1e-11; fn golden_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("golden") } fn golden_files() -> Vec { let mut files: Vec = std::fs::read_dir(golden_dir()) .expect("tests/golden exists") .map(|e| e.unwrap().path()) .filter(|p| p.extension().is_some_and(|e| e == "json")) .collect(); files.sort(); files } fn num(v: &Value) -> f64 { match v { Value::Null => f64::NAN, Value::Number(n) => n.as_f64().unwrap(), other => panic!("expected number or null, got {other}"), } } fn nums(v: &Value) -> Vec { v.as_array().unwrap().iter().map(num).collect() } #[derive(Default, Clone, Copy)] struct Dev { max_abs: f64, max_rel: f64, /// Worst `min(rel / REL_TOL, abs / ABS_TOL)` (must stay <= 1). worst: f64, n: usize, } struct Checker { devs: BTreeMap<&'static str, Dev>, failures: Vec, } impl Checker { fn check(&mut self, case: &str, field: &'static str, expected: f64, got: f64) { let dev = self.devs.entry(field).or_default(); dev.n += 1; if expected.is_nan() || got.is_nan() { if expected.is_nan() != got.is_nan() { self.failures .push(format!("{case}: {field}: expected {expected}, got {got}")); } return; } let abs = (expected - got).abs(); let scale = expected.abs().max(got.abs()); let rel = if scale > 0.0 { abs / scale } else { 0.0 }; dev.max_abs = dev.max_abs.max(abs); dev.max_rel = dev.max_rel.max(rel); let score = (rel / REL_TOL).min(abs / ABS_TOL); dev.worst = dev.worst.max(score); if score > 1.0 { self.failures.push(format!( "{case}: {field}: expected {expected:e}, got {got:e} (abs {abs:e}, rel {rel:e})" )); } } } #[test] fn golden_cases_match_python() { let files = golden_files(); assert!( files.len() >= 40, "expected the golden set, found {}", files.len() ); let mut checker = Checker { devs: BTreeMap::new(), failures: Vec::new(), }; for path in &files { let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); let case = doc["name"].as_str().unwrap().to_owned(); let wl = nums(&doc["wavelengths"]); let values = nums(&doc["values"]); let exp = &doc["expected"]; let got = tm30::compute(&wl, &values).unwrap_or_else(|e| panic!("{case}: {e}")); checker.check(&case, "Rf", num(&exp["Rf"]), got.rf); checker.check(&case, "Rg", num(&exp["Rg"]), got.rg); checker.check(&case, "CCT_K", num(&exp["CCT_K"]), got.cct_k); checker.check(&case, "Duv", num(&exp["Duv"]), got.duv); let bins = exp["bins"].as_array().unwrap(); assert_eq!(bins.len(), tm30::N_BINS, "{case}"); assert_eq!(got.bins.len(), tm30::N_BINS, "{case}"); for (e, g) in bins.iter().zip(&got.bins) { assert_eq!(e["bin"].as_u64().unwrap(), u64::from(g.bin), "{case}"); let c = &mut checker; c.check(&case, "ref_a", num(&e["ref_a"]), g.ref_a); c.check(&case, "ref_b", num(&e["ref_b"]), g.ref_b); c.check(&case, "test_a", num(&e["test_a"]), g.test_a); c.check(&case, "test_b", num(&e["test_b"]), g.test_b); c.check(&case, "ref_a_norm", num(&e["ref_a_norm"]), g.ref_a_norm); c.check(&case, "ref_b_norm", num(&e["ref_b_norm"]), g.ref_b_norm); c.check(&case, "test_a_norm", num(&e["test_a_norm"]), g.test_a_norm); c.check(&case, "test_b_norm", num(&e["test_b_norm"]), g.test_b_norm); c.check(&case, "Rcs_pct", num(&e["Rcs_pct"]), g.rcs_pct); c.check(&case, "Rhs", num(&e["Rhs"]), g.rhs); c.check(&case, "Rf_h", num(&e["Rf_h"]), g.rf_h); } } println!("{} golden cases", files.len()); println!( "{:<12} {:>12} {:>12} {:>12} {:>6}", "field", "max_abs", "max_rel", "worst_score", "n" ); for (field, d) in &checker.devs { println!( "{field:<12} {:>12.3e} {:>12.3e} {:>12.3e} {:>6}", d.max_abs, d.max_rel, d.worst, d.n ); } assert!( checker.failures.is_empty(), "{} mismatches:\n{}", checker.failures.len(), checker.failures.join("\n") ); } #[test] fn not_visible_error() { // Entirely outside 380-780 nm. let wl: Vec = (0..100).map(|i| 790.0 + 2.0 * i as f64).collect(); let v = vec![1.0; wl.len()]; let err = tm30::compute(&wl, &v).unwrap_err(); assert_eq!(err, tm30::Error::NotVisible); assert_eq!(err.to_string(), "spectrum does not cover the visible range"); // Only 9 points inside the range (tm30.py requires at least 10). let mut wl: Vec = (0..9).map(|i| 400.0 + 40.0 * i as f64).collect(); wl.extend((0..50).map(|i| 800.0 + i as f64)); let v = vec![1.0; wl.len()]; assert_eq!(tm30::compute(&wl, &v), Err(tm30::Error::NotVisible)); // Exactly 10 points inside is accepted. let wl: Vec = (0..10).map(|i| 380.0 + 40.0 * i as f64).collect(); let v = vec![1.0; wl.len()]; assert!(tm30::compute(&wl, &v).is_ok()); } #[test] fn invalid_input_errors() { use tm30::Error; let wl: Vec = (0..=40).map(|i| 380.0 + 10.0 * i as f64).collect(); let ok = vec![1.0; wl.len()]; assert!(matches!( tm30::compute(&wl, &ok[1..]), Err(Error::InvalidInput(_)) )); let mut nan = ok.clone(); nan[5] = f64::NAN; assert!(matches!( tm30::compute(&wl, &nan), Err(Error::InvalidInput(_)) )); let zeros = vec![0.0; wl.len()]; assert!(matches!( tm30::compute(&wl, &zeros), Err(Error::InvalidInput(_)) )); let mut unsorted = wl.clone(); unsorted.swap(3, 4); assert!(matches!( tm30::compute(&unsorted, &ok), Err(Error::InvalidInput(_)) )); } #[test] fn hpcs_grid_ignores_infrared_tail() { // The HPCS 6500 delivers 350 points over 380-1050 nm; anything past // 780 nm must not influence the result. let path = golden_dir().join("lamp_L0003.json"); let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); let wl = nums(&doc["wavelengths"]); let mut values = nums(&doc["values"]); assert_eq!(wl.len(), 350); let a = tm30::compute(&wl, &values).unwrap(); for (w, v) in wl.iter().zip(values.iter_mut()) { if *w > 780.0 { *v = 1e6; } } let b = tm30::compute(&wl, &values).unwrap(); assert_eq!(a, b); } #[test] fn scale_invariance() { let path = golden_dir().join("illuminant_FL2.json"); let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); let wl = nums(&doc["wavelengths"]); let values = nums(&doc["values"]); let a = tm30::compute(&wl, &values).unwrap(); let scaled: Vec = values.iter().map(|v| v * 1234.5).collect(); let b = tm30::compute(&wl, &scaled).unwrap(); assert!((a.rf - b.rf).abs() < 1e-9); assert!((a.rg - b.rg).abs() < 1e-9); assert!((a.cct_k - b.cct_k).abs() < 1e-6); }