main
Caleb Publish 0.2.0 cbe04a0 11h ago
//! Fast tests cut from the reference boards: a few nets of a reference board routed alone, in seconds, with
//! pass/fail checks on the result. The whole-board benchmark takes 13 minutes on benchmark C and says
//! only how many joins were made; a fixture says whether one behaviour is right (the feed to U10
//! holds its width, the trunk does not run wide to a part that draws 3 mA) and says it at once.
//!
//! A fixture strips the board, keeps every pad and pour as it is, and routes only the nets it
//! names. The other nets stay unrouted, so it tests a behaviour, not congestion: the whole-board
//! benchmark is still what says the board routes.
//!
//! `known_fail` marks a fixture that records a target the router does not meet yet. It is reported
//! and does not fail the run; when it starts passing the run says so, and the mark comes off.

use crate::board::*;
use crate::geom::*;
use serde_json::Value;
use std::path::Path;

struct Outcome {
    pass: bool,
    lines: Vec<String>,
}

/// Every fixture at once, each in its own process (each has its own work clock, so running side by
/// side changes no result), reported in the file's order. The set takes as long as its slowest one.
pub fn run_all(file: &str, only: Option<&str>, keep: Option<&str>) -> bool {
    let text = std::fs::read_to_string(file).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), "Pass the fixtures JSON, e.g. bench/fixtures.json."));
    let v: Value = serde_json::from_str(&text).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), ""));
    let names: Vec<String> = v["fixtures"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(String::from)).collect()).unwrap_or_default();
    let names: Vec<String> = names.into_iter().filter(|n| only.map(|o| n.contains(o)).unwrap_or(true)).collect();
    let exe = std::env::current_exe().unwrap_or_else(|e| crate::fail(e, ""));
    let t0 = std::time::Instant::now();
    let kids: Vec<(String, std::process::Child)> = names.iter().map(|n| {
        let mut c = std::process::Command::new(&exe);
        c.args(["fixtures", file, "--one", n]);
        if let Some(k) = keep {
            c.args(["--keep", k]);
        }
        (n.clone(), c.stdout(std::process::Stdio::piped()).spawn().unwrap_or_else(|e| crate::fail(e, "")))
    }).collect();
    let (mut ok, mut skipped) = (true, 0usize);
    for (n, kid) in kids {
        let out = kid.wait_with_output().unwrap_or_else(|e| crate::fail(e, ""));
        let text = String::from_utf8_lossy(&out.stdout);
        for l in text.lines().filter(|l| !l.starts_with("OK: ") && !l.starts_with("ERROR: ")) {
            println!("{l}");
        }
        if text.contains("SKIP ") {
            skipped += 1;
        }
        if !out.status.success() {
            ok = false;
            if text.trim().is_empty() {
                println!("FAIL {n}: it stopped without a result");
            }
        }
    }
    println!("OK: {} fixtures run in {:.0} s, {skipped} skipped{}", names.len() - skipped, t0.elapsed().as_secs_f64(), if ok { "." } else { "; at least one FAIL above." });
    ok
}

pub fn run(file: &str, only: Option<&str>, keep: Option<&str>) -> bool {
    let text = std::fs::read_to_string(file).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), "Pass the fixtures JSON, e.g. bench/fixtures.json."));
    let v: Value = serde_json::from_str(&text).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), ""));
    let base = Path::new(file).parent().unwrap_or(Path::new("."));
    let work = keep.map(std::path::PathBuf::from).unwrap_or_else(|| std::env::temp_dir().join("adom-route-fixtures"));
    std::fs::create_dir_all(&work).unwrap_or_else(|e| crate::fail(e, ""));
    let (mut ok, mut ran, mut skipped) = (true, 0usize, 0usize);
    for fx in v["fixtures"].as_array().cloned().unwrap_or_default() {
        let name = fx["name"].as_str().unwrap_or("?").to_string();
        if only.map(|o| name != o).unwrap_or(false) {
            continue;
        }
        let board = base.join(fx["board"].as_str().unwrap_or(""));
        if !board.exists() {
            println!("SKIP {name}: its board is not here ({})", board.display());
            skipped += 1;
            continue;
        }
        let t0 = std::time::Instant::now();
        let out = one(&fx, &name, &board, base, &work);
        let known = fx["known_fail"].as_bool().unwrap_or(false);
        let secs = t0.elapsed().as_secs_f64();
        ran += 1;
        match (out.pass, known) {
            (true, false) => println!("PASS {name} ({secs:.1} s)"),
            (true, true) => println!("FIXED {name} ({secs:.1} s): it passes now, take its known_fail mark off"),
            (false, true) => println!("KNOWN FAIL {name} ({secs:.1} s): a target the router does not meet yet"),
            (false, false) => {
                println!("FAIL {name} ({secs:.1} s)");
                ok = false;
            }
        }
        for l in &out.lines {
            println!("  {l}");
        }
    }
    println!("{} {ran} fixtures run, {skipped} skipped.", if ok { "OK:" } else { "ERROR:" });
    ok
}

fn one(fx: &Value, name: &str, board: &Path, base: &Path, work: &Path) -> Outcome {
    let mut lines: Vec<String> = Vec::new();
    let spec: Option<String> = fx["spec"].as_str().map(|s| base.join(s).to_string_lossy().to_string());
    let nets: Vec<String> = fx["nets"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
    // Strip, with the project beside it so the same rules bind.
    let src = Board::read(&board.to_string_lossy()).unwrap_or_else(|e| crate::fail(e, ""));
    let stripped = work.join(format!("{name}.stripped.kicad_pcb"));
    let routed = work.join(format!("{name}.routed.kicad_pcb"));
    std::fs::write(&stripped, crate::write::strip(&src)).unwrap_or_else(|e| crate::fail(e, ""));
    let pro = board.with_extension("kicad_pro");
    if pro.exists() {
        for p in [&stripped, &routed] {
            let _ = std::fs::copy(&pro, p.with_extension("kicad_pro"));
        }
    }
    let (b, mut r) = crate::load(&stripped.to_string_lossy(), &spec);
    let missing: Vec<&String> = nets.iter().filter(|n| !b.net_ids.contains_key(n.as_str())).collect();
    if !missing.is_empty() {
        return Outcome { pass: false, lines: vec![format!("not on the board: {missing:?}")] };
    }
    let mut plan = crate::router::make_plan(&b, &r);
    let wanted = |n: NetId| nets.iter().any(|x| x == b.net_name(n));
    plan.tasks.retain(|t| wanted(t.net));
    plan.pairs.retain(|p| wanted(p.0) && wanted(p.1));
    r.time_limit = fx["limit"].as_f64().unwrap_or(60.0);
    let mut rt = crate::router::Router::new(&b, r.clone());
    rt.only = Some(nets.iter().filter_map(|n| b.net_ids.get(n.as_str()).copied()).collect());
    let o = rt.run(&plan);
    let mut routes: Vec<&crate::router::Route> = rt.routes.values().collect();
    routes.sort_by_key(|x| x.id);
    std::fs::write(&routed, crate::write::render(&b, &routes, &r)).unwrap_or_else(|e| crate::fail(e, ""));
    let _ = crate::project::write_dru(&stripped.to_string_lossy(), &routed.to_string_lossy(), &rt.rules.pad_rules);
    let _ = o;
    let _ = std::fs::write(work.join(format!("{name}.log")), rt.log.join("\n") + "\n" + &rt.notes.join("\n"));
    lines.push(format!("board kept at {}", routed.display()));
    // Judge the written board, as `check` would.
    let (rb, rr) = crate::load(&routed.to_string_lossy(), &spec);
    let (findings, _) = crate::check::check(&rb, &rr);
    let mine = |text: &str| nets.iter().any(|n| text.starts_with(&format!("{n}:")));
    let mut pass = true;
    let mut say = |ok: bool, what: String, lines: &mut Vec<String>| {
        lines.push(format!("{} {what}", if ok { "ok  " } else { "FAIL" }));
        if !ok {
            pass = false;
        }
    };
    for e in fx["expect"].as_array().cloned().unwrap_or_default() {
        if e.get("joined").is_some() {
            let open: Vec<&str> = findings.iter().filter(|f| f.rule == "unconnected" && mine(&f.text)).map(|f| f.text.as_str()).collect();
            say(open.is_empty(), format!("joined: every named net is one piece{}", if open.is_empty() { String::new() } else { format!(" ({})", open.join("; ")) }), &mut lines);
        }
        if let Some(rules) = e.get("clean").and_then(|x| x.as_array()) {
            for rule in rules.iter().filter_map(|x| x.as_str()) {
                let hits: Vec<&str> = findings.iter().filter(|f| f.rule == rule && mine(&f.text)).map(|f| f.text.as_str()).collect();
                say(hits.is_empty(), format!("clean {rule}{}", if hits.is_empty() { String::new() } else { format!(": {}", hits.join("; ")) }), &mut lines);
            }
        }
        if let Some(w) = e.get("width_near") {
            let (net, part) = (w["net"].as_str().unwrap_or(""), w["part"].as_str().unwrap_or(""));
            let radius = w["radius"].as_f64().unwrap_or(3.0) * NM;
            let id = rb.net_ids.get(net).copied();
            let pads: Vec<V> = rb.pads.iter().filter(|p| Some(p.net) == id && rb.footprints[p.fp].reference == part).map(|p| V::from(p.center)).collect();
            // Runs, not links: a track shorter than `min_len` (2 mm unless given) is a tie between a
            // pad and its via or capacitor, not the run to the part that this check is about.
            let min_len = w["min_len"].as_f64().unwrap_or(2.0) * NM;
            let near: Vec<i64> = rb.tracks.iter().filter(|t| Some(t.net) == id && hypot(sub(t.a.into(), t.b.into())) >= min_len && pads.iter().any(|c| pt_seg(*c, t.a.into(), t.b.into()) <= radius)).map(|t| t.width).collect();
            if near.is_empty() {
                say(false, format!("width near {part} on {net}: no track of the net within {:.1} mm of it", radius / NM), &mut lines);
                continue;
            }
            let (lo, hi) = (to_mm(*near.iter().min().unwrap()), to_mm(*near.iter().max().unwrap()));
            if let Some(m) = w["max"].as_f64() {
                say(hi <= m + 1e-6, format!("width near {part} on {net}: widest {hi:.2} mm, at most {m:.2} mm allowed"), &mut lines);
            }
            if let Some(m) = w["min"].as_f64() {
                say(lo >= m - 1e-6, format!("width near {part} on {net}: narrowest {lo:.2} mm, at least {m:.2} mm wanted"), &mut lines);
            }
        }
        if let Some(d) = e.get("drop_max") {
            let (net, load, pct) = (d["net"].as_str().unwrap_or(""), d["load"].as_str().unwrap_or(""), d["pct"].as_f64().unwrap_or(0.25));
            let found = rr.rails.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).and_then(|(_, rail)| {
                let id = rb.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| *v)?;
                let v = rail.volts?;
                crate::current::rail_drops(&rb, &rr, id, rail).into_iter().find(|x| x.load == load).map(|x| (x.volts, v))
            });
            match found {
                Some((volts, v)) => say(volts / v * 100.0 <= pct + 1e-6, format!("drop on {net} to {load}: {:.1} mV, {:.2}% of {v} V, at most {pct:.2}% allowed", volts * 1000.0, volts / v * 100.0), &mut lines),
                None => say(false, format!("drop on {net} to {load}: not found (no rail, voltage or path)"), &mut lines),
            }
        }
        if let Some(m) = e.get("max_vias").and_then(|x| x.as_u64()) {
            let n = rb.vias.iter().filter(|v| nets.iter().any(|x| x == rb.net_name(v.net))).count();
            say(n as u64 <= m, format!("vias on the named nets: {n}, at most {m} allowed"), &mut lines);
        }
        if let Some(p) = e.get("pair_coupled") {
            let (want, min) = (p["pair"].as_str().unwrap_or(""), p["min_pct"].as_f64().unwrap_or(80.0));
            match crate::compare::pairs(&rb, &rr).into_iter().find(|x| x.0 == want) {
                Some((_, lp, _, c, _)) => {
                    let pct = if lp > 0.0 { 100.0 * c / lp } else { 0.0 };
                    say(pct >= min, format!("pair {want}: {pct:.0}% coupled, at least {min:.0}% wanted"), &mut lines);
                }
                None => say(false, format!("pair {want}: not found on the routed board"), &mut lines),
            }
        }
    }
    Outcome { pass, lines }
}