master
Colby Knox Restore 25 files to their state before PR #61: the wiki's pr revert left them truncated to the diff's context 4f3a0f4 8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
//! Schematic to PNG and visual baselines.
//!
//! `kicad-cli sch export svg --output <dir> <root>` writes one SVG per sheet (the root as
//! `<stem>.svg`, sub-sheets as `<stem>-<sheet path>.svg`). The SVG is rasterised in-process
//! with resvg (pure Rust: usvg parses, tiny-skia paints), so no Inkscape, cairo or browser is
//! needed on the desktop. KiCad plots text as strokes (the `<text>` elements it adds are
//! invisible, for search), so resvg is built without its font stack.
//!
//! A baseline is a PNG plus a small JSON (width, source hash, renderer) under
//! `<project>/.adom/baselines/`. Compare renders at the baseline's width and reports the share
//! of pixels that changed and the bounding box of the change, in pixels and in sheet mm.
//!
//! The kicad-cli call goes through `render::Runner`, so the argv and the file pick are unit
//! tested with canned output and no KiCad.

use std::path::{Path, PathBuf};
use std::time::Duration;

use resvg::tiny_skia::{self, Pixmap};
use resvg::usvg;
use serde_json::{json, Value};

use crate::render::Runner;

#[derive(Clone, Debug)]
pub struct SvgOpts {
    pub drawing_sheet: bool,
    pub black_and_white: bool,
    pub theme: Option<String>,
}

impl Default for SvgOpts {
    fn default() -> Self {
        SvgOpts { drawing_sheet: true, black_and_white: false, theme: None }
    }
}

fn norm(p: &Path) -> String {
    p.to_string_lossy().replace('\\', "/")
}

/// The kicad-cli argv (no program name): `sch export svg --output <dir> [...] <file>`.
pub fn svg_argv(sch: &Path, out_dir: &Path, o: &SvgOpts) -> Vec<String> {
    let mut v: Vec<String> = vec!["sch".into(), "export".into(), "svg".into(), "--output".into(), norm(out_dir)];
    if !o.drawing_sheet {
        v.push("--exclude-drawing-sheet".into());
    }
    if o.black_and_white {
        v.push("--black-and-white".into());
    }
    if let Some(t) = &o.theme {
        v.push("--theme".into());
        v.push(t.clone());
    }
    v.push(norm(sch));
    v
}

/// Run the export into `out_dir` (emptied of old SVGs first) and list what it wrote.
pub fn export_svgs(run: Runner, sch: &Path, out_dir: &Path, o: &SvgOpts, timeout: Duration) -> Result<Vec<PathBuf>, String> {
    std::fs::create_dir_all(out_dir).map_err(|e| format!("cannot create {}: {e}", out_dir.display()))?;
    for old in list_svgs(out_dir) {
        let _ = std::fs::remove_file(old);
    }
    let r = run(&svg_argv(sch, out_dir, o), timeout);
    let files = list_svgs(out_dir);
    if r.timed_out {
        return Err(format!("kicad-cli sch export svg timed out after {} s", timeout.as_secs()));
    }
    if files.is_empty() {
        let tail: String = r.stderr.trim().chars().rev().take(400).collect::<Vec<_>>().into_iter().rev().collect();
        return Err(format!("kicad-cli sch export svg wrote no SVG (exit {}): {}", r.exit_code, if tail.is_empty() { r.stdout.trim().to_string() } else { tail }));
    }
    Ok(files)
}

pub fn list_svgs(dir: &Path) -> Vec<PathBuf> {
    let mut v: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().and_then(|x| x.to_str()).map_or(false, |x| x.eq_ignore_ascii_case("svg"))).collect();
    v.sort();
    v
}

fn stem(p: &Path) -> String {
    p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()
}

fn squash(s: &str) -> String {
    s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
}

/// Pick the SVG for one sheet. `sheet` is None for the root, else (sheet name, human path like
/// `/Power/Filter/`). Exact `<stem>-<path>` first, then a name suffix, then a substring.
pub fn pick_svg(files: &[PathBuf], root_stem: &str, sheet: Option<(&str, &str)>) -> Option<PathBuf> {
    match sheet {
        None => files.iter().find(|f| stem(f) == root_stem).cloned().or_else(|| files.iter().min_by_key(|f| stem(f).len()).cloned()),
        Some((name, human)) => {
            let path_part = human.trim_matches('/').replace('/', "-");
            let exact = squash(&format!("{root_stem}-{path_part}"));
            if let Some(f) = files.iter().find(|f| squash(&stem(f)) == exact) {
                return Some(f.clone());
            }
            let n = squash(name);
            files
                .iter()
                .filter(|f| stem(f) != root_stem)
                .find(|f| squash(&stem(f)).ends_with(&n))
                .or_else(|| files.iter().filter(|f| stem(f) != root_stem).find(|f| squash(&stem(f)).contains(&n)))
                .cloned()
        }
    }
}

pub struct Raster {
    pub pixmap: Pixmap,
    pub width_mm: f64,
    pub height_mm: f64,
}

/// Rasterise SVG bytes to `width` pixels wide (height follows the aspect), on white.
pub fn rasterize(svg: &[u8], width: u32) -> Result<Raster, String> {
    if !(16..=16384).contains(&width) {
        return Err(format!("width {width} is out of range (16 to 16384 px)"));
    }
    let tree = usvg::Tree::from_data(svg, &usvg::Options::default()).map_err(|e| format!("cannot parse the SVG: {e}"))?;
    let size = tree.size();
    let (w_px, h_px) = (size.width() as f64, size.height() as f64);
    if w_px <= 0.0 || h_px <= 0.0 {
        return Err("the SVG has no size".into());
    }
    let scale = width as f64 / w_px;
    let height = (h_px * scale).round().max(1.0) as u32;
    if (width as u64) * (height as u64) > 200_000_000 {
        return Err(format!("{width} x {height} px is too large; pass a smaller width"));
    }
    let mut pixmap = Pixmap::new(width, height).ok_or("cannot allocate the image")?;
    pixmap.fill(tiny_skia::Color::WHITE);
    resvg::render(&tree, tiny_skia::Transform::from_scale(scale as f32, scale as f32), &mut pixmap.as_mut());
    // usvg resolves mm to px at 96 dpi
    Ok(Raster { pixmap, width_mm: w_px * 25.4 / 96.0, height_mm: h_px * 25.4 / 96.0 })
}

/// A proof frame of parts on a sheet: the plotted page cropped to `regions` (sheet mm, y down, [x0, y0, x1, y1]) plus
/// `margin_mm`, each region outlined, about `width` px wide. Returns the image and the framed region in sheet mm.
pub fn frame(svg: &[u8], regions: &[[f64; 4]], margin_mm: f64, width: u32) -> Result<(Pixmap, [f64; 4]), String> {
    if regions.is_empty() {
        return Err("nothing to frame".into());
    }
    let mut u = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
    for r in regions {
        u = [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])];
    }
    let m = margin_mm.max(0.0);
    let mut box_mm = [u[0] - m, u[1] - m, u[2] + m, u[3] + m];
    // the page at a scale that makes the framed region `width` px wide, within the rasteriser's limit
    let page = rasterize(svg, 64)?;
    let crop_w = (box_mm[2] - box_mm[0]).max(1.0);
    let full = ((width as f64) * page.width_mm / crop_w).round().clamp(64.0, 16384.0) as u32;
    let raster = rasterize(svg, full)?;
    let ppm = raster.pixmap.width() as f64 / raster.width_mm;
    box_mm = [box_mm[0].max(0.0), box_mm[1].max(0.0), box_mm[2].min(raster.width_mm), box_mm[3].min(raster.height_mm)];
    let px = |v: f64| (v * ppm).round().max(0.0) as i32;
    let (x0, y0) = (px(box_mm[0]), px(box_mm[1]));
    let (w, h) = ((px(box_mm[2]) - x0).max(1) as u32, (px(box_mm[3]) - y0).max(1) as u32);
    let rect = tiny_skia::IntRect::from_xywh(x0, y0, w, h).ok_or("the framed region is empty")?;
    let mut crop = raster.pixmap.clone_rect(rect).ok_or("the framed region is off the page")?;
    let mut paint = tiny_skia::Paint::default();
    paint.set_color_rgba8(220, 30, 30, 255);
    paint.anti_alias = true;
    let stroke = tiny_skia::Stroke { width: (0.15 * ppm as f32).max(2.0), ..Default::default() };
    for r in regions {
        let pad = 1.0;
        let (rx, ry) = (((r[0] - pad) * ppm) as f32 - x0 as f32, ((r[1] - pad) * ppm) as f32 - y0 as f32);
        if let Some(rr) = tiny_skia::Rect::from_xywh(rx, ry, ((r[2] - r[0] + 2.0 * pad) * ppm) as f32, ((r[3] - r[1] + 2.0 * pad) * ppm) as f32) {
            let path = tiny_skia::PathBuilder::from_rect(rr);
            crop.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);
        }
    }
    let r2 = |v: f64| (v * 100.0).round() / 100.0;
    Ok((crop, [r2(box_mm[0]), r2(box_mm[1]), r2(box_mm[2]), r2(box_mm[3])]))
}

pub struct Diff {
    pub changed: u64,
    pub total: u64,
    pub percent: f64,
    /// (x0, y0, x1, y1) inclusive, pixels.
    pub bbox: Option<(u32, u32, u32, u32)>,
    /// The current image with changed pixels painted red and the rest faded.
    pub image: Pixmap,
}

/// Compare two same-size images: a pixel changed when any channel moved more than `tol`.
pub fn diff(base: &Pixmap, cur: &Pixmap, tol: u8) -> Result<Diff, String> {
    if base.width() != cur.width() || base.height() != cur.height() {
        return Err(format!("sizes differ: baseline {}x{}, now {}x{}", base.width(), base.height(), cur.width(), cur.height()));
    }
    let (w, h) = (cur.width(), cur.height());
    let mut out = Pixmap::new(w, h).ok_or("cannot allocate the diff image")?;
    let (a, b) = (base.data(), cur.data());
    let o = out.data_mut();
    let mut changed = 0u64;
    let mut bb: Option<(u32, u32, u32, u32)> = None;
    for i in 0..(w as usize * h as usize) {
        let k = i * 4;
        let moved = (0..3).any(|c| a[k + c].abs_diff(b[k + c]) > tol);
        if moved {
            changed += 1;
            let (x, y) = ((i % w as usize) as u32, (i / w as usize) as u32);
            bb = Some(match bb {
                None => (x, y, x, y),
                Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
            });
            o[k] = 230;
            o[k + 1] = 0;
            o[k + 2] = 0;
            o[k + 3] = 255;
        } else {
            // faded copy of the current image for context
            for c in 0..3 {
                o[k + c] = 255 - (255 - b[k + c]) / 4;
            }
            o[k + 3] = 255;
        }
    }
    let total = w as u64 * h as u64;
    Ok(Diff { changed, total, percent: if total == 0 { 0.0 } else { changed as f64 * 100.0 / total as f64 }, bbox: bb, image: out })
}

/// FNV-1a over the bytes of every file, in order: the baseline's source hash.
pub fn source_hash(files: &[PathBuf]) -> String {
    let mut h: u64 = 0xcbf29ce484222325;
    for f in files {
        for b in std::fs::read(f).unwrap_or_default() {
            h ^= b as u64;
            h = h.wrapping_mul(0x100000001b3);
        }
    }
    format!("{h:016x}")
}

/// A file-name-safe key for a sheet: `hier`, `hier-Power-Filter`.
pub fn baseline_key(root_stem: &str, human: &str) -> String {
    let p = human.trim_matches('/');
    let raw = if p.is_empty() { root_stem.to_string() } else { format!("{root_stem}-{}", p.replace('/', "-")) };
    raw.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect()
}

pub fn baseline_dir(project_dir: &Path) -> PathBuf {
    project_dir.join(".adom").join("baselines")
}

/// Write a PNG atomically.
pub fn save_png(p: &Pixmap, path: &Path) -> Result<(), String> {
    let bytes = p.encode_png().map_err(|e| format!("cannot encode PNG: {e}"))?;
    if let Some(d) = path.parent() {
        std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
    }
    crate::libraries::write_atomic(path, &bytes)
}

pub fn load_png(path: &Path) -> Result<Pixmap, String> {
    Pixmap::load_png(path).map_err(|e| format!("cannot read {}: {e}", path.display()))
}

/// Pixel box to sheet millimetres.
pub fn bbox_mm(b: (u32, u32, u32, u32), px_per_mm: f64) -> Value {
    let f = |v: u32| ((v as f64 / px_per_mm) * 100.0).round() / 100.0;
    json!([f(b.0), f(b.1), f(b.2 + 1), f(b.3 + 1)])
}

/// What `render_sheet` should do with the image.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BaselineMode {
    None,
    Set,
    Compare,
}

pub struct RenderJob<'a> {
    /// The root sheet kicad-cli plots (sub-sheets are plotted from it).
    pub root: &'a Path,
    /// None for the root page, else (sheet name, human path `/Power/`).
    pub sheet: Option<(&'a str, &'a str)>,
    pub width: u32,
    pub out_dir: &'a Path,
    pub project_dir: &'a Path,
    pub svg: SvgOpts,
    pub mode: BaselineMode,
    /// Allowed drift in percent of pixels for a compare to pass.
    pub threshold: f64,
    /// kicad-cli version (or path) recorded with a baseline.
    pub renderer: String,
    /// Schematic files whose bytes make the source hash.
    pub sources: Vec<PathBuf>,
}

/// A failure with the reply's errorCode and hint.
#[derive(Debug)]
pub struct RenderError {
    pub code: &'static str,
    pub error: String,
    pub hint: String,
}

fn rerr(code: &'static str, error: impl Into<String>, hint: impl Into<String>) -> RenderError {
    RenderError { code, error: error.into(), hint: hint.into() }
}

/// Export, pick the sheet's SVG, rasterise to PNG, and set or compare the baseline.
pub fn render_sheet(run: Runner, job: &RenderJob) -> Result<Value, RenderError> {
    let svg_dir = job.out_dir.join("svg");
    let files = export_svgs(run, job.root, &svg_dir, &job.svg, Duration::from_secs(240)).map_err(|e| rerr("cli_failed", e, "kicad-cli could not plot the schematic: run kicad_run_erc or kicad_lint_schematic on the root sheet for the reason, and check the file opens in KiCad."))?;
    let root_stem = stem(job.root);
    let svg = pick_svg(&files, &root_stem, job.sheet).ok_or_else(|| {
        rerr(
            "sheet_not_found",
            format!("no SVG for sheet {} among {}", job.sheet.map(|s| s.0).unwrap_or("root"), files.iter().map(|f| stem(f)).collect::<Vec<_>>().join(", ")),
            "kicad_sch_hierarchy lists the sheet names; pass sheet as one of them, or omit it for the root page.",
        )
    })?;
    let human = job.sheet.map(|s| s.1).unwrap_or("/");
    let key = baseline_key(&root_stem, human);
    let base_png = baseline_dir(job.project_dir).join(format!("{key}.png"));
    let base_json = baseline_dir(job.project_dir).join(format!("{key}.json"));
    let meta: Option<Value> = std::fs::read_to_string(&base_json).ok().and_then(|t| serde_json::from_str(&t).ok());
    let width = if job.mode == BaselineMode::Compare { meta.as_ref().and_then(|m| m["width"].as_u64()).map(|w| w as u32).unwrap_or(job.width) } else { job.width };
    let bytes = std::fs::read(&svg).map_err(|e| rerr("read_failed", format!("cannot read {}: {e}", svg.display()), "The export folder must be readable."))?;
    let raster = rasterize(&bytes, width).map_err(|e| rerr("render_failed", e, "The SVG kicad-cli wrote could not be rasterised; the svg path in the reply is still usable."))?;
    let png = job.out_dir.join(format!("{key}.png"));
    save_png(&raster.pixmap, &png).map_err(|e| rerr("write_failed", e, "Pass an outputDir the desktop user can write."))?;
    let hash = source_hash(&job.sources);
    let px_per_mm = raster.pixmap.width() as f64 / raster.width_mm;
    let mut v = json!({
        "png": norm(&png),
        "svg": norm(&svg),
        "svgFiles": files.iter().map(|f| norm(f)).collect::<Vec<_>>(),
        "sheet": job.sheet.map(|s| s.0).unwrap_or("Root"),
        "sheetPath": human,
        "width": raster.pixmap.width(),
        "height": raster.pixmap.height(),
        "pageMm": [((raster.width_mm * 100.0).round() / 100.0), ((raster.height_mm * 100.0).round() / 100.0)],
        "sourceHash": hash,
    });
    match job.mode {
        BaselineMode::None => {}
        BaselineMode::Set => {
            save_png(&raster.pixmap, &base_png).map_err(|e| rerr("write_failed", e, "The project folder must be writable for baselines."))?;
            let m = json!({"sheet": v["sheet"], "sheetPath": human, "width": raster.pixmap.width(), "height": raster.pixmap.height(), "sourceHash": hash, "renderer": job.renderer, "rasterizer": "resvg 0.45", "createdAt": now_iso()});
            crate::libraries::write_atomic(&base_json, serde_json::to_string_pretty(&m).unwrap_or_default().as_bytes()).map_err(|e| rerr("write_failed", e, "The project folder must be writable for baselines."))?;
            v["baseline"] = json!({"set": true, "png": norm(&base_png), "meta": norm(&base_json), "replaced": meta.is_some()});
        }
        BaselineMode::Compare => {
            let Some(meta) = meta else {
                v["baseline"] = json!({"stored": false, "png": norm(&base_png)});
                return Err(RenderError { code: "no_baseline", error: format!("no baseline stored for {key} (looked for {})", norm(&base_png)), hint: "Set one first: kicad_render_schematic {baseline:\"set\"} on the same sheet, then compare after the edit.".into() });
            };
            let base = load_png(&base_png).map_err(|e| rerr("no_baseline", e, "The baseline PNG is missing or damaged: set it again with baseline:\"set\"."))?;
            let renderer_changed = meta["renderer"].as_str().map_or(false, |r| r != job.renderer);
            let source_changed = meta["sourceHash"].as_str().map_or(true, |h| h != hash);
            match diff(&base, &raster.pixmap, 24) {
                Ok(d) => {
                    let diff_png = job.out_dir.join(format!("{key}-diff.png"));
                    let _ = save_png(&d.image, &diff_png);
                    let pass = d.percent <= job.threshold;
                    v["baseline"] = json!({
                        "stored": true,
                        "png": norm(&base_png),
                        "createdAt": meta["createdAt"],
                        "driftPercent": (d.percent * 1000.0).round() / 1000.0,
                        "changedPixels": d.changed,
                        "totalPixels": d.total,
                        "threshold": job.threshold,
                        "pass": pass,
                        "changedRegionPx": d.bbox.map(|b| json!([b.0, b.1, b.2, b.3])),
                        "changedRegionMm": d.bbox.map(|b| bbox_mm(b, px_per_mm)),
                        "diffPng": norm(&diff_png),
                        "sourceChanged": source_changed,
                        "rendererChanged": renderer_changed,
                        "baselineRenderer": meta["renderer"],
                    });
                }
                Err(e) => {
                    v["baseline"] = json!({"stored": true, "png": norm(&base_png), "sizeChanged": true, "detail": e, "driftPercent": 100.0, "pass": false, "threshold": job.threshold, "sourceChanged": source_changed, "rendererChanged": renderer_changed});
                }
            }
        }
    }
    Ok(v)
}

fn now_iso() -> String {
    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    let days = (secs / 86400) as i64;
    let rem = secs % 86400;
    // civil from days (Howard Hinnant)
    let z = days + 719468;
    let era = z.div_euclid(146097);
    let doe = z.rem_euclid(146097);
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
}

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

    fn fixture_svg() -> Vec<u8> {
        std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch_hier/mcu-kicad10.svg")).unwrap()
    }

    #[test]
    fn argv_is_kicad_cli_shape() {
        let a = svg_argv(Path::new("C:\\p\\x.kicad_sch"), Path::new("C:/out"), &SvgOpts { drawing_sheet: false, black_and_white: true, theme: Some("KiCad Classic".into()) });
        assert_eq!(a, vec!["sch", "export", "svg", "--output", "C:/out", "--exclude-drawing-sheet", "--black-and-white", "--theme", "KiCad Classic", "C:/p/x.kicad_sch"]);
        assert_eq!(svg_argv(Path::new("/p/x.kicad_sch"), Path::new("/o"), &SvgOpts::default()).len(), 6);
    }

    #[test]
    fn pick_by_sheet() {
        let files: Vec<PathBuf> = ["hier.svg", "hier-Power.svg", "hier-Power-Filter.svg", "hier-MCU.svg"].iter().map(PathBuf::from).collect();
        assert_eq!(pick_svg(&files, "hier", None).unwrap(), PathBuf::from("hier.svg"));
        assert_eq!(pick_svg(&files, "hier", Some(("Filter", "/Power/Filter/"))).unwrap(), PathBuf::from("hier-Power-Filter.svg"));
        assert_eq!(pick_svg(&files, "hier", Some(("Power", "/Power/"))).unwrap(), PathBuf::from("hier-Power.svg"));
        assert_eq!(pick_svg(&files, "hier", Some(("mcu", "/x/"))).unwrap(), PathBuf::from("hier-MCU.svg"));
        assert!(pick_svg(&files, "hier", Some(("Nope", "/Nope/"))).is_none());
    }

    #[test]
    fn export_through_a_canned_runner() {
        let dir = std::env::temp_dir().join(format!("sch-render-{}-{}", std::process::id(), crate::schematic::new_uuid()));
        let svg = fixture_svg();
        let seen = std::sync::Mutex::new(Vec::<String>::new());
        let run = |argv: &[String], _t: Duration| -> CliResult {
            *seen.lock().unwrap() = argv.to_vec();
            let out = PathBuf::from(&argv[4]);
            std::fs::write(out.join("hier.svg"), &svg).unwrap();
            std::fs::write(out.join("hier-Power.svg"), &svg).unwrap();
            CliResult { exit_code: 0, stdout: "Plotted to 'hier.svg'.\nDone.\n".into(), stderr: String::new(), elapsed_ms: 5, timed_out: false }
        };
        let files = export_svgs(&run, Path::new("/p/hier.kicad_sch"), &dir, &SvgOpts::default(), Duration::from_secs(5)).unwrap();
        assert_eq!(files.len(), 2);
        assert_eq!(seen.lock().unwrap()[..3], ["sch", "export", "svg"]);
        // a failing kicad-cli: no SVG, stderr surfaces
        let bad = |_: &[String], _: Duration| CliResult { exit_code: 3, stdout: String::new(), stderr: "Failed to load schematic".into(), elapsed_ms: 1, timed_out: false };
        let e = export_svgs(&bad, Path::new("/p/hier.kicad_sch"), &dir, &SvgOpts::default(), Duration::from_secs(5)).unwrap_err();
        assert!(e.contains("exit 3") && e.contains("Failed to load"), "{e}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn rasterize_and_diff_a_kicad_svg() {
        let svg = fixture_svg();
        let r = rasterize(&svg, 1200).unwrap();
        assert_eq!(r.pixmap.width(), 1200);
        assert!((r.width_mm - 297.0).abs() < 0.1, "{}", r.width_mm);
        let h = r.pixmap.height();
        assert!((h as f64 - 1200.0 * 210.0 / 297.0).abs() < 2.0);
        // something got drawn: not all white
        if let Ok(dir) = std::env::var("SCH_RENDER_DUMP") {
            save_png(&r.pixmap, &Path::new(&dir).join("mcu.png")).unwrap();
        }
        let dark = r.pixmap.data().chunks(4).filter(|p| p[0].min(p[1]).min(p[2]) < 180).count();
        assert!(dark > 1000, "only {dark} dark pixels");
        let same = diff(&r.pixmap, &r.pixmap, 16).unwrap();
        assert_eq!(same.changed, 0);
        assert!(same.bbox.is_none());
        // move a wire: the drift lands where the wire is
        let text = String::from_utf8(svg.clone()).unwrap();
        let edited = text.replacen("</svg>", "<rect x=\"100\" y=\"100\" width=\"20\" height=\"10\" fill=\"#000000\"/></svg>", 1);
        let r2 = rasterize(edited.as_bytes(), 1200).unwrap();
        let d = diff(&r.pixmap, &r2.pixmap, 16).unwrap();
        assert!(d.percent > 0.25 && d.percent < 0.5, "{}", d.percent); // 20 x 10 mm of 297 x 210
        let (x0, y0, x1, y1) = d.bbox.unwrap();
        let ppm = 1200.0 / r.width_mm;
        let mm = bbox_mm((x0, y0, x1, y1), ppm);
        assert!((mm[0].as_f64().unwrap() - 100.0).abs() < 0.5 && (mm[2].as_f64().unwrap() - 120.0).abs() < 0.5, "{mm}");
        assert!(rasterize(b"not svg", 100).is_err());
        assert!(diff(&r.pixmap, &rasterize(&svg, 600).unwrap().pixmap, 16).is_err());
    }

    #[test]
    fn baseline_set_then_compare() {
        let dir = std::env::temp_dir().join(format!("sch-baseline-{}-{}", std::process::id(), crate::schematic::new_uuid()));
        std::fs::create_dir_all(&dir).unwrap();
        let root = dir.join("mcu.kicad_sch");
        std::fs::write(&root, "(kicad_sch (version 20260306))").unwrap();
        let svg = std::sync::Mutex::new(fixture_svg());
        let run = |argv: &[String], _t: Duration| -> CliResult {
            std::fs::write(PathBuf::from(&argv[4]).join("mcu.svg"), &*svg.lock().unwrap()).unwrap();
            CliResult { exit_code: 0, stdout: String::new(), stderr: String::new(), elapsed_ms: 1, timed_out: false }
        };
        let out = dir.join("render");
        let mut job = RenderJob { root: &root, sheet: None, width: 600, out_dir: &out, project_dir: &dir, svg: SvgOpts::default(), mode: BaselineMode::Compare, threshold: 0.1, renderer: "kicad-cli 10.0.2".into(), sources: vec![root.clone()] };
        let e = render_sheet(&run, &job).unwrap_err();
        assert_eq!(e.code, "no_baseline");
        job.mode = BaselineMode::Set;
        let v = render_sheet(&run, &job).unwrap();
        assert_eq!(v["baseline"]["set"], true);
        assert!(dir.join(".adom/baselines/mcu.png").is_file() && dir.join(".adom/baselines/mcu.json").is_file());
        // unchanged: zero drift, compare renders at the baseline's width even when asked otherwise
        job.mode = BaselineMode::Compare;
        job.width = 900;
        let v = render_sheet(&run, &job).unwrap();
        assert_eq!(v["width"], 600);
        assert_eq!(v["baseline"]["driftPercent"], 0.0);
        assert_eq!(v["baseline"]["pass"], true);
        assert_eq!(v["baseline"]["sourceChanged"], false);
        // an edit: a block appears at x 200..220 mm
        let edited = String::from_utf8(fixture_svg()).unwrap().replacen("</svg>", "<rect x=\"200\" y=\"150\" width=\"20\" height=\"20\" fill=\"#000000\"/></svg>", 1);
        *svg.lock().unwrap() = edited.into_bytes();
        std::fs::write(&root, "(kicad_sch (version 20260306) (uuid \"x\"))").unwrap();
        let v = render_sheet(&run, &job).unwrap();
        let b = &v["baseline"];
        assert_eq!(b["pass"], false, "{b}");
        assert_eq!(b["sourceChanged"], true);
        let mm = b["changedRegionMm"].as_array().unwrap();
        assert!((mm[0].as_f64().unwrap() - 200.0).abs() < 1.0 && (mm[3].as_f64().unwrap() - 170.0).abs() < 1.0, "{mm:?}");
        assert!(PathBuf::from(b["diffPng"].as_str().unwrap()).is_file());
        // another renderer is flagged, never silently trusted
        job.renderer = "kicad-cli 10.0.3".into();
        assert_eq!(render_sheet(&run, &job).unwrap()["baseline"]["rendererChanged"], true);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn iso_dates_look_right() {
        let s = now_iso();
        assert_eq!(s.len(), 20);
        assert!(s.starts_with("20") && s.ends_with('Z'));
    }

    #[test]
    fn keys_are_file_safe() {
        assert_eq!(baseline_key("hier", "/"), "hier");
        assert_eq!(baseline_key("hier", "/Power/Filter/"), "hier-Power-Filter");
        assert_eq!(baseline_key("hier", "/A B:C/"), "hier-A_B_C");
    }

    #[test]
    fn a_frame_crops_the_page_to_the_parts_and_outlines_them() {
        // an A4-sized page (297 x 210 mm) with one black box at 100..110, 50..60 mm
        let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="297mm" height="210mm" viewBox="0 0 297 210"><rect x="100" y="50" width="10" height="10" fill="black"/></svg>"#;
        let (img, region) = super::frame(svg, &[[100.0, 50.0, 110.0, 60.0]], 10.0, 600).unwrap();
        assert_eq!(region, [90.0, 40.0, 120.0, 70.0]);
        assert!((img.width() as i32 - 600).abs() <= 2, "{}", img.width());
        // the box sits in the middle of the frame, and the outline is red
        let mid = img.pixel(img.width() / 2, img.height() / 2).unwrap();
        assert!(mid.red() < 40 && mid.green() < 40);
        let reds = img.pixels().iter().filter(|p| p.red() > 180 && p.green() < 80).count();
        assert!(reds > 100, "{reds}");
        assert!(super::frame(svg, &[], 5.0, 600).is_err());
    }
}