//! Golden test verifying that per-band weight resolution matches the //! JSON produced by `scripts/recalibrate.py`. //! //! This test sets `PROP_BAND_WEIGHTS_JSON` before any weights lookup so //! the `OnceLock` cache in `band_config` seeds from the real JSON file. //! It runs in a separate test binary from `scorer_golden.rs`, so the //! env var does not affect the existing golden-fixture test. use std::collections::HashMap; use serde_json::Value; use prop_grid_rs::band_config::{self, Weights}; // ── Helpers ────────────────────────────────────────────────────────── fn weights_sum(w: &Weights) -> f64 { w.humidity + w.time_of_day + w.td_depression + w.refractivity + w.sky + w.season + w.wind + w.rain + w.pressure + w.pwat } fn parse_json_override_weights(json_path: &str) -> HashMap { let data = std::fs::read_to_string(json_path).expect("JSON file must exist"); let root: Value = serde_json::from_str(&data).expect("valid JSON"); let overrides = root["band_overrides"] .as_object() .expect("band_overrides must be an object"); let mut map = HashMap::new(); for (freq_str, entry) in overrides { let freq: u32 = freq_str.parse().expect("band override key must be a u32"); let w = &entry["weights"]; let humidity = w["humidity"].as_f64().expect("humidity"); let time_of_day = w["time_of_day"].as_f64().expect("time_of_day"); let td_depression = w["td_depression"].as_f64().expect("td_depression"); let refractivity = w["refractivity"].as_f64().expect("refractivity"); let sky = w["sky"].as_f64().expect("sky"); let season = w["season"].as_f64().expect("season"); let wind = w["wind"].as_f64().expect("wind"); let rain = w["rain"].as_f64().expect("rain"); let pwat = w["pwat"].as_f64().expect("pwat"); let pressure = w["pressure"].as_f64().expect("pressure"); map.insert( freq, Weights::new( humidity, time_of_day, td_depression, refractivity, sky, season, wind, rain, pressure, pwat, ), ); } map } fn assert_weights_equal(label: &str, got: &Weights, expected: &Weights) { let tolerance = 1e-9; let factors: [(&str, f64, f64); 10] = [ ("humidity", got.humidity, expected.humidity), ("time_of_day", got.time_of_day, expected.time_of_day), ("td_depression", got.td_depression, expected.td_depression), ("refractivity", got.refractivity, expected.refractivity), ("sky", got.sky, expected.sky), ("season", got.season, expected.season), ("wind", got.wind, expected.wind), ("rain", got.rain, expected.rain), ("pressure", got.pressure, expected.pressure), ("pwat", got.pwat, expected.pwat), ]; for (name, g, e) in &factors { assert!( (g - e).abs() < tolerance, "{label} factor {name}: got {g}, expected {e}" ); } } // ── Tests ──────────────────────────────────────────────────────────── /// Set env var before any test accesses the OnceLock cache. `std::sync::Once` /// ensures this runs exactly once even if the test runner spawns multiple /// threads for `#[test]` functions in this binary. static ENSURE_ENV: std::sync::Once = std::sync::Once::new(); fn ensure_json_env() { ENSURE_ENV.call_once(|| { std::env::set_var( "PROP_BAND_WEIGHTS_JSON", "../../priv/algo/band_weights.json", ); }); } #[test] fn json_override_weights_match_file() { ensure_json_env(); let json_path = "../../priv/algo/band_weights.json"; let expected = parse_json_override_weights(json_path); for band in band_config::all_bands() { let w = band.weights(); // Every weight vector must sum to ~1.0 let sum = weights_sum(&w); assert!( (sum - 1.0).abs() < 0.001, "band {} MHz weights sum = {sum} (expected 1.0 ± 0.001)", band.freq_mhz, ); // If JSON has an override for this band, it must match exactly if let Some(exp) = expected.get(&band.freq_mhz) { assert_weights_equal(&format!("band {} MHz", band.freq_mhz), &w, exp); } } } #[test] fn all_bands_weights_sum_to_one() { ensure_json_env(); for band in band_config::all_bands() { let w = band.weights(); let sum = weights_sum(&w); assert!( (sum - 1.0).abs() < 0.001, "band {} MHz weights sum = {sum}", band.freq_mhz, ); } }