master
John Lauer 1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati… 39ff8a6 14d ago
//! The konnect-route fixture project (tests/fixtures/konnect-route: a KiCad 10 board, its
//! .kicad_pro, .kicad_dru, a kicadxml netlist and a project footprint library) through the
//! routing, rules, placement-helper and update-from-schematic planners.

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

use serde_json::{json, Value};

use kicad_core::libraries::LibCtx;
use kicad_core::{netlist, pcb, pcb_sync, place_tools, placement, project_rules, route_tools};

fn dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route")
}

fn board() -> (String, pcb::Board) {
    let p = dir().join("route-fixture.kicad_pcb");
    let text = std::fs::read_to_string(&p).unwrap();
    let b = pcb::parse_pcb_text(&text, &p.to_string_lossy()).unwrap();
    (text, b)
}

fn project() -> Value {
    project_rules::read_json(&dir().join("route-fixture.kicad_pro")).unwrap()
}

fn net_names(b: &pcb::Board) -> Vec<String> {
    b.nets.iter().map(|n| n.name.clone()).collect()
}

/// A scratch copy of the whole fixture folder.
fn scratch(tag: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("kb-konnect-route-{tag}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&d);
    copy_dir(&dir(), &d);
    d
}

fn copy_dir(from: &Path, to: &Path) {
    std::fs::create_dir_all(to).unwrap();
    for e in std::fs::read_dir(from).unwrap() {
        let e = e.unwrap();
        let t = to.join(e.file_name());
        if e.file_type().unwrap().is_dir() {
            copy_dir(&e.path(), &t);
        } else {
            std::fs::copy(e.path(), t).unwrap();
        }
    }
}

const SIG_A_1: &str = "00000000-0000-4000-8000-000000000074";

#[test]
fn the_fixture_is_a_kicad_10_board() {
    let (_, b) = board();
    assert_eq!(b.net_format, pcb::NetFormat::Name);
    assert_eq!(b.footprints.len(), 7);
    assert_eq!(b.segments.len(), 3);
    assert_eq!(b.vias.len(), 1);
    let o = placement::outline(&b).unwrap();
    assert_eq!((o.min_x, o.min_y, o.max_x, o.max_y), (0.0, 0.0, 60.0, 40.0));
    let u1 = b.footprints.iter().find(|f| f.reference == "U1").unwrap();
    assert_eq!(u1.pads.iter().find(|p| p.number == "5").unwrap().rotation, Some(90.0), "pad angles are read");
}

// ---------------------------------------------------------------------------
// kicad_netclasses, kicad_board_rules, kicad_custom_rules
// ---------------------------------------------------------------------------

#[test]
fn netclasses_report_patterns_and_the_nets_they_match() {
    let (_, b) = board();
    let r = project_rules::netclasses_report(&project(), &net_names(&b));
    let usb = r["classes"].as_array().unwrap().iter().find(|c| c["name"] == "USB").unwrap();
    assert_eq!(usb["diffPairGap"], 0.15);
    assert_eq!(usb["patterns"][0]["pattern"], "USB_*");
    assert_eq!(usb["nets"], json!(["USB_D+", "USB_D-"]));
    let default = r["classes"].as_array().unwrap().iter().find(|c| c["name"] == "Default").unwrap();
    assert!(default["nets"].as_array().unwrap().contains(&json!("GND")));
    assert_eq!(project_rules::class_for_net(&project(), "USB_D-")["name"], "USB");
}

#[test]
fn netclass_edits_create_assign_and_remove_and_write_atomically() {
    let d = scratch("netclass");
    let pro_path = d.join("route-fixture.kicad_pro");
    let before = std::fs::read_to_string(&pro_path).unwrap();
    let mut pro: Value = serde_json::from_str(&before).unwrap();
    let changes = project_rules::edit_netclasses(&mut pro, &json!({"set": {"name": "PWR", "trackWidth": 0.5, "clearance": 0.3}, "assign": {"netclass": "PWR", "patterns": ["+3V3"]}})).unwrap();
    assert_eq!(changes.len(), 2, "{changes:?}");
    let pwr = project_rules::class_for_net(&pro, "+3V3");
    assert_eq!(pwr["name"], "PWR");
    assert_eq!(pwr["track_width"], 0.5);
    assert_eq!(pwr["via_diameter"], 0.6, "a new class starts from the Default values");
    assert!(project_rules::edit_netclasses(&mut pro, &json!({"set": {"name": "PWR", "trackWidth": 0.5}})).unwrap().is_empty(), "no change, no change list");
    assert_eq!(project_rules::edit_netclasses(&mut pro, &json!({"assign": {"netclass": "NOPE", "patterns": ["x"]}})).unwrap_err().code, "unknown_netclass");
    assert_eq!(project_rules::edit_netclasses(&mut pro, &json!({"set": {"name": "BAD", "viaDiameter": 0.3, "viaDrill": 0.4}})).unwrap_err().code, "invalid_value");
    let bak = project_rules::write_atomic(&pro_path, &project_rules::project_text(&pro), Some(&before)).unwrap();
    assert_eq!(std::fs::read_to_string(&bak).unwrap(), before);
    let back = project_rules::read_json(&pro_path).unwrap();
    assert_eq!(project_rules::class_for_net(&back, "+3V3")["name"], "PWR");
    assert_eq!(project_rules::write_atomic(&pro_path, "{}", Some(&before)).unwrap_err().code, "file_changed");
    let mut p2 = back.clone();
    project_rules::edit_netclasses(&mut p2, &json!({"remove": "PWR"})).unwrap();
    assert_eq!(project_rules::class_for_net(&p2, "+3V3")["name"], "Default");
    assert!(p2["net_settings"]["netclass_patterns"].as_array().unwrap().iter().all(|p| p["netclass"] != "PWR"));
    assert!(project_rules::open_locks(&pro_path).is_empty());
    std::fs::write(d.join("~route-fixture.kicad_pcb.lck"), "x").unwrap();
    assert_eq!(project_rules::open_locks(&pro_path).len(), 1);
    let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn board_rules_read_and_write_with_the_hidden_zero_rows() {
    let mut pro = project();
    let r = project_rules::board_rules_report(&pro);
    assert_eq!(r["constraints"]["minClearance"], 0.15);
    assert_eq!(r["constraints"]["minCopperEdgeClearance"], 0.5);
    assert_eq!(r["trackWidths"], json!([0.2, 0.3, 0.5]));
    assert_eq!(r["viaSizes"], json!([{"diameter": 0.6, "drill": 0.3}]));
    let ch = project_rules::edit_board_rules(&mut pro, &json!({"rules": {"minTrackWidth": 0.127, "min_hole_to_hole": 0.3}, "trackWidths": [0.15, 0.25], "viaSizes": [{"diameter": 0.45, "drill": 0.2}], "diffPairs": [{"width": 0.2, "gap": 0.15}]})).unwrap();
    assert_eq!(ch.len(), 5, "{ch:?}");
    assert_eq!(pro["board"]["design_settings"]["track_widths"], json!([0.0, 0.15, 0.25]));
    assert_eq!(pro["board"]["design_settings"]["via_dimensions"][0], json!({"diameter": 0.0, "drill": 0.0}));
    assert_eq!(pro["board"]["design_settings"]["rules"]["min_track_width"], 0.127);
    let r = project_rules::board_rules_report(&pro);
    assert_eq!(r["diffPairs"], json!([{"width": 0.2, "gap": 0.15, "viaGap": 0.15}]));
    assert_eq!(project_rules::edit_board_rules(&mut pro, &json!({"rules": {"minWiggle": 1}})).unwrap_err().code, "unknown_rule");
    assert_eq!(project_rules::edit_board_rules(&mut pro, &json!({"viaSizes": [{"diameter": 0.3, "drill": 0.3}]})).unwrap_err().code, "invalid_value");
}

#[test]
fn the_fixture_rules_file_parses_and_edits() {
    let text = std::fs::read_to_string(dir().join("route-fixture.kicad_dru")).unwrap();
    let (v, rules) = project_rules::parse_dru(&text).unwrap();
    assert_eq!(v, Some(1));
    assert_eq!(rules.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), vec!["HV clearance", "Inner thin"]);
    assert_eq!(rules[1].layer.as_deref(), Some("inner"));
    assert_eq!(rules[1].constraints[0]["opt"], "0.15mm");
    let rule = project_rules::rule_text(&json!({"name": "USB pair", "condition": "A.NetClass == 'USB'", "constraints": [{"type": "diff_pair_gap", "min": 0.15, "max": 0.2}, {"type": "skew", "max": 0.1}]})).unwrap();
    let added = project_rules::add_rule(&text, &rule, false).unwrap();
    assert_eq!(project_rules::parse_dru(&added).unwrap().1.len(), 3);
    let (gone, removed) = project_rules::remove_rules(&added, &["HV clearance".into()]).unwrap();
    assert_eq!(removed.len(), 1);
    assert!(gone.contains("Inner thin") && gone.contains("USB pair") && !gone.contains("HV clearance"));
}

// ---------------------------------------------------------------------------
// kicad_modify_track, kicad_route_diff_pair, kicad_copy_routing
// ---------------------------------------------------------------------------

#[test]
fn a_track_change_rewrites_only_that_segment() {
    let (text, b) = board();
    let (changes, cand) = route_tools::plan_track_changes(&b, &text, &json!({"changes": [{"id": SIG_A_1, "width": 0.3, "layer": "B.Cu"}]})).unwrap();
    assert_eq!(changes[0].changed, vec!["width", "layer"]);
    assert_eq!(changes[0].net, "SIG_A");
    let nb = pcb::parse_pcb_text(&cand, "c").unwrap();
    assert_eq!(nb.segments.len(), 3);
    let s = nb.segments.iter().find(|s| s.uuid.as_deref() == Some(SIG_A_1)).unwrap();
    assert_eq!((s.width, s.layer.as_deref(), s.net_name.as_deref()), (Some(0.3), Some("B.Cu"), Some("SIG_A")));
    let others_before: Vec<_> = b.segments.iter().filter(|s| s.uuid.as_deref() != Some(SIG_A_1)).map(|s| (s.uuid.clone(), s.width, s.layer.clone())).collect();
    let others_after: Vec<_> = nb.segments.iter().filter(|s| s.uuid.as_deref() != Some(SIG_A_1)).map(|s| (s.uuid.clone(), s.width, s.layer.clone())).collect();
    assert_eq!(others_before, others_after);
    // an endpoint onto a pad
    let (c2, cand2) = route_tools::plan_track_changes(&b, &text, &json!({"id": SIG_A_1, "end": [12, 19.675]})).unwrap();
    assert_eq!(c2[0].after.end, [12.0, 19.675]);
    assert!(cand2.contains("(end 12 19.675)"));
    let e = |a: Value| route_tools::plan_track_changes(&b, &text, &a).unwrap_err().code;
    assert_eq!(e(json!({"id": "nope", "width": 0.3})), "unknown_track");
    assert_eq!(e(json!({"id": "00000000-0000-4000-8000-000000000076", "width": 0.3})), "unknown_track", "a via is not a track");
    assert_eq!(e(json!({"id": SIG_A_1, "layer": "In7.Cu"})), "unknown_layer");
    assert_eq!(e(json!({"id": SIG_A_1, "width": 0.2})), "no_change");
}

#[test]
fn a_diff_pair_takes_the_netclass_gap_and_reports_skew() {
    let (text, b) = board();
    let pro = project();
    let plan = route_tools::plan_diff_pair(&b, Some(&pro), &json!({"pair": "USB_D", "centerline": [[15, 36], [25, 36], [27, 38]]})).unwrap();
    let v = plan.public();
    assert_eq!((v["netP"].as_str(), v["netN"].as_str()), (Some("USB_D+"), Some("USB_D-")));
    assert_eq!((v["width"].as_f64(), v["gap"].as_f64()), (Some(0.2), Some(0.15)), "the USB netclass values");
    assert_eq!(v["sizesFrom"], "netclass");
    // centre-to-centre spacing is gap + width on the straight run
    let p0 = plan.p.segments[0].start;
    let n0 = plan.n.segments[0].start;
    assert!(((p0[1] - n0[1]).abs() - 0.35).abs() < 1e-6, "{p0:?} {n0:?}");
    assert!(p0[1] < n0[1], "P is on the left of +x travel (smaller y)");
    // on the outside of a right turn the left half is longer
    assert!(v["skewMm"].as_f64().unwrap() > 0.0);
    let cand = pcb::splice(&text, &plan.blocks());
    assert_eq!(pcb::parse_pcb_text(&cand, "c").unwrap().segments.len(), 3 + 4);
    // a layer change puts a via pair at the vias' spacing
    let plan = route_tools::plan_diff_pair(&b, Some(&pro), &json!({"netP": "USB_D+", "centerline": [[15, 36], [25, 36], {"x": 30, "y": 36, "layer": "B.Cu"}, [35, 36]], "viaSize": 0.6})).unwrap();
    assert_eq!((plan.p.vias.len(), plan.n.vias.len()), (1, 1));
    let (vp, vn) = (plan.p.vias[0].at, plan.n.vias[0].at);
    assert!(((vp[1] - vn[1]).abs() - (0.6 + 0.25)).abs() < 1e-6, "via pair centre spacing is viaSize + viaGap: {vp:?} {vn:?}");
    assert_eq!(plan.p.segments.last().unwrap().layer, "B.Cu");
    // pads join the ends, and pcb::plan checks they are on the right nets
    let ok = route_tools::plan_diff_pair(&b, None, &json!({"netP": "USB_D+", "netN": "USB_D-", "centerline": [[20, 24], [26, 24]], "pEnd": "J1.2", "nEnd": "J1.3", "gap": 0.2, "width": 0.25})).unwrap();
    assert_eq!(ok.public()["sizesFrom"], "arguments");
    assert_eq!(ok.p.segments.last().unwrap().end, [30.0, 28.54]);
    let wrong = route_tools::plan_diff_pair(&b, None, &json!({"netP": "USB_D+", "netN": "USB_D-", "centerline": [[20, 24], [26, 24]], "pEnd": "J1.3"})).unwrap_err();
    assert_eq!(wrong.code, "net_mismatch");
    assert_eq!(route_tools::plan_diff_pair(&b, None, &json!({"pair": "NOPE", "centerline": [[0, 0], [1, 0]]})).unwrap_err().code, "pair_not_found");
}

#[test]
fn copy_routing_translates_and_remaps_nets() {
    let (text, b) = board();
    let src = json!({"minX": 9, "minY": 19, "maxX": 19, "maxY": 29});
    let plan = route_tools::plan_copy(&b, &json!({"source": src, "offset": {"dx": 30, "dy": -15}})).unwrap();
    let v = plan.public();
    assert_eq!((v["segmentCount"].as_u64(), v["viaCount"].as_u64()), (Some(2), Some(1)), "the B.Cu run leaves the box and stays");
    assert_eq!(plan.plans[0].net_name, "SIG_A");
    assert_eq!(plan.plans[0].vias[0].at, [40.0, 13.0]);
    let cand = pcb::splice(&text, &plan.blocks());
    assert_eq!(pcb::parse_pcb_text(&cand, "c").unwrap().vias.len(), 2);
    let remap = route_tools::plan_copy(&b, &json!({"source": src, "offset": [30, -15], "netMap": {"SIG_A": "SIG_B"}})).unwrap();
    assert_eq!(remap.plans.iter().map(|p| p.net_name.as_str()).collect::<Vec<_>>(), vec!["SIG_B"]);
    // a footprint pair that maps no net leaves nothing to copy, and says why
    let none = route_tools::plan_copy(&b, &json!({"source": src, "offset": [30, -15], "refMap": {"U1": "U2"}})).unwrap_err();
    assert_eq!(none.code, "nothing_to_copy");
    assert_eq!(none.detail["skipped"][0]["reason"], "unmapped_net");
    assert_eq!(route_tools::plan_copy(&b, &json!({"source": src, "offset": [0, 0]})).unwrap_err().code, "invalid_geometry");
}

// ---------------------------------------------------------------------------
// kicad_check_clearance, kicad_score_placement, kicad_place_decoupling, kicad_plan_fanout
// ---------------------------------------------------------------------------

#[test]
fn clearance_by_courtyard_and_by_pad() {
    let (_, b) = board();
    let c = place_tools::check_clearance(&b, &json!({"threshold": 3})).unwrap();
    let pairs = c["pairs"].as_array().unwrap();
    let c1c2 = pairs.iter().find(|p| p["a"] == "C1" && p["b"] == "C2").expect("C1-C2 under 3 mm");
    assert!((c1c2["distanceMm"].as_f64().unwrap() - 2.14).abs() < 1e-6);
    assert!(place_tools::check_clearance(&b, &json!({})).unwrap()["clean"] == true, "nothing under 0.25 mm");
    let only = place_tools::check_clearance(&b, &json!({"threshold": 50, "refs": ["R1"]})).unwrap();
    assert!(only["pairs"].as_array().unwrap().iter().all(|p| p["a"] == "R1" || p["b"] == "R1"));
    let pads = place_tools::check_clearance(&b, &json!({"mode": "pads", "threshold": 3.5})).unwrap();
    let p = pads["pairs"].as_array().unwrap().iter().find(|p| p["a"] == "C1.2" && p["b"] == "C2.1").expect("C1.2 to C2.1");
    assert!((p["distanceMm"].as_f64().unwrap() - (4.0 - 0.96 - 0.56)).abs() < 1e-6, "{p}");
    assert_eq!(place_tools::check_clearance(&b, &json!({"refs": ["Q9"]})).unwrap_err().code, "unknown_footprint");
}

#[test]
fn the_score_names_its_deductions() {
    let (_, b) = board();
    let s = place_tools::score(&b, &json!({}));
    let rule = |name: &str| s["deductions"].as_array().unwrap().iter().find(|d| d["rule"] == name).cloned().unwrap();
    assert_eq!(rule("connector_off_edge")["items"][0]["ref"], "J1");
    assert_eq!(rule("decoupling_far")["count"], 2);
    assert_eq!(rule("courtyard_overlap")["count"], 0);
    assert_eq!(rule("outside_board")["count"], 0);
    let sc = s["score"].as_f64().unwrap();
    assert!(sc < 90.0 && sc > 50.0, "{s}");
}

#[test]
fn decoupling_caps_go_beside_the_power_pins() {
    let (_, b) = board();
    let plan = place_tools::plan_decoupling(&b, &json!({"ic": "U1"})).unwrap();
    assert_eq!(plan["validation"]["ok"], true, "{plan}");
    let rows = plan["placements"].as_array().unwrap();
    assert_eq!(rows.len(), 2);
    let c1 = rows.iter().find(|r| r["ref"] == "C1").unwrap();
    let c2 = rows.iter().find(|r| r["ref"] == "C2").unwrap();
    assert_eq!((c1["pin"].as_str(), c1["side"].as_str()), (Some("U1.1"), Some("left")));
    assert_eq!((c2["pin"].as_str(), c2["side"].as_str()), (Some("U1.5"), Some("bottom")));
    assert!(c1["padToPinMm"].as_f64().unwrap() < 2.0 && c2["padToPinMm"].as_f64().unwrap() < 2.0, "{plan}");
    // the power pad faces the IC: C1 at 180 puts pad 1 (+3V3) on its right, toward U1
    assert_eq!(c1["rotation"], 180.0);
    // moveArgs is a kicad_move_footprint batch the move planner accepts
    let mv = placement::plan_moves(&b, &plan["moveArgs"]).unwrap();
    assert_eq!(mv.moves.len(), 2);
    let after = place_tools::score(&b, &json!({}));
    let moved = {
        let mut t = std::fs::read_to_string(dir().join("route-fixture.kicad_pcb")).unwrap();
        for m in &mv.moves {
            t = t.replace(&format!("(at {} {})", pcb::num(m.before.x), pcb::num(m.before.y)), &format!("(at {} {} {})", pcb::num(m.after.x), pcb::num(m.after.y), pcb::num(m.after.rotation)));
        }
        pcb::parse_pcb_text(&t, "m").unwrap()
    };
    let better = place_tools::score(&moved, &json!({}));
    assert!(better["score"].as_f64() > after["score"].as_f64(), "placing the caps raises the score: {} -> {}", after["score"], better["score"]);
    assert_eq!(place_tools::plan_decoupling(&b, &json!({"ic": "R1"})).unwrap_err().code, "no_caps");
}

fn route_net_accepts(b: &pcb::Board, calls: &[Value]) {
    for c in calls {
        pcb::plan(b, c).unwrap_or_else(|e| panic!("kicad_route_net would refuse {c}: {} {}", e.code, e.message));
    }
}

#[test]
fn a_bga_fanout_plan_is_route_net_ready() {
    let (_, b) = board();
    let f = place_tools::plan_fanout(&b, &json!({"ref": "U2"})).unwrap();
    assert_eq!(f["package"], "bga");
    assert_eq!(f["pitchMm"], 0.8);
    assert_eq!(f["style"], "dogbone");
    assert_eq!(f["escapeLayer"], "B.Cu", "a two-layer board escapes to the back");
    let rows = f["pads"].as_array().unwrap();
    assert_eq!(rows.iter().filter(|r| r["style"] == "surface").count(), 12, "the outer ring escapes on top");
    assert_eq!(rows.iter().filter(|r| r["style"] == "dogbone").count(), 4);
    let b2 = rows.iter().find(|r| r["pad"] == "U2.B2").unwrap();
    assert_eq!(b2["via"]["at"], json!([41.2, 19.2]), "away from the centre, on the diagonal");
    assert!(f["minViaToPadMm"].as_f64().unwrap() >= 0.1, "{f}");
    route_net_accepts(&b, f["calls"].as_array().unwrap());
    let vip = place_tools::plan_fanout(&b, &json!({"ref": "U2", "style": "via-in-pad"})).unwrap();
    route_net_accepts(&b, vip["calls"].as_array().unwrap());
    let tight = place_tools::plan_fanout(&b, &json!({"ref": "U2", "viaSize": 0.6, "viaDrill": 0.3})).unwrap();
    assert_eq!(tight["style"], "via-in-pad", "a 0.6 mm via does not fit between 0.8 mm balls");
}

#[test]
fn a_qfn_fanout_plan_stubs_out_and_stitches_the_exposed_pad() {
    let (_, b) = board();
    let f = place_tools::plan_fanout(&b, &json!({"ref": "U1"})).unwrap();
    assert_eq!(f["package"], "perimeter");
    assert_eq!(f["pitchMm"], 0.65);
    let rows = f["pads"].as_array().unwrap();
    let ep = rows.iter().find(|r| r["pad"] == "U1.17").unwrap();
    assert_eq!(ep["style"], "thermal-via-grid");
    assert_eq!(ep["vias"].as_array().unwrap().len(), 9, "a 3 x 3 grid at 1 mm in the 2.6 mm pad");
    let p3 = rows.iter().find(|r| r["pad"] == "U1.3").unwrap();
    assert_eq!(p3["edge"], "left");
    assert!(p3["stubEnd"][0].as_f64().unwrap() < 18.05 - 0.4);
    let p6 = rows.iter().find(|r| r["pad"] == "U1.6").unwrap();
    assert_eq!(p6["style"], "skip", "a pad with no net is not fanned out");
    route_net_accepts(&b, f["calls"].as_array().unwrap());
    assert_eq!(place_tools::plan_fanout(&b, &json!({"ref": "C1"})).unwrap_err().code, "not_a_package");
}

// ---------------------------------------------------------------------------
// kicad_update_pcb_from_schematic
// ---------------------------------------------------------------------------

fn fixture_netlist() -> netlist::Netlist {
    netlist::parse_kicadxml(&std::fs::read_to_string(dir().join("route-fixture.net")).unwrap()).unwrap()
}

#[test]
fn the_netlist_carries_symbol_uuids() {
    let nl = fixture_netlist();
    let r2 = nl.component("R2").unwrap();
    assert_eq!(pcb_sync::symbol_path(r2).as_deref(), Some("/10000000-0000-4000-8000-000000000007"));
}

#[test]
fn the_plan_lists_every_difference() {
    let (text, b) = board();
    let d = pcb_sync::compare(&b, &text, &fixture_netlist());
    assert_eq!(d["missingFootprints"], json!([{"ref": "R2", "value": "4k7", "footprint": "Fixture:R_0603_1608Metric"}]));
    assert_eq!(d["extraFootprints"][0]["ref"], "MH1");
    assert_eq!(d["changedValues"], json!([{"ref": "C2", "from": "100n", "to": "1u"}]));
    assert_eq!(d["changedFootprints"][0]["ref"], "J1");
    assert_eq!(d["padNetChanges"], json!([{"pad": "R1.2", "from": "SIG_B", "to": "SIG_C"}]));
    assert_eq!(d["relinks"], json!([]));
    assert_eq!(d["inSync"], false);
}

#[test]
fn apply_on_the_closed_file_brings_the_board_in_sync() {
    let d = scratch("sync");
    let path = d.join("route-fixture.kicad_pcb");
    let text = std::fs::read_to_string(&path).unwrap();
    let b = pcb::parse_pcb_text(&text, &path.to_string_lossy()).unwrap();
    let nl = fixture_netlist();
    let ctx = LibCtx::default();
    let module = |id: &str| pcb_sync::resolve_footprint(id, &d, &ctx).and_then(|p| std::fs::read_to_string(p).map_err(|e| e.to_string()));
    let (out, done) = pcb_sync::apply(&text, &b, &nl, &pcb_sync::ApplyOpts { delete_extra: false, update_footprints: true }, &module).unwrap();
    assert_eq!(done["footprintsAdded"][0]["ref"], "R2", "{done}");
    assert_eq!(done["footprintsReplaced"][0]["to"], "Fixture:PinHeader_1x04_P2.00mm_Vertical");
    assert_eq!(done["valuesUpdated"][0]["ref"], "C2");
    assert_eq!(done["extrasMarked"], json!(["MH1"]));
    assert_eq!(done["notApplied"], json!([]));
    pcb::write_board(&path, &out, true, Some(&text)).unwrap();
    let after = pcb::parse_pcb(&path).unwrap();
    let again = pcb_sync::compare(&after, &out, &nl);
    assert_eq!(again["changeCount"], 1, "only the marked extra is left: {again}");
    assert_eq!(again["extraFootprints"][0]["ref"], "MH1");
    let r2 = after.footprints.iter().find(|f| f.reference == "R2").unwrap();
    assert_eq!(r2.value, "4k7");
    assert_eq!(r2.path.as_deref(), Some("/10000000-0000-4000-8000-000000000007"));
    let pos = r2.position.as_ref().unwrap();
    assert!(pos.y > 40.0, "new parts land in a row below the board: {pos:?}");
    assert_eq!(r2.pads.iter().map(|p| p.net_name.clone().unwrap_or_default()).collect::<Vec<_>>(), vec!["+3V3", "SIG_B"]);
    let j1 = after.footprints.iter().find(|f| f.reference == "J1").unwrap();
    assert_eq!(j1.position.as_ref().map(|p| (p.x, p.y)), Some((30.0, 26.0)), "a replaced footprint keeps its place");
    assert_eq!(j1.pads[2].net_name.as_deref(), Some("USB_D-"));
    assert!(out.contains(pcb_sync::EXTRA_MARK));
    // ids stay unique
    let ids: Vec<&str> = out.match_indices("(uuid \"").map(|(i, _)| &out[i + 7..i + 43]).collect();
    let mut sorted = ids.clone();
    sorted.sort();
    sorted.dedup();
    assert_eq!(sorted.len(), ids.len(), "duplicate uuids");
    // deleteExtra removes it
    let (out2, done2) = pcb_sync::apply(&out, &after, &nl, &pcb_sync::ApplyOpts { delete_extra: true, update_footprints: true }, &module).unwrap();
    assert_eq!(done2["extrasDeleted"], json!(["MH1"]));
    let after2 = pcb::parse_pcb_text(&out2, "x").unwrap();
    assert_eq!(pcb_sync::compare(&after2, &out2, &nl)["inSync"], true);
    let _ = std::fs::remove_dir_all(&d);
}