prop/rust/prop_grid_rs/tests/json_weights_golden.rs
Graham McIntire bfef11dbdf
Some checks failed
Build base image / Build and push base image (push) Successful in 14s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 18m31s
Build prop-grid-rs / Test, build, push (push) Successful in 23m55s
fix(ci): stage band_weights.json into the prop-grid-rs build context
json_weights_golden reads priv/algo/band_weights.json via a bare
relative path. That resolves inside `cargo test` at the crate root but
not inside the image build, whose context is rust/prop_grid_rs and
whose WORKDIR is /src — so the test aborted with "JSON file must
exist" and the build never produced an image.

grid-rs CI has been red since 2fd88a94, the commit that added the
test. Production still runs main-1785606663-95976b6 (95976b6b, the
last green build), so every prop-grid-rs change since then — including
the HRDPS rotated-pole decode fix — has silently failed to ship.

Three parts:
  * resolve the fixture from CARGO_MANIFEST_DIR so the path no longer
    depends on the cwd
  * stage the file into the build context in the workflow
  * COPY it to /priv in the builder stage, where
    CARGO_MANIFEST_DIR/../../priv resolves from /src
2026-08-05 18:01:24 -05:00

155 lines
5.4 KiB
Rust

//! 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<u32, Weights> {
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();
/// Absolute path to the repo's calibration JSON, resolved from the
/// crate root rather than the cwd. `cargo test` runs with the cwd at
/// the crate root, but the Docker build stages the file at an absolute
/// path, and a bare relative path silently resolved to a nonexistent
/// file there — which is what turned this golden test into a hard CI
/// failure that blocked every prop-grid-rs image build.
const BAND_WEIGHTS_JSON: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../priv/algo/band_weights.json"
);
fn ensure_json_env() {
ENSURE_ENV.call_once(|| {
std::env::set_var("PROP_BAND_WEIGHTS_JSON", BAND_WEIGHTS_JSON);
});
}
#[test]
fn json_override_weights_match_file() {
ensure_json_env();
let expected = parse_json_override_weights(BAND_WEIGHTS_JSON);
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,
);
}
}