main
Drew Owens 0.11.4 source 8889116 1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
//! THE CSWA BENCHMARK (0.11.0) — exam parts as release validation.
//!
//! Drew (2026-10-15): "pull 3-5 random parts from CSWA exams as part of our
//! e2e testing/validation for each release … use those as benchmarks for new
//! feature developments." Each spec in `resources/cswa/` (shipped in the zip)
//! is one part: the published answer (mass in grams, sometimes volume, centre
//! of mass, surface area), the material, and the `swx_run_script` that builds
//! it. `swx_selftest {level:"cswa"}` and `tools/cswa-check.py` run them and
//! compare within the exam's tolerance; `tools/release.sh --require-live`
//! refuses to publish without a passing live report.
//!
//! The answer key is the PUBLISHED one, never the bridge's own idea: a spec
//! whose key was not published says where its number came from (`keyBasis`).

use serde_json::{json, Value};

/// Every shipped spec, embedded so the self-test needs no file next to the
/// exe (the same files ship in the zip for tools/cswa-check.py). A test pins
/// this list to the directory.
pub const SPECS: &[(&str, &str)] = &[
    ("cswa-00-lego-2x4.json", include_str!("../resources/cswa/cswa-00-lego-2x4.json")),
    ("cswa-01-3dengr-q5-block.json", include_str!("../resources/cswa/cswa-01-3dengr-q5-block.json")),
    ("cswa-02-3dengr-q6-hole.json", include_str!("../resources/cswa/cswa-02-3dengr-q6-hole.json")),
    ("cswa-03-3dengr-q8-hole30.json", include_str!("../resources/cswa/cswa-03-3dengr-q8-hole30.json")),
    ("cswa-04-3dengr-q9-l-bracket.json", include_str!("../resources/cswa/cswa-04-3dengr-q9-l-bracket.json")),
    ("cswa-05-tool-block-step1.json", include_str!("../resources/cswa/cswa-05-tool-block-step1.json")),
    ("cswa-06-tool-block-step2.json", include_str!("../resources/cswa/cswa-06-tool-block-step2.json")),
    ("cswa-07-tool-block-step3.json", include_str!("../resources/cswa/cswa-07-tool-block-step3.json")),
    ("cswa-08-tool-block-step4.json", include_str!("../resources/cswa/cswa-08-tool-block-step4.json")),
    ("cswa-08b-tool-block-step4-explicit.json", include_str!("../resources/cswa/cswa-08b-tool-block-step4-explicit.json")),
    ("cswa-09-community-stepped-shaft.json", include_str!("../resources/cswa/cswa-09-community-stepped-shaft.json")),
    ("cswa-10-community-mirrored-block.json", include_str!("../resources/cswa/cswa-10-community-mirrored-block.json")),
    ("cswa-11-community-slotted-plate.json", include_str!("../resources/cswa/cswa-11-community-slotted-plate.json")),
    ("cswa-12-community-up-to-plates.json", include_str!("../resources/cswa/cswa-12-community-up-to-plates.json")),
];

// ── SPEC VARIABLES (0.11.0) ────────────────────────────────────────────────
// An exam part is often the SAME drawing with new values (the Tool Block's
// Steps 1 and 2: A, B, C change, "all other dimensions the same"). A spec may
// therefore name `variables` (numbers), `derived` ([name, expression] pairs,
// evaluated in order) and write any number in its script as "=expression";
// `extends: "<id>"` takes another spec's script and derived values and
// overrides its variables. This lives in the BENCHMARK (here and in
// tools/cswa-check.py, which implements the same grammar), never in the DSL:
// the bridge is always sent plain numbers.

/// Evaluate an arithmetic expression: numbers, variables, + − * / ^,
/// parentheses, unary minus, `pi`, and sqrt sin cos tan asin acos atan atan2
/// abs min max rad deg (angles in RADIANS; rad()/deg() convert).
pub fn eval(expr: &str, vars: &std::collections::HashMap<String, f64>) -> Result<f64, String> {
    struct P<'a> {
        s: &'a [u8],
        i: usize,
        vars: &'a std::collections::HashMap<String, f64>,
    }
    impl P<'_> {
        fn ws(&mut self) {
            while self.i < self.s.len() && (self.s[self.i] as char).is_whitespace() {
                self.i += 1;
            }
        }
        fn peek(&mut self) -> Option<char> {
            self.ws();
            self.s.get(self.i).map(|c| *c as char)
        }
        fn expr(&mut self) -> Result<f64, String> {
            let mut v = self.term()?;
            while let Some(c) = self.peek() {
                match c {
                    '+' => { self.i += 1; v += self.term()?; }
                    '-' => { self.i += 1; v -= self.term()?; }
                    _ => break,
                }
            }
            Ok(v)
        }
        fn term(&mut self) -> Result<f64, String> {
            let mut v = self.power()?;
            while let Some(c) = self.peek() {
                match c {
                    '*' => { self.i += 1; v *= self.power()?; }
                    '/' => { self.i += 1; v /= self.power()?; }
                    _ => break,
                }
            }
            Ok(v)
        }
        fn power(&mut self) -> Result<f64, String> {
            let base = self.unary()?;
            if self.peek() == Some('^') {
                self.i += 1;
                return Ok(base.powf(self.power()?));
            }
            Ok(base)
        }
        fn unary(&mut self) -> Result<f64, String> {
            match self.peek() {
                Some('-') => { self.i += 1; Ok(-self.unary()?) }
                Some('+') => { self.i += 1; self.unary() }
                _ => self.atom(),
            }
        }
        fn atom(&mut self) -> Result<f64, String> {
            match self.peek() {
                Some('(') => {
                    self.i += 1;
                    let v = self.expr()?;
                    if self.peek() != Some(')') {
                        return Err("missing )".into());
                    }
                    self.i += 1;
                    Ok(v)
                }
                Some(c) if c.is_ascii_digit() || c == '.' => {
                    let st = self.i;
                    while self.i < self.s.len() && ((self.s[self.i] as char).is_ascii_digit() || self.s[self.i] == b'.' || self.s[self.i] == b'e'
                        || ((self.s[self.i] == b'-' || self.s[self.i] == b'+') && self.i > st && self.s[self.i - 1] == b'e')) {
                        self.i += 1;
                    }
                    std::str::from_utf8(&self.s[st..self.i]).unwrap_or("").parse::<f64>().map_err(|e| format!("bad number: {e}"))
                }
                Some(c) if c.is_ascii_alphabetic() || c == '_' => {
                    let st = self.i;
                    while self.i < self.s.len() && ((self.s[self.i] as char).is_ascii_alphanumeric() || self.s[self.i] == b'_') {
                        self.i += 1;
                    }
                    let name = std::str::from_utf8(&self.s[st..self.i]).unwrap_or("").to_string();
                    if self.peek() == Some('(') {
                        self.i += 1;
                        let mut args = vec![self.expr()?];
                        while self.peek() == Some(',') {
                            self.i += 1;
                            args.push(self.expr()?);
                        }
                        if self.peek() != Some(')') {
                            return Err(format!("missing ) after {name}("));
                        }
                        self.i += 1;
                        let one = |f: fn(f64) -> f64| if args.len() == 1 { Ok(f(args[0])) } else { Err(format!("{name} takes one argument")) };
                        let two = |f: fn(f64, f64) -> f64| if args.len() == 2 { Ok(f(args[0], args[1])) } else { Err(format!("{name} takes two arguments")) };
                        return match name.as_str() {
                            "sqrt" => one(f64::sqrt),
                            "sin" => one(f64::sin),
                            "cos" => one(f64::cos),
                            "tan" => one(f64::tan),
                            "asin" => one(f64::asin),
                            "acos" => one(f64::acos),
                            "atan" => one(f64::atan),
                            "abs" => one(f64::abs),
                            "rad" => one(f64::to_radians),
                            "deg" => one(f64::to_degrees),
                            "atan2" => two(f64::atan2),
                            "min" => two(f64::min),
                            "max" => two(f64::max),
                            _ => Err(format!("unknown function {name}")),
                        };
                    }
                    if name == "pi" {
                        return Ok(std::f64::consts::PI);
                    }
                    self.vars.get(&name).copied().ok_or_else(|| format!("unknown variable {name}"))
                }
                other => Err(format!("unexpected {other:?}")),
            }
        }
    }
    let mut p = P { s: expr.as_bytes(), i: 0, vars };
    let v = p.expr().map_err(|e| format!("{e} in {expr:?}"))?;
    if p.peek().is_some() {
        return Err(format!("unexpected text at {} in {expr:?}", p.i));
    }
    if !v.is_finite() {
        return Err(format!("{expr:?} is not a finite number (with these variables)"));
    }
    Ok(v)
}

/// A spec with its `extends` chain merged in (the base's script, derived values
/// and variables, overridden or appended to by each child in turn).
fn resolve_extends(v: &Value, all: &[Value], depth: usize) -> Result<Value, String> {
    let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("?").to_string();
    let Some(base_id) = v.get("extends").and_then(|e| e.as_str()) else { return Ok(v.clone()) };
    if depth > 8 {
        return Err(format!("{id}: extends goes more than 8 deep (a cycle?)"));
    }
    let base = all.iter().find(|b| b.get("id").and_then(|i| i.as_str()) == Some(base_id)).ok_or_else(|| format!("{id}: extends {base_id:?}, which is not a spec"))?;
    let mut merged = resolve_extends(base, all, depth + 1)?;
    let mut vars = merged.get("variables").cloned().unwrap_or(json!({}));
    if let (Value::Object(m), Some(Value::Object(o))) = (&mut vars, v.get("variables")) {
        for (k, x) in o {
            m.insert(k.clone(), x.clone());
        }
    }
    if let (Value::Object(m), Value::Object(o)) = (&mut merged, v) {
        // What is TRUE OF THE BASE PART ONLY is not inherited: the ops it
        // lacked, the derivation of ITS key, why IT is skipped in the simulator.
        m.remove("needsOps");
        m.remove("derivation");
        m.remove("simulatorSkips");
        for (k, x) in o {
            if k != "extends" && k != "variables" && k != "scriptAppend" && k != "derivedAppend" {
                m.insert(k.clone(), x.clone());
            }
        }
        m.insert("variables".into(), vars);
        // A derivedAppend name the base already derives would SHADOW it — and
        // every "=name" in the base's script would silently take the new value
        // (0.11.4: Step 4's tangent point `tpx` redrew Step 1's R5 fillet).
        // Refused: an appended value gets a name of its own.
        if let (Some(Value::Array(extra)), Some(Value::Array(base))) = (o.get("derivedAppend"), m.get("derived")) {
            let taken: Vec<&str> = base.iter().filter_map(|p| p.get(0).and_then(|n| n.as_str())).collect();
            if let Some(clash) = extra.iter().filter_map(|p| p.get(0).and_then(|n| n.as_str())).find(|n| taken.contains(n)) {
                return Err(format!("{id}: derivedAppend redefines {clash:?}, which {base_id} already derives — name it differently"));
            }
        }
        // More steps after the base part's (the Tool Block's Steps 3 and 4
        // cut the Step 2 part), and more derived values for them.
        for (key, into) in [("scriptAppend", "script"), ("derivedAppend", "derived")] {
            if let Some(extra) = o.get(key) {
                let more = extra.as_array().ok_or_else(|| format!("{id}: {key} must be a list"))?;
                let list = m.entry(into.to_string()).or_insert_with(|| json!([]));
                if let Value::Array(a) = list {
                    a.extend(more.iter().cloned());
                }
            }
        }
    }
    Ok(merged)
}

/// Resolve `extends`, `variables` and `derived`, and replace every
/// "=expression" string in the script with its value. A spec without them is
/// returned as it is.
pub fn expand(v: &Value, all: &[Value]) -> Result<Value, String> {
    let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("?").to_string();
    let mut spec = resolve_extends(v, all, 0)?;
    let mut vars = std::collections::HashMap::new();
    if let Some(Value::Object(m)) = spec.get("variables") {
        for (k, x) in m {
            vars.insert(k.clone(), x.as_f64().ok_or_else(|| format!("{id}: variable {k} must be a number"))?);
        }
    }
    if let Some(d) = spec.get("derived") {
        for pair in d.as_array().ok_or_else(|| format!("{id}: derived is a list of [name, expression]"))? {
            let (name, e) = match pair.as_array().map(|a| a.as_slice()) {
                Some([Value::String(n), Value::String(e)]) => (n.clone(), e.clone()),
                _ => return Err(format!("{id}: derived is a list of [name, expression]")),
            };
            let x = eval(&e, &vars).map_err(|m| format!("{id}: derived {name}: {m}"))?;
            vars.insert(name, x);
        }
    }
    fn subst(v: &mut Value, vars: &std::collections::HashMap<String, f64>) -> Result<(), String> {
        match v {
            Value::String(s) if s.starts_with('=') => {
                *v = json!(eval(&s[1..], vars)?);
            }
            Value::Array(a) => {
                for x in a {
                    subst(x, vars)?;
                }
            }
            Value::Object(m) => {
                for (_, x) in m.iter_mut() {
                    subst(x, vars)?;
                }
            }
            _ => {}
        }
        Ok(())
    }
    for key in ["script", "expected"] {
        if let Some(x) = spec.get_mut(key) {
            subst(x, &vars).map_err(|m| format!("{id}: {key}: {m}"))?;
        }
    }
    Ok(spec)
}

/// One benchmark part.
#[derive(Debug, Clone, PartialEq)]
pub struct Spec {
    pub id: String,
    pub title: String,
    pub official: bool,
    pub material: Option<String>,
    pub density: Option<f64>,
    pub mass_g: f64,
    pub volume_mm3: Option<f64>,
    pub com_mm: Option<[f64; 3]>,
    pub area_mm2: Option<f64>,
    pub tol_pct: f64,
    pub script: Value,
    /// Ops the part needs that the bridge does not have yet: the part is
    /// SKIPPED (and said so), never half-built.
    pub needs_ops: Vec<String>,
    /// Why the SIMULATOR cannot build this part (0.11.4) — e.g. Offset
    /// Entities needs SOLIDWORKS' own face outline. Skipped there, said so;
    /// run live as any other part.
    pub simulator_skips: Option<String>,
    /// HOW A DERIVED KEY IS REPRODUCED (0.11.4): `[name, Python expression]`
    /// pairs evaluated in order (math's functions, the spec's variables and
    /// derived values in scope); `massG`, and `volumeMm3` / `centerOfMassMm`
    /// where the key has them, must come out as the key says.
    /// `tools/cswa-check.py --derivations` checks every one.
    pub derivation: Vec<(String, String)>,
}

pub fn parse_spec(v: &Value) -> Result<Spec, String> {
    let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(String::from);
    let id = s("id").ok_or("a spec needs an id")?;
    if v.get("units").and_then(|u| u.as_str()).unwrap_or("MMGS") != "MMGS" {
        return Err(format!("{id}: units must be MMGS (the DSL is millimetres; convert an IPS part's dimensions first)"));
    }
    let e = v.get("expected").ok_or_else(|| format!("{id}: expected is required"))?;
    let f = |k: &str| e.get(k).and_then(|x| x.as_f64()).filter(|x| x.is_finite());
    let mass_g = f("massG").filter(|m| *m > 0.0).ok_or_else(|| format!("{id}: expected.massG (grams, > 0) is required"))?;
    let tol_pct = f("tolerancePct").unwrap_or(1.0);
    if !(tol_pct > 0.0 && tol_pct <= 5.0) {
        return Err(format!("{id}: tolerancePct must be in (0, 5]"));
    }
    let com_mm = match e.get("centerOfMassMm") {
        None | Some(Value::Null) => None,
        Some(c) => {
            let a: Vec<f64> = c.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
            if a.len() != 3 {
                return Err(format!("{id}: expected.centerOfMassMm must be [x, y, z]"));
            }
            Some([a[0], a[1], a[2]])
        }
    };
    let script = v.get("script").cloned().ok_or_else(|| format!("{id}: script is required"))?;
    let needs_ops: Vec<String> = v.get("needsOps").and_then(|n| n.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
    let simulator_skips = match v.get("simulatorSkips") {
        None | Some(Value::Null) => None,
        Some(Value::String(why)) if !why.trim().is_empty() => Some(why.clone()),
        Some(_) => return Err(format!("{id}: simulatorSkips is the reason the simulator cannot build the part (a sentence)")),
    };
    let derivation = derivation_pairs(v).map_err(|m| format!("{id}: {m}"))?;
    if needs_ops.is_empty() {
        // A runnable spec must PARSE: a typo is caught here, not on a live run.
        crate::script::parse(&script).map_err(|m| format!("{id}: its script does not parse: {m}"))?;
    }
    Ok(Spec {
        title: s("title").unwrap_or_else(|| id.clone()),
        official: v.get("official").and_then(|o| o.as_bool()).unwrap_or(false),
        material: v.pointer("/material/name").and_then(|m| m.as_str()).map(String::from),
        density: v.pointer("/material/densityKgM3").and_then(|m| m.as_f64()),
        mass_g,
        volume_mm3: f("volumeMm3"),
        com_mm,
        area_mm2: f("surfaceAreaMm2"),
        tol_pct,
        script,
        needs_ops,
        simulator_skips,
        derivation,
        id,
    })
}

/// `derivation`: a list of `[name, expression]` pairs; a name is an
/// identifier (it becomes a Python variable), the last ones the key's
/// quantities.
fn derivation_pairs(v: &Value) -> Result<Vec<(String, String)>, String> {
    let Some(d) = v.get("derivation").filter(|d| !d.is_null()) else { return Ok(Vec::new()) };
    let bad = || "derivation is a list of [name, Python expression] pairs".to_string();
    let mut out = Vec::new();
    for pair in d.as_array().ok_or_else(bad)? {
        match pair.as_array().map(|a| a.as_slice()) {
            Some([Value::String(n), Value::String(e)])
                if !n.is_empty() && n.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false)
                    && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !e.trim().is_empty() =>
            {
                out.push((n.clone(), e.clone()))
            }
            _ => return Err(bad()),
        }
    }
    if !out.iter().any(|(n, _)| n == "massG") {
        return Err("a derivation must end in massG (the key)".into());
    }
    Ok(out)
}

/// Every shipped spec, parsed.
pub fn specs() -> Vec<Result<Spec, String>> {
    let raw: Vec<Result<Value, String>> = SPECS.iter().map(|(name, text)| serde_json::from_str::<Value>(text).map_err(|e| format!("{name}: {e}"))).collect();
    let all: Vec<Value> = raw.iter().filter_map(|r| r.as_ref().ok().cloned()).collect();
    raw.into_iter().map(|r| r.and_then(|v| expand(&v, &all)).and_then(|v| parse_spec(&v))).collect()
}

/// How far off `got` is, in percent of `want`.
pub fn pct_off(got: f64, want: f64) -> f64 {
    if want == 0.0 {
        return if got == 0.0 { 0.0 } else { f64::INFINITY };
    }
    (got - want).abs() / want.abs() * 100.0
}

/// A centre-of-mass coordinate is within tolerance when it is within
/// `tol_pct` of the key, or within 0.05 mm (a coordinate of 0 has no percent).
pub fn coord_ok(got: f64, want: f64, tol_pct: f64) -> bool {
    (got - want).abs() <= (want.abs() * tol_pct / 100.0).max(0.05)
}

/// COMPARE a swx_mass_properties reply with the key. Mass always; volume,
/// centre of mass and surface area when the key has them — except that a
/// SIMULATED reply (the mock) is compared on mass and volume only, the two
/// quantities its arithmetic model gets exactly. Returns (pass, the checks).
pub fn compare(spec: &Spec, mp: &Value) -> (bool, Value) {
    let simulated = mp.get("simulated").is_some();
    // The simulator's centre of mass is compared when its column model made
    // it (exact to the integration); its surface area never is.
    let com_simulated_ok = mp.pointer("/simulatedBy/volume").and_then(|v| v.as_str()).map(|v| v.contains("column")).unwrap_or(false);
    let mut checks = Vec::new();
    let mut pass = true;
    let mass = mp.get("massG").and_then(|m| m.as_f64()).or_else(|| mp.get("massKg").and_then(|m| m.as_f64()).map(|k| k * 1000.0));
    let mut one = |what: &str, got: Option<f64>, want: f64| {
        let off = got.map(|g| pct_off(g, want));
        let ok = off.map(|o| o <= spec.tol_pct).unwrap_or(false);
        pass &= ok;
        checks.push(json!({"quantity": what, "expected": want, "measured": got, "offPct": off, "pass": ok}));
    };
    one("massG", mass, spec.mass_g);
    if let Some(v) = spec.volume_mm3 {
        let got = mp.get("volumeMm3").and_then(|m| m.as_f64()).or_else(|| mp.get("volumeM3").and_then(|m| m.as_f64()).map(|v| v * 1.0e9));
        one("volumeMm3", got, v);
    }
    if let Some(a) = spec.area_mm2 {
        if simulated {
            checks.push(json!({"quantity": "surfaceAreaMm2", "expected": a, "pass": Value::Null, "skipped": "not compared in the simulator"}));
        } else {
            let got = mp.get("surfaceAreaMm2").and_then(|m| m.as_f64()).or_else(|| mp.get("surfaceAreaM2").and_then(|m| m.as_f64()).map(|v| v * 1.0e6));
            one("surfaceAreaMm2", got, a);
        }
    }
    if let Some(c) = spec.com_mm {
        if simulated && !com_simulated_ok {
            checks.push(json!({"quantity": "centerOfMassMm", "expected": c, "pass": Value::Null, "skipped": "not compared in the simulator"}));
        } else {
            let got: Vec<f64> = mp.get("centerOfMassMm").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
            let ok = got.len() == 3 && (0..3).all(|i| coord_ok(got[i], c[i], spec.tol_pct));
            pass &= ok;
            checks.push(json!({"quantity": "centerOfMassMm", "expected": c, "measured": got, "pass": ok}));
        }
    }
    (pass, Value::Array(checks))
}

/// The one-line verdict: "N of M CSWA parts within tolerance", and what was
/// skipped and why — parts needing ops the bridge lacks, and (in the
/// simulator) parts only SOLIDWORKS can build.
pub fn summary(passed: usize, ran: usize, skipped: usize, simulator_only: usize) -> String {
    let mut s = format!("{passed} of {ran} CSWA parts within tolerance");
    let mut why = Vec::new();
    if skipped > 0 {
        why.push(format!("{skipped} skipped: they need ops the bridge does not have yet"));
    }
    if simulator_only > 0 {
        why.push(format!("{simulator_only} skipped in the simulator: only SOLIDWORKS can build them"));
    }
    if !why.is_empty() {
        s.push_str(&format!(" ({})", why.join("; ")));
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_shipped_spec_parses_and_the_list_is_the_directory() {
        for s in specs() {
            assert!(s.is_ok(), "{s:?}");
        }
        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/cswa");
        let mut on_disk: Vec<String> = std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string())
            .filter(|n| n.ends_with(".json")).collect();
        on_disk.sort();
        let mut listed: Vec<String> = SPECS.iter().map(|(n, _)| n.to_string()).collect();
        listed.sort();
        assert_eq!(on_disk, listed, "every resources/cswa/*.json is embedded, and nothing else");
        // Spec 0 is the LEGO brick: Michael's measured volume.
        let lego = specs().remove(0).unwrap();
        assert_eq!(lego.id, "cswa-00-lego-2x4");
        assert_eq!(lego.volume_mm3, Some(2073.3615208));
        assert!((lego.mass_g - 2073.3615208 * 1.02e-3).abs() < 1e-6);
    }

    #[test]
    fn variables_and_extends_expand_and_the_python_checker_agrees() {
        let vars: std::collections::HashMap<String, f64> = [("A".to_string(), 81.0)].into_iter().collect();
        assert_eq!(eval("A - 43", &vars).unwrap(), 38.0);
        assert_eq!(eval("2^3^2", &vars).unwrap(), 512.0, "^ is right-associative");
        assert_eq!(eval("-2^2", &vars).unwrap(), 4.0, "unary minus binds tighter, as in the Python twin");
        assert!((eval("atan2(1, 1) - pi/4", &vars).unwrap()).abs() < 1e-15);
        assert!((eval("sqrt(29^2 - 19^2) + rad(180) - pi", &vars).unwrap() - (29f64 * 29.0 - 361.0).sqrt()).abs() < 1e-12);
        assert!(eval("B", &vars).unwrap_err().contains("unknown variable B"));
        assert!(eval("sqrt(-1)", &vars).is_err(), "not finite");
        assert!(eval("1 +", &vars).is_err() && eval("foo(1)", &vars).is_err() && eval("(1", &vars).is_err());
        // Step 2 is Step 1's script with other variables; Step 3 appends cuts.
        let all: Vec<Value> = SPECS.iter().map(|(_, t)| serde_json::from_str(t).unwrap()).collect();
        let by = |id: &str| all.iter().find(|v| v["id"] == id).unwrap();
        let s1 = expand(by("cswa-05-tool-block-step1"), &all).unwrap();
        let s2 = expand(by("cswa-06-tool-block-step2"), &all).unwrap();
        let s3 = expand(by("cswa-07-tool-block-step3"), &all).unwrap();
        assert_eq!(s1["script"][2]["depthMm"], 43.0);
        assert_eq!(s2["script"][2]["depthMm"], 45.0);
        assert_eq!(s2["script"].as_array().unwrap().len(), s1["script"].as_array().unwrap().len());
        assert_eq!(s3["script"].as_array().unwrap().len(), s1["script"].as_array().unwrap().len() + 7);
        // The recipe's R19 centre (21.54, 33.49) and tangent point (18.24, 14.78):
        // the tangent arc continues the 10° line that ends at T.
        let t = &s1["script"][1]["entities"][0]["line"]["to"];
        assert!((t[0].as_f64().unwrap() - 18.243084987875623).abs() < 1e-9 && (t[1].as_f64().unwrap() - 14.783251905279968).abs() < 1e-9, "{t}");
        // tools/cswa-check.py implements the same grammar: it must expand
        // every spec to the same numbers (when python3 is there to ask).
        let out = std::process::Command::new("python3")
            .arg(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/cswa-check.py"))
            .arg("--expand-only")
            .output();
        if let Some(o) = out.ok().filter(|o| o.status.success()) {
            let py: Value = serde_json::from_slice(&o.stdout).unwrap();
            for v in &all {
                let id = v["id"].as_str().unwrap();
                let rs = expand(v, &all).unwrap();
                let close = |a: &Value, b: &Value| -> bool {
                    fn walk(a: &Value, b: &Value) -> bool {
                        match (a, b) {
                            (Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-9 * x.as_f64().unwrap().abs().max(1.0),
                            (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| walk(p, q)),
                            (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, p)| y.get(k).map(|q| walk(p, q)).unwrap_or(false)),
                            _ => a == b,
                        }
                    }
                    walk(a, b)
                };
                assert!(close(&rs["script"], &py[id]["script"]), "{id}: the Python checker expands the script differently");
                assert!(close(&rs["expected"], &py[id]["expected"]), "{id}: ...and the key");
            }
        }
    }

    #[test]
    fn an_appended_derived_value_may_not_shadow_the_base_s() {
        let base = json!({"id": "b", "expected": {"massG": 1}, "variables": {"A": 2}, "derived": [["k", "A*2"]],
                          "script": [{"op": "new_part"}, {"op": "sketch_rect", "widthMm": "=k", "heightMm": 1}]});
        let child = json!({"id": "c", "extends": "b", "derivedAppend": [["k", "A*3"]], "scriptAppend": []});
        let all = vec![base.clone(), child.clone()];
        let e = expand(&child, &all).unwrap_err();
        assert!(e.contains("redefines \"k\"") && e.contains("b already derives"), "{e}");
        // ...a new name is fine, and the base's script keeps its value.
        let ok = json!({"id": "c", "extends": "b", "derivedAppend": [["k2", "A*3"]], "scriptAppend": [{"op": "sketch_rect", "widthMm": "=k2", "heightMm": 1}]});
        let all = vec![base, ok.clone()];
        let x = expand(&ok, &all).unwrap();
        assert_eq!(x["script"][1]["widthMm"], 4.0);
        assert_eq!(x["script"][2]["widthMm"], 6.0);
        // The Python twin refuses it too.
        let dir = std::env::temp_dir().join(format!("swx-cswa-shadow-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("resources/cswa")).unwrap();
        std::fs::create_dir_all(dir.join("tools")).unwrap();
        std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/cswa-check.py"), dir.join("tools/cswa-check.py")).unwrap();
        std::fs::write(dir.join("resources/cswa/b.json"), json!({"id": "b", "expected": {"massG": 1}, "variables": {"A": 2}, "derived": [["k", "A*2"]], "script": []}).to_string()).unwrap();
        std::fs::write(dir.join("resources/cswa/c.json"), json!({"id": "c", "extends": "b", "derivedAppend": [["k", "A*3"]]}).to_string()).unwrap();
        if let Ok(o) = std::process::Command::new("python3").arg(dir.join("tools/cswa-check.py")).arg("--expand-only").output() {
            assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("redefines 'k'"), "{}", String::from_utf8_lossy(&o.stderr));
        }
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_reply_is_compared_within_the_tolerance() {
        let spec = parse_spec(&json!({"id": "t", "expected": {"massG": 100.0, "volumeMm3": 1000.0, "centerOfMassMm": [10.0, 0.0, -5.0], "surfaceAreaMm2": 500.0},
                                     "script": [{"op": "new_part"}]})).unwrap();
        let good = json!({"massG": 100.9, "volumeMm3": 1009.0, "centerOfMassMm": [10.09, 0.04, -5.04], "surfaceAreaMm2": 504.0});
        assert!(compare(&spec, &good).0, "{}", compare(&spec, &good).1);
        let off = json!({"massG": 101.1, "volumeMm3": 1000.0, "centerOfMassMm": [10.0, 0.0, -5.0], "surfaceAreaMm2": 500.0});
        assert!(!compare(&spec, &off).0, "1.1 % off is out");
        let com = json!({"massG": 100.0, "volumeMm3": 1000.0, "centerOfMassMm": [10.0, 0.06, -5.0], "surfaceAreaMm2": 500.0});
        assert!(!compare(&spec, &com).0, "0.06 mm off a zero coordinate is out");
        // massKg is read when massG is missing.
        let kg = json!({"massKg": 0.1, "volumeM3": 1.0e-6, "centerOfMassMm": [10.0, 0.0, -5.0], "surfaceAreaM2": 5.0e-4});
        assert!(compare(&spec, &kg).0, "{}", compare(&spec, &kg).1);
        // The simulator is held to mass and volume only.
        let sim = json!({"massG": 100.0, "volumeMm3": 1000.0, "simulated": "mock", "centerOfMassMm": [0, 0, 0], "surfaceAreaMm2": 1.0});
        assert!(compare(&spec, &sim).0);
        // ...except that the column model's centre of mass IS compared.
        let col = json!({"massG": 100.0, "volumeMm3": 1000.0, "simulated": "mock", "simulatedBy": {"volume": "column integration"}, "centerOfMassMm": [0, 0, 0], "surfaceAreaMm2": 1.0});
        assert!(!compare(&spec, &col).0, "a wrong centre of mass from the column model fails");
        assert!(!compare(&spec, &json!({})).0, "nothing measured is not a pass");
        assert_eq!(summary(3, 4, 1, 0), "3 of 4 CSWA parts within tolerance (1 skipped: they need ops the bridge does not have yet)");
        assert_eq!(summary(4, 4, 0, 1), "4 of 4 CSWA parts within tolerance (1 skipped in the simulator: only SOLIDWORKS can build them)");
        assert_eq!(summary(2, 2, 1, 2), "2 of 2 CSWA parts within tolerance (1 skipped: they need ops the bridge does not have yet; 2 skipped in the simulator: only SOLIDWORKS can build them)");
        assert_eq!(summary(5, 5, 0, 0), "5 of 5 CSWA parts within tolerance");
    }

    #[test]
    fn a_spec_is_refused_for_what_would_make_it_meaningless() {
        assert!(parse_spec(&json!({"id": "a", "expected": {}, "script": []})).unwrap_err().contains("massG"));
        assert!(parse_spec(&json!({"id": "a", "units": "IPS", "expected": {"massG": 1}, "script": []})).unwrap_err().contains("MMGS"));
        assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1}, "script": [{"op": "frobnicate"}]})).unwrap_err().contains("does not parse"));
        // A spec that needs a missing op is not parsed as a script: it is skipped.
        assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1}, "needsOps": ["loft"], "script": [{"op": "loft"}]})).is_ok());
        assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1, "tolerancePct": 9}, "script": [{"op": "new_part"}]})).unwrap_err().contains("tolerancePct"));
        // A derivation must be [name, expression] pairs and end in the key's mass (0.11.4).
        let with = |d: Value| parse_spec(&json!({"id": "a", "expected": {"massG": 1}, "script": [{"op": "new_part"}], "derivation": d}));
        assert!(with(json!([["V", "1"]])).unwrap_err().contains("must end in massG"));
        assert!(with(json!([["massG"]])).unwrap_err().contains("[name, Python expression]"));
        assert!(with(json!([["1x", "1"], ["massG", "1"]])).unwrap_err().contains("[name, Python expression]"), "a name is an identifier");
        assert!(with(json!({"massG": "1"})).unwrap_err().contains("[name, Python expression]"));
        assert_eq!(with(json!([["V", "2*3"], ["massG", "V*1e-3"]])).unwrap().derivation, vec![("V".to_string(), "2*3".to_string()), ("massG".to_string(), "V*1e-3".to_string())]);
        assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1}, "script": [], "simulatorSkips": 3})).unwrap_err().contains("simulatorSkips"));
    }

    /// What is true of the BASE part only — the derivation of ITS key, why IT
    /// is skipped in the simulator, the ops it lacked — is not inherited by a
    /// spec that extends it (0.11.4).
    #[test]
    fn a_spec_that_extends_another_does_not_inherit_its_derivation_or_its_skips() {
        let base = json!({"id": "b", "expected": {"massG": 2}, "script": [{"op": "new_part"}], "derivation": [["massG", "2"]],
                          "simulatorSkips": "only SOLIDWORKS", "needsOps": ["loft"]});
        let child = json!({"id": "c", "extends": "b", "expected": {"massG": 3}});
        let all = vec![base, child.clone()];
        let x = expand(&child, &all).unwrap();
        assert!(x.get("derivation").is_none() && x.get("simulatorSkips").is_none() && x.get("needsOps").is_none(), "{x}");
        let s = parse_spec(&x).unwrap();
        assert!(s.derivation.is_empty() && s.simulator_skips.is_none() && s.needs_ops.is_empty());
        // ...while its own are its own.
        let own = json!({"id": "c", "extends": "b", "expected": {"massG": 3}, "simulatorSkips": "mine"});
        let all = vec![all[0].clone(), own.clone()];
        assert_eq!(parse_spec(&expand(&own, &all).unwrap()).unwrap().simulator_skips.as_deref(), Some("mine"));
    }

    /// The Python checker REFUSES a derivation that does not reproduce its
    /// key (0.11.4) — run on a scratch copy holding one wrong spec.
    #[test]
    fn a_derivation_that_misses_its_key_is_reported() {
        let dir = std::env::temp_dir().join(format!("swx-cswa-derivation-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("resources/cswa")).unwrap();
        std::fs::create_dir_all(dir.join("tools")).unwrap();
        std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/cswa-check.py"), dir.join("tools/cswa-check.py")).unwrap();
        let spec = |mass: f64, com: Value| json!({"id": "d", "expected": {"massG": mass, "volumeMm3": 1000.0, "centerOfMassMm": com},
            "derivation": [["volumeMm3", "10**3"], ["massG", "volumeMm3*2.7e-3"], ["centerOfMassMm", "[5, 5, 5]"]], "script": []});
        let run = |v: &Value| -> Option<(bool, String)> {
            std::fs::write(dir.join("resources/cswa/d.json"), v.to_string()).unwrap();
            let o = std::process::Command::new("python3").arg(dir.join("tools/cswa-check.py")).arg("--derivations").output().ok()?;
            Some((o.status.success(), String::from_utf8_lossy(&o.stdout).to_string()))
        };
        if let Some((ok, out)) = run(&spec(2.7, json!([5.0, 5.0, 5.0]))) {
            assert!(ok, "the exact key is reproduced: {out}");
            let (ok, out) = run(&spec(2.7001, json!([5.0, 5.0, 5.0]))).unwrap();
            assert!(!ok && out.contains("massG: the derivation gives"), "a mass 1e-4 g off is reported: {out}");
            let (ok, out) = run(&spec(2.7, json!([5.0, 5.0, 5.01]))).unwrap();
            assert!(!ok && out.contains("centerOfMassMm"), "a centre of mass 0.01 mm off is reported: {out}");
        }
        let _ = std::fs::remove_dir_all(&dir);
    }
}