main
Caleb Publish 0.2.0 cbe04a0 12h ago
//! `grade`: runs the checker over the designer's boards and holds it to an expected-results file. His rule
//! (AI_Mistakes_Log R1): a check gates only after it passes the reference boards and fails the layouts he
//! rejected. Findings his own finished boards carry stay listed as known, with their coordinates,
//! for him to decide; a new one fails the grade, and so does a known one that has gone away.

use crate::board::Board;
use crate::check::{self, Finding, GEOMETRY_RULES};
use crate::classify;
use crate::project;
use crate::rules::Rules;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::path::Path;

const NEAR_MM: f64 = 0.02;

pub struct Outcome {
    pub ok: bool,
    pub lines: Vec<String>,
    pub json: Value,
}

fn strs(v: Option<&Value>) -> Vec<String> {
    v.and_then(|x| x.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default()
}

pub fn run(file: &str) -> Result<Outcome, String> {
    let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
    let v: Value = serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))?;
    let base = Path::new(file).parent().unwrap_or(Path::new("."));
    let boards = v.get("boards").and_then(|b| b.as_array()).ok_or("no boards in the grade file")?;
    let mut ok = true;
    let mut lines = Vec::new();
    let mut out = Vec::new();
    for e in boards {
        let name = e.get("name").and_then(|x| x.as_str()).unwrap_or("?");
        let rel = e.get("board").and_then(|x| x.as_str()).ok_or("board entry without a path")?;
        let path = base.join(rel);
        // A synthetic fixture is the reference board with copper added, in memory: `insert` is appended to the
        // file's text, so every check is proven to fire on a fault placed in a board it passes.
        let inserts = strs(e.get("insert"));
        let board = if inserts.is_empty() {
            Board::read(path.to_str().unwrap_or(rel))?
        } else {
            let t = std::fs::read_to_string(&path).map_err(|e| format!("{rel}: {e}"))?;
            let end = t.rfind(')').ok_or("board file has no closing bracket")?;
            Board::parse(format!("{}\n{}\n)\n", &t[..end], inserts.join("\n")))?
        };
        let mut rules = match e.get("spec").and_then(|x| x.as_str()) {
            Some(s) => Rules::load(base.join(s).to_str().unwrap_or(s))?,
            None => Rules::default(),
        };
        let pro = e.get("project").and_then(|x| x.as_str()).map(|p| base.join(p)).unwrap_or_else(|| path.with_extension("kicad_pro"));
        project::apply_files(&pro, &path.with_extension("kicad_dru"), &mut rules);
        for k in strs(e.get("kelvin")) {
            rules.kelvin.push(k);
        }
        rules.bind(&board);
        classify::find_pairs(&board, &mut rules);
        let (f, s) = check::check(&board, &rules);
        let errors: Vec<&Finding> = f.iter().filter(|x| x.severity == "error").collect();
        let count = |rule: &str| errors.iter().filter(|x| x.rule == rule).count();
        let mut fails: Vec<String> = Vec::new();

        let known_at = e.get("knownAt").and_then(|x| x.as_array()).cloned().unwrap_or_default();
        let listed: Vec<(String, f64, f64)> = known_at
            .iter()
            .filter_map(|k| Some((k.get("rule")?.as_str()?.to_string(), k.pointer("/at/0")?.as_f64()?, k.pointer("/at/1")?.as_f64()?)))
            .collect();
        let known_count: BTreeMap<String, usize> = e.get("knownCount").and_then(|x| x.as_object()).map(|o| o.iter().map(|(k, c)| (k.clone(), c.as_u64().unwrap_or(0) as usize)).collect()).unwrap_or_default();
        let is_known = |x: &Finding| known_count.contains_key(x.rule) || listed.iter().any(|(r, a, b)| r == x.rule && (a - x.at.0).abs() <= NEAR_MM && (b - x.at.1).abs() <= NEAR_MM);

        // A finished board: connected, and every geometry rule clean apart from what is listed.
        if e.get("finished").and_then(|x| x.as_bool()).unwrap_or(false) {
            if s.nets_open != 0 {
                fails.push(format!("{} nets open; KiCad and the reference board say 0", s.nets_open));
            }
            for x in errors.iter().filter(|x| GEOMETRY_RULES.contains(&x.rule) && !is_known(x)) {
                fails.push(format!("new finding {} at ({:.2}, {:.2}): {}", x.rule, x.at.0, x.at.1, x.text));
            }
            for (r, a, b) in &listed {
                if !f.iter().any(|x| x.rule == r && (a - x.at.0).abs() <= NEAR_MM && (b - x.at.1).abs() <= NEAR_MM) {
                    fails.push(format!("known {r} at ({a:.2}, {b:.2}) is no longer found: the list is stale or the check changed"));
                }
            }
            for (r, c) in &known_count {
                let got = f.iter().filter(|x| x.rule == r).count();
                if got != *c {
                    fails.push(format!("known {r}: expected {c}, found {got}"));
                }
            }
        }
        for r in strs(e.get("mustFail")) {
            if count(&r) == 0 {
                fails.push(format!("{r} must fail on this layout and did not"));
            }
        }
        for r in strs(e.get("mustPass")) {
            if count(&r) != 0 {
                fails.push(format!("{r} must pass on this layout and found {}", count(&r)));
            }
        }
        for r in strs(e.get("mustWarn")) {
            if !f.iter().any(|x| x.rule == r && x.severity != "error") {
                fails.push(format!("{r} must warn on this layout and did not"));
            }
        }
        // A fixture must add what it says and nothing else: the check fires on the fault it was
        // built for, and the board's other findings don't move.
        if let Some(add) = e.get("adds").and_then(|x| x.as_object()) {
            let plain = Board::read(path.to_str().unwrap_or(rel))?;
            let (f0, _) = check::check(&plain, &rules);
            let tally = |v: &[Finding]| {
                let mut m: BTreeMap<String, i64> = BTreeMap::new();
                for x in v.iter().filter(|x| x.severity == "error") {
                    *m.entry(x.rule.to_string()).or_insert(0) += 1;
                }
                m
            };
            let (t0, t1) = (tally(&f0), tally(&f));
            let mut rules_seen: Vec<&String> = t0.keys().chain(t1.keys()).collect();
            rules_seen.sort();
            rules_seen.dedup();
            for r in rules_seen {
                let d = t1.get(r).copied().unwrap_or(0) - t0.get(r).copied().unwrap_or(0);
                let want = add.get(r).and_then(|x| x.as_i64()).unwrap_or(0);
                if d != want {
                    fails.push(format!("the edit moved {r} by {d}, expected {want}"));
                }
            }
            for (r, w) in add {
                if !t1.contains_key(r.as_str()) && w.as_i64().unwrap_or(0) != 0 {
                    fails.push(format!("{r} never fired on the edit"));
                }
            }
        }
        let mut per: BTreeMap<&str, usize> = BTreeMap::new();
        for x in &f {
            *per.entry(x.rule).or_insert(0) += 1;
        }
        let known_n = errors.iter().filter(|x| is_known(x)).count();
        let pass = fails.is_empty();
        ok &= pass;
        lines.push(format!("{} {name}: {} errors ({} known), {} warnings, {} nets open. {:?}", if pass { "PASS" } else { "FAIL" }, errors.len(), known_n, f.len() - errors.len(), s.nets_open, per));
        for x in &fails {
            lines.push(format!("    {x}"));
        }
        if let Some(k) = e.get("kicad") {
            lines.push(format!("    KiCad DRC, last run by hand: {k}"));
        }
        out.push(json!({"name": name, "pass": pass, "errors": errors.len(), "known": known_n, "warnings": f.len() - errors.len(), "netsOpen": s.nets_open, "perRule": per, "fails": fails}));
    }
    Ok(Outcome { ok, lines, json: json!({"ok": ok, "boards": out}) })
}