master
Colby Knox Merge PR #357: kicad-lane: 1.0.90, swap_footprint, plate_vias, normalize_bodies, symbol_mode, explained refusals (#29) e955f3c 11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
//! Board and footprint edits on the repository's KiCad 10 boards: the blank template
//! (`templates/blank-board.kicad_pcb`) and the four-layer ESC G431 placement fixture
//! (`demo/placement/esc-g431-unrouted.kicad_pcb`, 149 footprints, a stack-up, inner planes).
//! Every edit is read back through the bridge's own board parser (`pcb::parse_pcb_text`)
//! and, when `service-kicad` is on PATH, through KiCad 10 itself (a DRC run must load it).

use std::path::{Path, PathBuf};
use std::process::Command;

use serde_json::{json, Value};

use kicad_core::board_edit::{self as be, Change};
use kicad_core::pcb;
use kicad_core::schematic::new_uuid;
use kicad_core::sxedit;

fn repo_file(rel: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
}

fn blank() -> String {
    std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).unwrap()
}

fn esc() -> String {
    std::fs::read_to_string(repo_file("demo/placement/esc-g431-unrouted.kicad_pcb")).unwrap()
}

fn parse(t: &str) -> pcb::Board {
    pcb::parse_pcb_text(t, "t.kicad_pcb").unwrap()
}

fn temp_dir(tag: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("kicad-core-board-edit-{tag}-{}-{}", std::process::id(), new_uuid()));
    std::fs::create_dir_all(&d).unwrap();
    d
}

const R0603: &str = "(footprint \"R_0603_1608Metric\"\n\t(version 20241229)\n\t(generator \"pcbnew\")\n\t(generator_version \"9.0\")\n\t(layer \"F.Cu\")\n\t(descr \"Resistor SMD 0603\")\n\t(property \"Reference\" \"REF**\" (at 0 -1.43 0) (layer \"F.SilkS\") (uuid \"8a2c3b4e-0000-4000-8000-000000000001\") (effects (font (size 1 1) (thickness 0.15))))\n\t(property \"Value\" \"R_0603_1608Metric\" (at 0 1.43 0) (layer \"F.Fab\") (uuid \"8a2c3b4e-0000-4000-8000-000000000002\") (effects (font (size 1 1) (thickness 0.15))))\n\t(attr smd)\n\t(fp_line (start -0.237258 -0.5225) (end 0.237258 -0.5225) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\") (uuid \"8a2c3b4e-0000-4000-8000-000000000003\"))\n\t(fp_rect (start -1.48 -0.73) (end 1.48 0.73) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"8a2c3b4e-0000-4000-8000-000000000004\"))\n\t(pad \"1\" smd roundrect (at -0.825 0) (size 0.8 0.95) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"8a2c3b4e-0000-4000-8000-000000000005\"))\n\t(pad \"2\" smd roundrect (at 0.825 0) (size 0.8 0.95) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"8a2c3b4e-0000-4000-8000-000000000006\"))\n\t(embedded_fonts no)\n\t(model \"${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))\n)\n";

fn service_kicad() -> Option<PathBuf> {
    let path = std::env::var_os("PATH")?;
    std::env::split_paths(&path).map(|d| d.join("service-kicad")).find(|p| p.is_file())
}

/// KiCad 10 loads the board (a DRC report comes back); None when service-kicad is absent.
fn kicad_loads(tag: &str, text: &str) -> Option<Value> {
    let svc = service_kicad()?;
    let dir = temp_dir(tag);
    let path = dir.join(format!("{tag}.kicad_pcb"));
    std::fs::write(&path, text).unwrap();
    let report = dir.join("drc.json");
    let out = Command::new(&svc).args(["pcb", "drc"]).arg(&path).args(["--format", "json", "--out"]).arg(&report).output().ok()?;
    if !report.is_file() {
        let msg = format!("{}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));
        // the service being unreachable is not a failure of the edit
        if msg.to_lowercase().contains("connect") || msg.to_lowercase().contains("unavailable") || msg.to_lowercase().contains("timed out") {
            eprintln!("service-kicad unreachable; skipping KiCad load check for {tag}: {msg}");
            return None;
        }
        panic!("KiCad did not load the edited board {tag}: {msg}");
    }
    Some(serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap())
}

#[test]
fn outline_rectangle_rounded_and_polygon_on_the_blank_board() {
    let t = blank();
    let c = be::outline(&t, &json!({"width": 50, "height": 30, "x": 100, "y": 80})).unwrap();
    let b = parse(&c.candidate);
    let d = b.dimensions.unwrap();
    assert_eq!((d.min_x, d.min_y, d.max_x, d.max_y), (100.0, 80.0, 150.0, 110.0));
    assert_eq!(c.create.len(), 1);
    // a second outline is refused, a replacement removes the first
    assert_eq!(be::outline(&c.candidate, &json!({"width": 10, "height": 10})).unwrap_err().code, "outline_exists");
    let r = be::outline(&c.candidate, &json!({"shape": "rounded", "width": 40, "height": 20, "radius": 3, "x": 10, "y": 10, "replace": true})).unwrap();
    assert_eq!(r.remove.len(), 1);
    assert_eq!(r.create.len(), 8, "four lines and four true arcs");
    let d = parse(&r.candidate).dimensions.unwrap();
    assert_eq!((d.min_x, d.min_y, d.max_x, d.max_y), (10.0, 10.0, 50.0, 30.0));
    assert!(r.candidate.contains("(gr_arc (start 10 13) (mid"), "{}", &r.candidate[r.candidate.len() - 900..]);
    assert!(!r.candidate.contains("(gr_rect"));
    let p = be::outline(&t, &json!({"points": [[0, 0], [30, 0], [30, 20], [15, 28], [0, 20]]})).unwrap();
    let d = parse(&p.candidate).dimensions.unwrap();
    assert_eq!((d.max_x, d.max_y), (30.0, 28.0));
    assert!(be::outline(&t, &json!({"shape": "rounded", "width": 10, "height": 10, "radius": 6})).is_err());
    if let Some(rep) = kicad_loads("outline", &r.candidate) {
        // a closed outline: no "board has malformed outline" finding
        let bad = rep["violations"].as_array().unwrap().iter().any(|v| v["type"] == "invalid_outline");
        assert!(!bad, "KiCad finds the rounded outline malformed: {rep}");
    }
}

#[test]
fn layer_count_grows_with_the_stackup_and_refuses_to_drop_used_planes() {
    let t = blank();
    let c = be::set_layer_count(&t, &json!({"count": 4})).unwrap();
    assert_eq!(pcb::copper_layers(&parse(&c.candidate)), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);
    assert!(c.candidate.contains("(4 \"In1.Cu\" signal)") && c.candidate.contains("(6 \"In2.Cu\" signal)"));
    let back = be::set_layer_count(&c.candidate, &json!({"count": 2})).unwrap();
    assert_eq!(pcb::copper_layers(&parse(&back.candidate)), vec!["F.Cu", "B.Cu"]);
    assert!(be::set_layer_count(&t, &json!({"count": 3})).is_err());

    let e = esc();
    let six = be::set_layer_count(&e, &json!({"count": 6})).unwrap();
    assert_eq!(pcb::copper_layers(&parse(&six.candidate)).len(), 6);
    let root = sxedit::parse(&six.candidate).unwrap();
    let stack = root.kid("setup").unwrap().kid("stackup").unwrap();
    let names: Vec<&str> = stack.kids_tagged("layer").filter_map(|l| l.atom(1)).collect();
    let cu: Vec<&&str> = names.iter().filter(|n| n.ends_with(".Cu")).collect();
    assert_eq!(cu, vec![&"F.Cu", &"In1.Cu", &"In2.Cu", &"In3.Cu", &"In4.Cu", &"B.Cu"]);
    assert_eq!(names.iter().filter(|n| n.starts_with("dielectric")).count(), 5);
    assert_eq!(six.report["stackup"]["rewritten"], true);
    // the ESC's inner layers carry planes: dropping them is refused with the counts
    let err = be::set_layer_count(&e, &json!({"count": 2})).unwrap_err();
    assert_eq!(err.code, "layers_in_use");
    assert!(err.detail["itemsPerLayer"]["In1.Cu"].as_u64().unwrap() > 0);
    if let Some(rep) = kicad_loads("six-layers", &six.candidate) {
        assert!(rep.get("kicad_version").is_some());
    }
}

#[test]
fn delete_graphics_by_filter_and_by_uuid() {
    let e = esc();
    let edge = be::delete_graphics(&e, &json!({"layer": "Edge.Cuts"})).unwrap();
    assert!(edge.report["count"].as_u64().unwrap() >= 1);
    assert!(parse(&edge.candidate).dimensions.is_none(), "every Edge.Cuts graphic is gone");
    assert_eq!(edge.remove.len() as u64, edge.report["count"].as_u64().unwrap());
    let one = edge.remove[0].clone();
    let by_id = be::delete_graphics(&e, &json!({"uuids": [one]})).unwrap();
    assert_eq!(by_id.report["count"], 1);
    assert_eq!(be::delete_graphics(&e, &json!({"uuids": ["nope"]})).unwrap_err().code, "unknown_ids");
    assert_eq!(be::delete_graphics(&e, &json!({})).unwrap_err().code, "missing_arg");
    assert_eq!(be::delete_graphics(&e, &json!({"type": "blob"})).unwrap_err().code, "invalid_args");
}

#[test]
fn mounting_holes_npth_generated_and_plated_on_a_net() {
    let e = esc();
    let h = be::add_mounting_hole(&e, None, &json!({"x": 106, "y": 60, "drill": 3.2})).unwrap();
    let b = parse(&h.candidate);
    let fp = b.footprints.iter().find(|f| f.reference == h.report["ref"].as_str().unwrap()).unwrap();
    assert_eq!(fp.pads[0].kind, "np_thru_hole");
    assert!(!h.copper);
    let p = be::add_mounting_hole(&e, None, &json!({"x": 166, "y": 60, "drill": 3.2, "plated": true, "padDiameter": 6, "net": "GND", "reference": "H9"})).unwrap();
    let b = parse(&p.candidate);
    let fp = b.footprints.iter().find(|f| f.reference == "H9").unwrap();
    assert_eq!(fp.pads[0].kind, "thru_hole");
    assert_eq!(fp.pads[0].net_name.as_deref(), Some("GND"));
    assert_eq!(fp.pads[0].width, Some(6.0));
    assert!(p.copper);
    assert_eq!(be::add_mounting_hole(&e, None, &json!({"x": 1, "y": 1, "drill": 3, "net": "NOPE"})).unwrap_err().code, "unknown_net");
    // a stock library footprint, when the caller found one
    let lib = "(footprint \"MountingHole_3.2mm_M3\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -4.2 0) (layer \"F.SilkS\") (effects (font (size 1 1)))) (property \"Value\" \"MountingHole_3.2mm_M3\" (at 0 4.2 0) (layer \"F.Fab\") (effects (font (size 1 1)))) (attr exclude_from_pos_files exclude_from_bom) (pad \"\" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers \"*.Cu\" \"*.Mask\")))";
    let s = be::add_mounting_hole(&e, Some((lib, "MountingHole:MountingHole_3.2mm_M3")), &json!({"x": 106, "y": 130, "drill": 3.2})).unwrap();
    assert_eq!(s.report["footprint"], "MountingHole:MountingHole_3.2mm_M3");
    if let Some(rep) = kicad_loads("holes", &p.candidate) {
        assert!(rep.get("kicad_version").is_some());
    }
}

#[test]
fn svg_logo_lands_as_filled_polygons_scaled_to_width() {
    let t = blank();
    let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><path d="M0 0 H200 V100 H0 Z M50 25 h100 v50 h-100 z"/></svg>"#;
    let c = be::import_svg(&t, svg, &json!({"x": 10, "y": 20, "width": 20, "layer": "F.SilkS"})).unwrap();
    assert_eq!(c.report["polygons"], 1);
    assert_eq!(c.report["sizeMm"], json!([20.0, 10.0]));
    assert!(c.candidate.contains("(fill yes) (layer \"F.SilkS\")"));
    let back = be::import_svg(&t, svg, &json!({"x": 10, "y": 20, "width": 20, "layer": "B.SilkS"})).unwrap();
    assert_eq!(back.report["mirrored"], true);
    assert_eq!(be::import_svg(&t, svg, &json!({"x": 0, "y": 0, "layer": "Edge.Cuts"})).unwrap_err().code, "invalid_layer");
    assert_eq!(be::import_svg(&t, "<svg/>", &json!({"x": 0, "y": 0})).unwrap_err().code, "svg_empty");
    if let Some(rep) = kicad_loads("svg", &c.candidate) {
        assert!(rep.get("kicad_version").is_some());
    }
}

#[test]
fn add_footprint_from_a_library_as_an_array_and_as_a_duplicate() {
    let e = esc();
    let c = be::add_footprint(&e, Some((R0603, "Resistor_SMD:R_0603_1608Metric")), &json!({"x": 180, "y": 70, "reference": "R", "value": "10k", "count": 3, "pitch": [3, 0]})).unwrap();
    let b = parse(&c.candidate);
    let added: Vec<&pcb::Footprint> = b.footprints.iter().filter(|f| f.footprint == "Resistor_SMD:R_0603_1608Metric").collect();
    assert_eq!(added.len(), 3);
    let xs: Vec<f64> = added.iter().map(|f| f.position.as_ref().unwrap().x).collect();
    assert_eq!(xs, vec![180.0, 183.0, 186.0]);
    assert!(added.iter().all(|f| f.value == "10k" && f.reference.starts_with('R')));
    let refs: Vec<&str> = added.iter().map(|f| f.reference.as_str()).collect();
    let before = parse(&e);
    assert!(refs.iter().all(|r| !before.footprints.iter().any(|f| f.reference == *r)), "fresh references {refs:?}");
    assert_eq!(c.create.len(), 3);
    assert_eq!(c.expect.len(), 3);
    // one on the back with nets
    let one = be::add_footprint(&e, Some((R0603, "Resistor_SMD:R_0603_1608Metric")), &json!({"x": 180, "y": 80, "rotation": 90, "side": "B.Cu", "reference": "R900", "nets": {"1": "GND", "2": "+3V3"}})).unwrap();
    let b = parse(&one.candidate);
    let r = b.footprints.iter().find(|f| f.reference == "R900").unwrap();
    assert_eq!(r.layer.as_deref(), Some("B.Cu"));
    assert_eq!(r.position.as_ref().unwrap().rotation, Some(90.0));
    assert_eq!(r.pads.iter().map(|p| p.net_name.clone().unwrap_or_default()).collect::<Vec<_>>(), vec!["GND", "+3V3"]);
    assert!(r.pads.iter().all(|p| p.layers.as_ref().unwrap()[0] == "B.Cu"));
    assert_eq!(be::add_footprint(&e, Some((R0603, "x:y")), &json!({"x": 1, "y": 1, "reference": "C7"})).unwrap_err().code, "duplicate_reference");
    // duplicate of C7: new reference, no nets, no schematic link
    let d = be::add_footprint(&e, None, &json!({"duplicateOf": "C7", "x": 190, "y": 70})).unwrap();
    let b = parse(&d.candidate);
    let newref = d.report["added"][0]["ref"].as_str().unwrap();
    assert!(newref.starts_with('C') && newref != "C7");
    let dup = b.footprints.iter().find(|f| f.reference == newref).unwrap();
    assert_eq!(dup.footprint, "Capacitor_SMD:C_0805_2012Metric");
    assert!(dup.pads.iter().all(|p| p.net_name.is_none()));
    assert!(dup.path.is_none());
    let orig = b.footprints.iter().find(|f| f.reference == "C7").unwrap();
    assert_ne!(dup.uuid, orig.uuid);
    if let Some(rep) = kicad_loads("add", &one.candidate) {
        assert!(rep.get("kicad_version").is_some());
    }
}

/// The board positions of a footprint's pads, the way KiCad places them.
fn pad_xy(b: &pcb::Board, r: &str) -> Vec<(String, f64, f64, String)> {
    let mut v: Vec<(String, f64, f64, String)> = pcb::pads(b).into_iter().filter(|p| p.reference == r).map(|p| (p.pad, (p.x * 1e4).round() / 1e4, (p.y * 1e4).round() / 1e4, p.layers.first().cloned().unwrap_or_default())).collect();
    v.sort_by(|a, b| a.0.cmp(&b.0));
    v
}

#[test]
fn flip_mirrors_every_pad_about_the_anchor_and_twice_is_the_identity() {
    let e = esc();
    let before = parse(&e);
    let c7 = before.footprints.iter().find(|f| f.reference == "C7").unwrap();
    let (ax, _ay) = (c7.position.as_ref().unwrap().x, c7.position.as_ref().unwrap().y);
    let pads_before = pad_xy(&before, "C7");
    let c = be::flip_footprints(&e, &json!({"refs": ["C7"]})).unwrap();
    let after = parse(&c.candidate);
    let f = after.footprints.iter().find(|f| f.reference == "C7").unwrap();
    assert_eq!(f.layer.as_deref(), Some("B.Cu"));
    let pads_after = pad_xy(&after, "C7");
    for (b, a) in pads_before.iter().zip(&pads_after) {
        // a left/right flip about the anchor: x mirrors, y stays
        assert!((a.1 - (2.0 * ax - b.1)).abs() < 1e-3 && (a.2 - b.2).abs() < 1e-3, "pad {} {:?} -> {:?}", b.0, b, a);
    }
    assert_eq!(c.remove.len(), 1);
    assert_eq!(c.expect[0].side.as_deref(), Some("B.Cu"));
    assert!(!c.create[0].contains(c7.uuid.as_deref().unwrap()), "the live copy carries fresh UUIDs");
    assert!(c.candidate.contains(c7.uuid.as_deref().unwrap()), "the file keeps the UUID");
    let twice = be::flip_footprints(&c.candidate, &json!({"ref": "C7"})).unwrap();
    assert_eq!(pad_xy(&parse(&twice.candidate), "C7"), pads_before);
    let tb = be::flip_footprints(&e, &json!({"ref": "C7", "direction": "top_bottom"})).unwrap();
    let ay = c7.position.as_ref().unwrap().y;
    for (b, a) in pads_before.iter().zip(&pad_xy(&parse(&tb.candidate), "C7")) {
        assert!((a.1 - b.1).abs() < 1e-3 && (a.2 - (2.0 * ay - b.2)).abs() < 1e-3, "top/bottom pad {} {:?} -> {:?}", b.0, b, a);
    }
    assert_eq!(be::flip_footprints(&e, &json!({"ref": "NOPE"})).unwrap_err().code, "unknown_footprint");
    if let Some(rep) = kicad_loads("flip", &c.candidate) {
        assert!(rep.get("kicad_version").is_some());
    }
}

#[test]
fn delete_and_edit_footprints() {
    let e = esc();
    let d = be::delete_footprints(&e, &json!({"refs": ["C7", "U1"]})).unwrap();
    let b = parse(&d.candidate);
    assert!(!b.footprints.iter().any(|f| f.reference == "C7" || f.reference == "U1"));
    assert_eq!(b.footprints.len(), 147);
    assert_eq!(d.remove.len(), 2);
    assert!(d.expect.iter().all(|x| !x.present));
    let locked = be::flip_footprints(&e, &json!({"ref": "C7"})).unwrap().candidate;
    let locked = be::edit_footprints(&locked, &json!({"ref": "C7", "locked": true})).unwrap().candidate;
    assert_eq!(be::delete_footprints(&locked, &json!({"ref": "C7"})).unwrap_err().code, "footprint_locked");
    assert!(be::delete_footprints(&locked, &json!({"ref": "C7", "allowLocked": true})).is_ok());

    let ed = be::edit_footprints(&e, &json!({"ref": "C7", "value": "2u2", "fields": {"LCSC": "C99", "Note": "hand fit"}, "dnp": true, "excludeFromBom": true})).unwrap();
    let b = parse(&ed.candidate);
    let c7 = b.footprints.iter().find(|f| f.reference == "C7").unwrap();
    assert_eq!(c7.value, "2u2");
    assert_eq!(c7.properties.get("LCSC").map(String::as_str), Some("C99"));
    assert_eq!(c7.properties.get("Note").map(String::as_str), Some("hand fit"));
    assert!(c7.attr.contains("smd"));
    let row = &ed.report["edited"][0];
    assert_eq!(row["after"]["attributes"]["dnp"], true);
    assert_eq!(row["before"]["attributes"]["dnp"], false);
    let ren = be::edit_footprints(&e, &json!({"ref": "C7", "newReference": "C700"})).unwrap();
    assert!(parse(&ren.candidate).footprints.iter().any(|f| f.reference == "C700"));
    assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7", "newReference": "C8"})).unwrap_err().code, "duplicate_reference");
    assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7"})).unwrap_err().code, "missing_arg");
    assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7", "fields": {"Value": null}})).unwrap_err().code, "invalid_args");
}

#[test]
fn align_and_distribute_by_courtyard() {
    let e = esc();
    let b = parse(&e);
    let (moves, rep) = be::align_moves(&b, &json!({"refs": ["C7", "C8", "C9"], "mode": "left"})).unwrap();
    assert_eq!(moves.len(), 3);
    let places = kicad_core::placement::placements(&b);
    let min_left = ["C7", "C8", "C9"].iter().map(|r| places.iter().find(|p| p.reference == *r).unwrap().courtyard.min_x).fold(f64::MAX, f64::min);
    for m in &moves {
        let p = places.iter().find(|p| p.reference == m["ref"].as_str().unwrap()).unwrap();
        let shift = m["x"].as_f64().unwrap() - p.x;
        assert!((p.courtyard.min_x + shift - min_left).abs() < 1e-6);
        assert_eq!(m["y"].as_f64().unwrap(), p.y);
    }
    assert_eq!(rep["mode"], "left");
    let (d, _) = be::align_moves(&b, &json!({"refs": ["C7", "C8", "C9"], "mode": "distributeY", "gap": 1})).unwrap();
    assert_eq!(d.len(), 3);
    assert!(be::align_moves(&b, &json!({"refs": ["C7"], "mode": "left"})).is_err());
    assert!(be::align_moves(&b, &json!({"refs": ["C7", "C8"], "mode": "diagonal"})).is_err());
    // the moves are exactly what kicad_move_footprint plans (file dry run)
    let plan = kicad_core::placement::plan_moves(&b, &json!({"refs": moves, "allowOverlap": true, "allowOutside": true})).unwrap();
    assert_eq!(plan.moves.len(), 3);
}

/// C7's own block turned back into a library footprint: the update finds nothing to do.
fn c7_as_library(e: &str) -> String {
    let root = sxedit::parse(e).unwrap();
    let c7 = be::footprints(&root).into_iter().find(|f| be::fp_ref(f) == "C7").unwrap();
    let block = kicad_core::fp_text::move_block(c7.src(e), 0.0, 0.0, 0.0).unwrap();
    let mut b = kicad_core::fp_text::strip_nets(&block).unwrap();
    b = kicad_core::fp_text::strip_symbol_link(&b).unwrap();
    b.replacen("Capacitor_SMD:C_0805_2012Metric", "C_0805_2012Metric", 1)
}

#[test]
fn update_from_library_plans_then_applies_keeping_nets_fields_and_pose() {
    let e = esc();
    let lib = c7_as_library(&e);
    let mut same = |_: &str| -> Result<String, String> { Ok(lib.clone()) };
    let plan = be::update_from_library(&e, &json!({"ref": "C7"}), &mut same).unwrap();
    assert_eq!(plan.report["footprints"][0]["status"], "up_to_date", "{}", plan.report);
    assert!(plan.remove.is_empty());
    // the library grew its pads: the plan says so, the apply keeps nets, fields and pose
    let bigger = lib.replace("(size 1 1.45)", "(size 1.2 1.6)");
    let mut changed = |id: &str| -> Result<String, String> { if id.starts_with("Capacitor_SMD:") { Ok(bigger.clone()) } else { Err(format!("{id} not in this test library")) } };
    let c = be::update_from_library(&e, &json!({"refs": ["C7", "U1"]}), &mut changed).unwrap();
    let rows = c.report["footprints"].as_array().unwrap();
    let c7 = rows.iter().find(|r| r["ref"] == "C7").unwrap();
    assert_eq!(c7["status"], "differs");
    assert_eq!(c7["diff"]["padsChanged"].as_array().unwrap().len(), 2);
    assert_eq!(rows.iter().find(|r| r["ref"] == "U1").unwrap()["status"], "library_not_found");
    let before = parse(&e);
    let after = parse(&c.candidate);
    let (b7, a7) = (before.footprints.iter().find(|f| f.reference == "C7").unwrap(), after.footprints.iter().find(|f| f.reference == "C7").unwrap());
    assert_eq!(a7.pads.iter().map(|p| p.net_name.clone()).collect::<Vec<_>>(), b7.pads.iter().map(|p| p.net_name.clone()).collect::<Vec<_>>());
    assert_eq!(a7.pads[0].width, Some(1.2));
    assert_eq!((a7.position.as_ref().unwrap().x, a7.position.as_ref().unwrap().rotation), (b7.position.as_ref().unwrap().x, b7.position.as_ref().unwrap().rotation));
    assert_eq!(a7.uuid, b7.uuid);
    assert_eq!(a7.path, b7.path);
    assert_eq!(a7.properties.get("LCSC"), b7.properties.get("LCSC"));
    assert_eq!(c.remove, vec![b7.uuid.clone().unwrap()]);
    if let Some(rep) = kicad_loads("update", &c.candidate) {
        assert!(rep.get("kicad_version").is_some());
    }
}

#[test]
fn swap_footprint_puts_another_footprint_on_one_part_keeping_nets_by_number() {
    let e = esc();
    // C7's footprint with a third pad: a different library footprint
    let lib = c7_as_library(&e);
    let root = sxedit::parse(&lib).unwrap();
    let pad2 = root.kids_tagged("pad").find(|p| p.atom(1) == Some("2")).unwrap().src(&lib).to_string();
    let pad3 = pad2.replacen("(pad \"2\"", "(pad \"3\"", 1);
    let three = lib.replacen(&pad2, &format!("{pad2}\n\t{pad3}"), 1);
    let c = be::swap_footprint(&e, &json!({"ref": "C7", "assigns": "3=GND"}), &three, "Mine:C_3pad").unwrap();
    assert_eq!(c.report["from"], "Capacitor_SMD:C_0805_2012Metric", "{}", c.report);
    assert_eq!(c.report["to"], "Mine:C_3pad");
    assert_eq!(c.report["netsKeptOnPads"], json!(["1", "2"]));
    assert_eq!(c.report["padsWithoutNet"], json!([]));
    let (before, after) = (parse(&e), parse(&c.candidate));
    let (b7, a7) = (before.footprints.iter().find(|f| f.reference == "C7").unwrap(), after.footprints.iter().find(|f| f.reference == "C7").unwrap());
    assert_eq!(a7.pads.len(), 3);
    assert_eq!(a7.pads[0].net_name, b7.pads[0].net_name);
    assert_eq!(a7.pads[2].net_name.as_deref(), Some("GND"));
    assert_eq!((a7.position.as_ref().unwrap().x, a7.position.as_ref().unwrap().y), (b7.position.as_ref().unwrap().x, b7.position.as_ref().unwrap().y));
    assert_eq!(a7.uuid, b7.uuid);
    assert!(c.candidate.contains("(footprint \"Mine:C_3pad\""));
    // one part only, and an unknown net is refused
    assert!(be::swap_footprint(&e, &json!({"refs": ["C7", "C8"]}), &three, "Mine:C_3pad").is_err());
    assert!(be::swap_footprint(&e, &json!({"ref": "C7", "assigns": {"3": "NO_SUCH_NET"}}), &three, "Mine:C_3pad").is_err());
}

#[test]
fn footprint_graphics_and_models_list_and_edit() {
    let e = esc();
    let (list, none) = be::footprint_graphics(&e, &json!({"ref": "C7"})).unwrap();
    assert!(none.is_none());
    let g = list["graphics"].as_array().unwrap();
    let silk = g.iter().find(|g| g["layer"] == "F.SilkS" && g["kind"] == "line").unwrap();
    let uuid = silk["uuid"].as_str().unwrap();
    let (_, c) = be::footprint_graphics(&e, &json!({"ref": "C7", "uuid": uuid, "change": {"width": 0.2}})).unwrap();
    let c = c.unwrap();
    assert_eq!(c.report["after"]["width"], 0.2);
    assert_eq!(c.report["before"]["width"], 0.12);
    let (models, _) = be::footprint_models(&e, &json!({"ref": "C7"})).unwrap();
    assert_eq!(models["models"].as_array().unwrap().len(), 1);
    let (_, m) = be::footprint_models(&e, &json!({"ref": "C7", "ops": [{"action": "replace", "index": 0, "offset": [0, 0, 0.1], "rotate": [0, 0, 180]}]})).unwrap();
    let m = m.unwrap();
    assert_eq!(m.report["after"][0]["offset"], json!([0.0, 0.0, 0.1]));
    assert!(m.candidate.contains("(xyz 0 0 0.1)"));
    let (_, r) = be::footprint_models(&e, &json!({"ref": "C7", "action": "remove", "index": 0})).unwrap();
    assert_eq!(r.unwrap().report["after"].as_array().unwrap().len(), 0);
}

#[test]
fn cross_probe_from_reference_and_symbol_uuid_on_the_rp2040_project() {
    let pcb_text = std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_pcb")).unwrap();
    let sch = std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_sch")).unwrap();
    // The tour board was written without schematic links; give one footprint the (path ...)
    // KiCad's Update PCB from Schematic writes: "/<symbol uuid>" on a root sheet.
    let symbols = be::schematic_symbols(&sch);
    let b = parse(&pcb_text);
    let (sym_uuid, reference) = symbols.iter().find_map(|s| b.footprints.iter().any(|f| f.reference == s.1).then(|| (s.0.clone(), s.1.clone()))).expect("a symbol whose reference is on the board");
    let root = sxedit::parse(&pcb_text).unwrap();
    let fp = be::footprints(&root).into_iter().find(|f| be::fp_ref(f) == reference).unwrap();
    let at = fp.kid("at").unwrap();
    let linked_text = format!("{}\n\t\t(path \"/{sym_uuid}\")\n\t\t(sheetname \"/\")\n\t\t(sheetfile \"rp2040-breakout.kicad_sch\"){}", &pcb_text[..at.end], &pcb_text[at.end..]);
    let sheets = vec![("rp2040-breakout.kicad_sch".to_string(), sch)];
    let v = be::cross_probe(&linked_text, &sheets, &json!({"reference": reference})).unwrap();
    assert_eq!(v["count"], 1);
    let m = &v["matches"][0];
    assert_eq!(m["linked"], true);
    assert_eq!(m["symbolUuid"], sym_uuid.as_str());
    assert_eq!(m["symbol"]["uuid"], sym_uuid.as_str(), "the schematic symbol is found by the board's path link: {v}");
    assert_eq!(m["symbol"]["reference"], reference.as_str());
    let back = be::cross_probe(&linked_text, &sheets, &json!({"symbolUuid": sym_uuid})).unwrap();
    assert_eq!(back["matches"][0]["ref"], reference.as_str());
    let by_path = be::cross_probe(&linked_text, &sheets, &json!({"path": format!("/{sym_uuid}")})).unwrap();
    assert_eq!(by_path["count"], 1);
    // a symbol with no footprint is named as such
    let orphan = symbols.iter().find(|s| !b.footprints.iter().any(|f| f.reference == s.1) && !s.1.starts_with('#'));
    if let Some(o) = orphan {
        let r = be::cross_probe(&linked_text, &sheets, &json!({"symbolUuid": o.0})).unwrap();
        assert_eq!(r["count"], 0);
        assert_eq!(r["symbolWithoutFootprint"]["uuid"], o.0.as_str());
    }
    assert_eq!(be::cross_probe(&linked_text, &sheets, &json!({})).unwrap_err().code, "missing_arg");
}

#[test]
fn title_block_and_extents_of_the_esc() {
    let e = esc();
    let tb = be::title_block(&e);
    assert_eq!(tb["title"], "ESC: G431");
    assert_eq!(tb["rev"], "2.0.1");
    assert_eq!(tb["company"], "ADOM INDUSTRIES INC.");
    let x = be::extents(&parse(&e));
    assert!(x["x0"].as_f64().unwrap() <= 104.0 && x["x1"].as_f64().unwrap() >= 168.0);
}

#[test]
fn closed_file_write_backs_up_refuses_open_boards_and_stale_text() {
    let dir = temp_dir("write");
    let path = dir.join("b.kicad_pcb");
    let t = blank();
    std::fs::write(&path, &t).unwrap();
    let c: Change = be::outline(&t, &json!({"width": 20, "height": 20, "x": 0, "y": 0})).unwrap();
    let bak = be::write_closed(&path, &t, &c.candidate).unwrap();
    assert_eq!(std::fs::read_to_string(&bak).unwrap(), t);
    assert_eq!(std::fs::read_to_string(&path).unwrap(), c.candidate);
    // stale: the file no longer holds the text the edit was planned on
    assert_eq!(be::write_closed(&path, &t, &c.candidate).unwrap_err().code, "stale_board");
    std::fs::write(dir.join("~b.kicad_pcb.lck"), "x").unwrap();
    assert_eq!(be::write_closed(&path, &c.candidate, &t).unwrap_err().code, "board_open");
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn the_live_create_text_wraps_blocks_in_the_boards_header() {
    let e = esc();
    let w = be::create_text(&e, &["(gr_line (start 0 0) (end 1 1) (layer \"Dwgs.User\"))".to_string()]);
    let root = sxedit::parse(&w).unwrap();
    assert_eq!(root.tag(), Some("kicad_pcb"));
    assert_eq!(root.value_of("version"), Some("20260206"));
    assert!(root.kid("layers").is_some());
    assert!(root.kid("gr_line").is_some());
    let b = parse(&w);
    assert_eq!(pcb::copper_layers(&b).len(), 4);
    // what a live flip of C7 hands ParseAndCreateItemsFromString is a board KiCad reads
    let flip = be::flip_footprints(&e, &json!({"ref": "C7"})).unwrap();
    let w = be::create_text(&e, &flip.create);
    let b = parse(&w);
    assert_eq!(b.footprints.len(), 1);
    assert_eq!(b.footprints[0].layer.as_deref(), Some("B.Cu"));
    assert_eq!(b.footprints[0].pads.iter().filter(|p| p.net_name.is_some()).count(), 2, "the pads keep their nets by name");
    if let Some(rep) = kicad_loads("create-text", &w) {
        assert!(rep.get("kicad_version").is_some());
    }
}

/// Pad flashes (D03) in a Gerber file, in KiCad's frame (Gerber y is up), rounded to 1 um.
fn flashes(gbr: &str) -> Vec<(i64, i64)> {
    let mut out = Vec::new();
    let (mut x, mut y) = (0i64, 0i64);
    for line in gbr.lines() {
        let l = line.trim();
        if !l.ends_with("D03*") || !l.starts_with('X') && !l.starts_with('Y') {
            continue;
        }
        let num = |tag: char| -> Option<i64> {
            let i = l.find(tag)? + 1;
            let s: String = l[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect();
            s.parse().ok()
        };
        if let Some(v) = num('X') { x = v; }
        if let Some(v) = num('Y') { y = v; }
        out.push(((x as f64 / 1000.0).round() as i64, (-(y as f64) / 1000.0).round() as i64));
    }
    out.sort();
    out
}

fn gerber(tag: &str, text: &str, layer: &str) -> Option<String> {
    let svc = service_kicad()?;
    let dir = temp_dir(tag);
    let path = dir.join(format!("{tag}.kicad_pcb"));
    std::fs::write(&path, text).unwrap();
    let out_dir = dir.join("gerbers");
    let out = Command::new(&svc).args(["pcb", "export", "gerbers"]).arg(&path).arg("--out").arg(&out_dir).output().ok()?;
    let want = layer.replace('.', "_");
    let file = std::fs::read_dir(&out_dir).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().map(|n| n.to_string_lossy().contains(&want)).unwrap_or(false));
    match file {
        Some(f) => Some(std::fs::read_to_string(f).unwrap()),
        None => {
            eprintln!("no {layer} gerber from service-kicad for {tag}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));
            None
        }
    }
}

/// KiCad itself draws the flipped footprint's pads where the bridge mirrored them: the
/// B.Cu Gerber of the flipped part is the F.Cu Gerber mirrored about the anchor.
#[test]
fn kicad_draws_the_flipped_pads_at_the_mirrored_positions() {
    let lib = "(footprint \"Asym\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -2 0) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15)))) (property \"Value\" \"Asym\" (at 0 2 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15)))) (attr smd) (pad \"1\" smd circle (at -1 0.5) (size 0.6 0.6) (layers \"F.Cu\" \"F.Mask\")) (pad \"2\" smd circle (at 2 -1) (size 0.4 0.4) (layers \"F.Cu\" \"F.Mask\")) (pad \"3\" smd circle (at 0.3 1.7) (size 0.5 0.5) (layers \"F.Cu\" \"F.Mask\")))";
    let base = blank();
    let c = be::add_footprint(&base, Some((lib, "Test:Asym")), &json!({"x": 100, "y": 100, "rotation": 30, "reference": "U1"})).unwrap();
    let Some(front) = gerber("flip-front", &c.candidate, "F.Cu") else { eprintln!("service-kicad not available; skipping"); return };
    let f = flashes(&front);
    assert_eq!(f.len(), 3, "three pad flashes on F.Cu: {front}");
    // the bridge's own pad positions agree with KiCad's drawing
    let mut ours: Vec<(i64, i64)> = pcb::pads(&parse(&c.candidate)).iter().map(|p| ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)).collect();
    ours.sort();
    assert_eq!(ours, f);
    for (dir, tag) in [("left_right", "flip-lr"), ("top_bottom", "flip-tb")] {
        let flipped = be::flip_footprints(&c.candidate, &json!({"ref": "U1", "direction": dir})).unwrap();
        let back = gerber(tag, &flipped.candidate, "B.Cu").expect("B.Cu gerber");
        let b = flashes(&back);
        let mut want: Vec<(i64, i64)> = f.iter().map(|(x, y)| if dir == "left_right" { (200_000 - x, *y) } else { (*x, 200_000 - y) }).collect();
        want.sort();
        assert_eq!(b, want, "{dir}: KiCad drew the flipped pads at {b:?}, the mirror is {want:?}");
    }
}