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 newer schematic edits (`kicad_core::sch_edit`) on a KiCad 10 fixture
//! (`fixtures/sch-edit/edit10.kicad_sch`, version 20260306, built from the KiCad 10 stock
//! Device, Amplifier_Operational and power libraries and loaded by KiCad 10.0.2's ERC):
//! pin geometry for rotated and multi-unit parts, the junction rule, wire delete and split,
//! multi-unit move with wire stretch, rotate, region move, annotate across sheets, symbol
//! replace and library refresh, field reset, labels, text, buses. Every edited text must
//! parse again. When `service-kicad` is on PATH, KiCad 10 itself checks an edited sheet.

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

use kicad_core::sch_edit::*;
use kicad_core::schematic::*;

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch-edit")
}

fn fixture() -> String {
    std::fs::read_to_string(fixture_dir().join("edit10.kicad_sch")).expect("edit10.kicad_sch readable")
}

fn sch() -> Schematic {
    Schematic::parse(&fixture()).unwrap()
}

fn minilib(name: &str) -> Node {
    load_lib_symbol(&fixture_dir().join("MiniLib.kicad_sym"), name).unwrap().block
}

fn reparse(s: &Schematic) -> Schematic {
    let text = s.to_text();
    let again = Schematic::parse(&text).expect("edited text parses again");
    assert_eq!(again.to_text(), text, "and writes back byte identical");
    again
}

fn pin(s: &Schematic, spec: &str) -> PinInfo {
    s.find_pin(spec).unwrap_or_else(|e| panic!("{spec}: {e}"))
}

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

#[test]
fn fixture_is_kicad_10_and_round_trips() {
    let text = fixture();
    let s = Schematic::parse(&text).unwrap();
    assert_eq!(s.version().as_deref(), Some("20260306"));
    assert_eq!(s.to_text(), text);
}

#[test]
fn components_list_units_and_fields() {
    let s = sch();
    let all = s.components(None);
    let refs: Vec<String> = all.iter().map(|c| c["reference"].as_str().unwrap().to_string()).collect();
    // R? twice (two separate parts), U? once (two units of one unannotated part would still be
    // two entries: each X? instance is its own part until annotated)
    assert_eq!(refs.iter().filter(|r| *r == "R?").count(), 2);
    let u1 = all.iter().find(|c| c["reference"] == "U1").unwrap();
    assert_eq!(u1["libId"], "Amplifier_Operational:LM358");
    assert_eq!(u1["unitCount"], 3);
    let units: Vec<u64> = u1["units"].as_array().unwrap().iter().map(|u| u["unit"].as_u64().unwrap()).collect();
    assert_eq!(units, vec![1, 2, 3]);
    assert!(u1["fields"].as_array().unwrap().iter().any(|f| f["name"] == "Value" && f["value"] == "LM358"));
    let only = s.components(Some(&["R2".to_string()]));
    assert_eq!(only.len(), 1);
    assert_eq!(only[0]["units"][0]["rotation"], 90.0);
}

#[test]
fn pins_follow_rotation_and_units() {
    let s = sch();
    let r1 = s.pins_of("R1").unwrap();
    assert_eq!(r1.iter().map(|p| (p.number.as_str(), p.at)).collect::<Vec<_>>(), vec![("1", (100.33, 46.99)), ("2", (100.33, 54.61))]);
    assert_eq!(dir_name(r1[0].out), "up");
    assert_eq!(dir_name(r1[1].out), "down");
    // R2 is turned 90: pin 1 on the left, where KiCad's ERC accepts the no-connect flag
    let p = pin(&s, "R2.1");
    assert_eq!(p.at, (127.0, 50.8));
    assert_eq!(dir_name(p.out), "left");
    let u1 = s.pins_of("U1").unwrap();
    assert_eq!(u1.len(), 8, "three units: 1,2,3 + 5,6,7 + 4,8");
    assert_eq!(pin(&s, "U1.1").at, (160.02, 81.28));
    assert_eq!(pin(&s, "U1.1").etype, "output");
    assert_eq!(pin(&s, "U1.6").at, (144.78, 111.76));
    assert_eq!(pin(&s, "U1.4").at, (180.34, 102.87));
    assert_eq!(pin(&s, "U1.V+").number, "8");
    assert!(s.find_pin("U1.99").is_err());
    assert!(s.find_pin("U1").is_err());
}

#[test]
fn net_walk_and_touching() {
    let s = sch();
    let all = s.all_pins();
    let (nets, others) = s.net_at(pin(&s, "R1.2").at, &all);
    assert_eq!(nets, vec!["VOUT".to_string()]);
    assert!(others.contains(&"R1.2".to_string()));
    let (nets, others) = s.net_at(pin(&s, "C1.2").at, &all);
    assert_eq!(nets, vec!["GND".to_string()], "the power symbol names the net");
    assert!(others.contains(&"#PWR01.1".to_string()));
    let t = s.touching(pin(&s, "R2.1").at);
    assert_eq!(t["noConnect"], true);
    let t = s.touching(pin(&s, "R1.2").at);
    assert_eq!(t["wires"].as_array().unwrap().len(), 1);
}

#[test]
fn items_lists_every_kind() {
    let s = sch();
    let v = s.items(None);
    assert_eq!(v["wires"].as_array().unwrap().len(), 4);
    assert_eq!(v["buses"].as_array().unwrap().len(), 1);
    assert_eq!(v["junctions"].as_array().unwrap().len(), 1);
    assert_eq!(v["noConnects"].as_array().unwrap().len(), 1);
    assert_eq!(v["texts"][0]["text"], "edit fixture");
    assert_eq!(v["powerSymbols"][0]["net"], "GND");
    let kinds: BTreeSet<String> = v["labels"].as_array().unwrap().iter().map(|l| l["kind"].as_str().unwrap().to_string()).collect();
    assert_eq!(kinds, ["global_label", "label"].iter().map(|s| s.to_string()).collect());
    let only = s.items(Some(&["wires".to_string()]));
    assert_eq!(only.as_object().unwrap().len(), 1);
}

#[test]
fn junction_rule_and_auto_junctions() {
    let mut s = sch();
    assert!(s.junction_needed((110.49, 60.96)), "a T needs a dot");
    assert!(!s.junction_needed((100.33, 60.96)), "a corner does not");
    assert!(!s.junction_needed(pin(&s, "R1.2").at), "a pin with one wire does not");
    // A branch off the middle of the VOUT wire gets a dot; its free end does not.
    let (w, j) = s.add_wire_auto(&[(115.57, 60.96), (115.57, 66.04)]).unwrap();
    assert_eq!(w.len(), 1);
    assert_eq!(j.len(), 1);
    assert_eq!(j[0].1, (115.57, 60.96));
    // A wire running through an existing wire end gets a dot there.
    let (_, j) = s.add_wire_auto(&[(110.49, 76.2), (120.65, 76.2)]).unwrap();
    assert!(j.is_empty());
    let (_, j) = s.add_wire_auto(&[(115.57, 66.04), (115.57, 76.2)]).unwrap();
    assert_eq!(j.iter().map(|x| x.1).collect::<Vec<_>>(), vec![(115.57, 76.2)], "an end on another wire's interior");
    // Adding a dot by hand where one is.
    assert!(s.junction_at((115.57, 60.96)).is_some());
    let txt = s.to_text();
    assert!(txt.contains("\t(junction\n\t\t(at 115.57 60.96)\n\t\t(diameter 0)\n\t\t(color 0 0 0 0)\n\t\t(uuid \""));
    reparse(&s);
}

#[test]
fn delete_wire_prunes_and_split_wire_cuts() {
    let mut s = sch();
    let branch = s.wires_between((110.49, 60.96), (110.49, 71.12));
    assert_eq!(branch.len(), 1);
    let (gone, pruned) = s.delete_wires(&branch).unwrap();
    assert_eq!(gone[0].a, (110.49, 60.96));
    assert_eq!(pruned.len(), 1, "the T dot joins nothing now");
    assert!(s.junction_at((110.49, 60.96)).is_none());
    assert!(s.delete_wires(&["nope".to_string()]).is_err());

    let before = s.wires().len();
    let (a, b, j) = s.split_wire((105.41, 60.96), None, true).unwrap();
    assert_ne!(a, b);
    assert!(j.is_some());
    assert_eq!(s.wires().len(), before + 1);
    let w = s.wires();
    let wa = w.iter().find(|x| x.uuid == a).unwrap();
    let wb = w.iter().find(|x| x.uuid == b).unwrap();
    assert_eq!((wa.a, wa.b), ((100.33, 60.96), (105.41, 60.96)));
    assert_eq!((wb.a, wb.b), ((105.41, 60.96), (120.65, 60.96)));
    assert_eq!(s.split_wire((100.33, 60.96), None, true).unwrap_err().code, "not_on_wire");
    // the net still reaches the label across the cut
    let all = s.all_pins();
    assert_eq!(s.net_at(pin(&s, "R1.2").at, &all).0, vec!["VOUT".to_string()]);
    reparse(&s);
}

#[test]
fn move_every_unit_and_stretch_wires() {
    let mut s = sch();
    let (uuids, _) = s.move_units("U1", (2.54, 0.0), false).unwrap();
    assert_eq!(uuids.len(), 3);
    assert_eq!(pin(&s, "U1.1").at, (162.56, 81.28));
    assert_eq!(pin(&s, "U1.4").at, (182.88, 102.87));
    let u1 = s.components(Some(&["U1".to_string()]));
    let ref_field = u1[0]["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Reference").unwrap().clone();
    assert!(ref_field["at"][0].as_f64().unwrap() > 152.4 + 2.0, "fields move with the anchor");

    // R1 down its own axis: the vertical wire end just follows
    let (_, st) = s.move_units("R1", (0.0, -2.54), true).unwrap();
    assert_eq!(st.wires_moved.len(), 1);
    assert!(st.wires_added.is_empty());
    assert_eq!(s.touching(pin(&s, "R1.2").at)["wires"].as_array().unwrap().len(), 1);
    // R1 sideways: the vertical wire stays vertical and a horizontal dogleg joins the pin
    let (_, st) = s.move_units("R1", (5.08, 0.0), true).unwrap();
    assert_eq!(st.wires_added.len(), 1);
    let p2 = pin(&s, "R1.2").at;
    assert_eq!(p2, (105.41, 52.07));
    for w in s.wires() {
        assert!((w.a.0 - w.b.0).abs() < 1e-6 || (w.a.1 - w.b.1).abs() < 1e-6, "every wire stays horizontal or vertical: {w:?}");
    }
    let all = s.all_pins();
    assert_eq!(s.net_at(p2, &all).0, vec!["VOUT".to_string()], "still on its net");

    // The no-connect on R2.1 travels with the pin
    let (_, st) = s.move_units("R2", (0.0, 2.54), true).unwrap();
    assert_eq!(st.flags_moved.len(), 1);
    assert_eq!(s.touching(pin(&s, "R2.1").at)["noConnect"], true);
    assert!(s.move_units("R99", (1.0, 1.0), false).is_err());
    reparse(&s);
}

#[test]
fn rotate_turns_units_and_fields() {
    let mut s = sch();
    let (old, new, uuids) = s.rotate_units("R2", 0.0).unwrap();
    assert_eq!((old, new, uuids.len()), (90.0, 0.0, 1));
    assert_eq!(pin(&s, "R2.1").at, (130.81, 46.99), "pin 1 back on top");
    let (_, _, u) = s.rotate_units("U1", 180.0).unwrap();
    assert_eq!(u.len(), 3);
    assert_eq!(pin(&s, "U1.1").at, (144.78, 81.28), "the output now on the left");
    assert_eq!(pin(&s, "U1.4").at, (185.42, 87.63), "unit 3 turned about its own anchor");
    assert!(s.rotate_units("R2", 45.0).is_err());
    // fields turn about the anchor: a field 2.54 right of R1 ends up 2.54 above after 90
    let before = s.components(Some(&["R1".to_string()]));
    let f = |c: &Vec<serde_json::Value>, n: &str| -> (f64, f64) {
        let f = c[0]["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap().clone();
        (f["at"][0].as_f64().unwrap(), f["at"][1].as_f64().unwrap())
    };
    let (vx, vy) = f(&before, "Value");
    s.rotate_units("R1", 90.0).unwrap();
    let after = s.components(Some(&["R1".to_string()]));
    let (ax, ay) = f(&after, "Value");
    let (dx, dy) = (vx - 100.33, vy - 50.8);
    assert!(((ax - 100.33) - dy).abs() < 1e-3 && ((ay - 50.8) + dx).abs() < 1e-3, "({vx},{vy}) -> ({ax},{ay})");
    // mirrored: the stored angle runs the other way
    let txt = s.to_text().replacen("(lib_id \"Device:C\")\n\t\t(at 100.33 81.28 0)", "(lib_id \"Device:C\")\n\t\t(at 100.33 81.28 0)\n\t\t(mirror x)", 1);
    let mut m = Schematic::parse(&txt).unwrap();
    m.rotate_units("C1", 90.0).unwrap();
    assert!(m.to_text().contains("(lib_id \"Device:C\")\n\t\t(at 100.33 81.28 90)\n\t\t(mirror x)"));
    reparse(&s);
}

#[test]
fn region_move_takes_what_is_inside() {
    let mut s = sch();
    let moved = s.move_region([45.0, 95.0, 70.0, 130.0], (0.0, -12.7), true, true, true);
    assert_eq!(moved["symbols"].as_array().unwrap().len(), 3, "two R? and the C?");
    assert!(moved.get("wires").is_none());
    let moved = s.move_region([95.0, 55.0, 125.0, 75.0], (0.0, 2.54), true, true, true);
    assert!(moved["wires"].as_array().unwrap().len() >= 2);
    assert_eq!(moved["junctions"].as_array().unwrap().len(), 1);
    assert_eq!(moved["labels"].as_array().unwrap().len(), 2);
    // the wire from R1.2 stretched: its pin end stayed, its other end moved
    assert!(s.touching(pin(&s, "R1.2").at)["wires"].as_array().unwrap().len() == 1);
    reparse(&s);
}

#[test]
fn annotate_numbers_question_marks_like_eeschema() {
    let s = sch();
    let used = s.references_used();
    let plan = s.annotate_plan(&used, false);
    let got: Vec<(String, String, u32)> = plan.iter().map(|p| (p.from.clone(), p.to.clone(), p.unit)).collect();
    assert_eq!(
        got,
        vec![
            ("R?".into(), "R3".into(), 1), // x 50.8 first
            ("C?".into(), "C2".into(), 1),
            ("R?".into(), "R4".into(), 1),
            ("U?".into(), "U2".into(), 1),
            ("U?".into(), "U2".into(), 2), // the second unit packs into the same part
        ]
    );
    // numbers used on other sheets are skipped
    let mut more = used.clone();
    more.insert("R3".into());
    more.insert("U2".into());
    let plan = s.annotate_plan(&more, false);
    assert_eq!(plan[0].to, "R4");
    assert_eq!(plan[3].to, "U3");
    // by y: the C? (y 127) comes after both R? (y 101.6)
    let plan = s.annotate_plan(&used, true);
    assert_eq!(plan.iter().map(|p| p.to.as_str()).collect::<Vec<_>>(), vec!["R3", "R4", "C2", "U2", "U2"]);
    // apply
    let mut s = s;
    for p in &plan {
        s.set_unit_reference(&p.uuid, &p.to).unwrap();
    }
    let again = reparse(&s);
    assert!(again.components(None).iter().all(|c| !c["reference"].as_str().unwrap().ends_with('?')));
    assert!(again.to_text().contains("(reference \"U2\")"));
    assert_eq!(again.unit_indices("U2").len(), 2);
}

#[test]
fn project_sheets_follow_the_hierarchy() {
    let dir = temp_dir("sheets");
    let root = dir.join("proj.kicad_sch");
    let sub = dir.join("sub.kicad_sch");
    std::fs::write(dir.join("proj.kicad_pro"), "{}").unwrap();
    let sheet = "\t(sheet\n\t\t(at 50 50)\n\t\t(size 20 20)\n\t\t(uuid \"aaaaaaaa-0000-4000-8000-000000000001\")\n\t\t(property \"Sheetname\" \"sub\"\n\t\t\t(at 50 49 0)\n\t\t)\n\t\t(property \"Sheetfile\" \"sub.kicad_sch\"\n\t\t\t(at 50 71 0)\n\t\t)\n\t)\n";
    let mut t = fixture();
    t.insert_str(t.find("\t(sheet_instances").unwrap(), sheet);
    std::fs::write(&root, &t).unwrap();
    // the sub-sheet already uses R3
    let subtext = fixture().replace("\"R1\"", "\"R3\"");
    std::fs::write(&sub, subtext).unwrap();
    let sheets = project_sheets(&sub);
    assert_eq!(sheets.len(), 2, "{sheets:?}");
    let s = Schematic::load(&root).unwrap();
    let mut used = s.references_used();
    used.extend(Schematic::load(&sub).unwrap().references_used());
    assert_eq!(s.annotate_plan(&used, false)[0].to, "R4");
    let items = s.items(Some(&["sheets".to_string()]));
    assert_eq!(items["sheets"][0]["file"], "sub.kicad_sch");
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn replace_symbol_keeps_place_and_guards_pins() {
    let mut s = sch();
    let small = minilib("R_Small");
    let r = s.replace_symbol("R1", "Device:R_Small", Some(&small), false).unwrap();
    assert_eq!(r.old_lib_id, "Device:R");
    assert!(r.lib_symbol_added);
    assert!(r.pins_removed.is_empty());
    assert!(!r.old_entry_pruned, "R2 still uses Device:R");
    let c = s.components(Some(&["R1".to_string()]));
    assert_eq!(c[0]["libId"], "Device:R_Small");
    assert_eq!(c[0]["value"], "10k");
    assert_eq!(c[0]["units"][0]["at"], serde_json::json!([100.33, 50.8]));
    // R_Small's pins are 2.54 from the anchor, so pin 2 moved off the wire
    assert_eq!(pin(&s, "R1.2").at, (100.33, 53.34));

    // A one-pin symbol would lose pin 2 of C1
    let one = parse_node("(symbol \"One\" (property \"Reference\" \"TP\" (at 0 0 0) (effects (font (size 1.27 1.27)))) (property \"Value\" \"One\" (at 0 0 0) (effects (font (size 1.27 1.27)))) (symbol \"One_1_1\" (pin passive line (at 0 0 90) (length 0) (name \"1\" (effects (font (size 1.27 1.27)))) (number \"1\" (effects (font (size 1.27 1.27)))))))").unwrap();
    let e = s.replace_symbol("C1", "Test:One", Some(&one), false).unwrap_err();
    assert_eq!(e.code, "pins_lost");
    assert!(!s.has_lib_symbol("Test:One"), "a refusal changes nothing");
    let r = s.replace_symbol("C1", "Test:One", Some(&one), true).unwrap();
    assert_eq!(r.pins_removed, vec!["2".to_string()]);
    assert!(!r.old_entry_pruned, "C? still uses Device:C");
    assert!(s.has_lib_symbol("Device:C"));
    let r = s.replace_symbol("C?", "Test:One", None, true).unwrap();
    assert!(r.old_entry_pruned, "now nothing uses Device:C");
    assert!(!s.has_lib_symbol("Device:C"));
    // a three-unit part cannot become a one-unit resistor
    assert_eq!(s.replace_symbol("U1", "Device:R", None, false).unwrap_err().code, "unit_missing");
    reparse(&s);
}

#[test]
fn update_from_library_detects_changes() {
    let mut s = sch();
    // the fixture was built from these very libraries: nothing to do
    assert!(s.update_lib_symbol("Device:R", &minilib("R"), false).unwrap().is_none());
    let lm = minilib("LM358");
    assert!(s.update_lib_symbol("Amplifier_Operational:LM358", &lm, false).unwrap().is_none(), "flattened extends compares equal");
    // a changed description is an update
    let mut changed = minilib("R");
    let text = changed.to_text().replace("\"Resistor\"", "\"Resistor, updated\"");
    changed = parse_node(&text).unwrap();
    let sync = s.update_lib_symbol("Device:R", &changed, false).unwrap().unwrap();
    assert!(sync.added.is_empty() && sync.removed.is_empty());
    assert!(s.to_text().contains("\"Resistor, updated\""));
    // a library version that drops pin 2 is refused unless forced
    let one = parse_node(&minilib("R").to_text().replacen("(number \"2\"", "(number \"9\"", 1)).unwrap();
    assert_eq!(s.update_lib_symbol("Device:R", &one, false).unwrap_err().code, "pins_lost");
    let sync = s.update_lib_symbol("Device:R", &one, true).unwrap().unwrap();
    assert_eq!(sync.removed, vec!["2".to_string()]);
    assert_eq!(sync.added, vec!["9".to_string()]);
    assert_eq!(s.lib_symbol_keys().len(), 4);
    reparse(&s);
}

#[test]
fn reset_fields_uses_library_offsets() {
    let mut s = sch();
    let done = s.reset_fields("R2", &["Reference".to_string(), "Value".to_string()]).unwrap();
    assert_eq!(done, vec!["1:Reference".to_string(), "1:Value".to_string()]);
    let c = s.components(Some(&["R2".to_string()]));
    let r = c[0]["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Reference").unwrap().clone();
    // Device:R puts Reference at (2.032, 0) angle 90; R2 is turned 90, so it lands 2.032 above
    assert_eq!(r["at"], serde_json::json!([130.81, 48.768]));
    assert_eq!(r["angle"], 90.0);
    let done = s.reset_fields("U1", &["Reference".to_string()]).unwrap();
    assert_eq!(done.len(), 3);
    reparse(&s);
}

#[test]
fn labels_rotate_with_justify() {
    let mut s = sch();
    let idx = s.label_indices(None, Some("VOUT"), Some((120.65, 60.96)));
    assert_eq!(idx.len(), 1);
    s.set_label_rotation(idx[0], 180.0).unwrap();
    let txt = s.to_text();
    assert!(txt.contains("(label \"VOUT\"\n\t\t(at 120.65 60.96 180)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify right bottom)\n\t\t)"), "{txt}");
    let g = s.label_indices(None, Some("SDA"), None);
    s.set_label_rotation(g[0], 270.0).unwrap();
    assert!(s.to_text().contains("(at 63.5 60.96 270)\n\t\t(fields_autoplaced yes)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify right)\n\t\t)"));
    assert_eq!(s.label_indices(None, Some("VOUT"), None).len(), 2);
    assert!(s.set_label_rotation(idx[0], 10.0).is_err());
    reparse(&s);
}

#[test]
fn text_bus_and_entries() {
    let mut s = sch();
    let u = s.add_text("Hello\nworld", (25.4, 25.4), 0.0, 2.54, &["left".into(), "bottom".into()], true, false).unwrap();
    let txt = s.to_text();
    assert!(txt.contains(&format!("\t(text \"Hello\\nworld\"\n\t\t(exclude_from_sim no)\n\t\t(at 25.4 25.4 0)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 2.54 2.54)\n\t\t\t\t(bold yes)\n\t\t\t)\n\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid \"{u}\")\n\t)")), "{txt}");
    assert!(s.add_text("x", (0.0, 0.0), 0.0, 1.27, &["middle".into()], false, false).is_err());
    let b = s.add_segments("bus", &[(220.98, 50.8), (220.98, 76.2)]).unwrap();
    assert_eq!(b.len(), 1);
    let e = s.add_bus_entry((218.44, 63.5), (2.54, -2.54)).unwrap();
    assert!(s.to_text().contains(&format!("\t(bus_entry\n\t\t(at 218.44 63.5)\n\t\t(size 2.54 -2.54)\n\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n\t\t(uuid \"{e}\")\n\t)")));
    assert!(s.add_bus_entry((0.0, 0.0), (0.0, 2.54)).is_err());
    let items = s.items(Some(&["busEntries".to_string(), "buses".to_string()]));
    assert_eq!(items["busEntries"][0]["to"], serde_json::json!([220.98, 60.96]));
    assert_eq!(items["buses"].as_array().unwrap().len(), 2);
    reparse(&s);
}

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 a sheet edited by every operation above, and the pins wired by the
/// edits are no longer reported unconnected.
#[test]
fn service_kicad_erc_accepts_an_edited_kicad_10_sheet() {
    let Some(svc) = service_kicad() else {
        eprintln!("service-kicad not on PATH; skipping the live ERC test");
        return;
    };
    let dir = temp_dir("erc");
    let path = dir.join("edit10.kicad_sch");
    let mut s = sch();
    // wire U1.1 (out, right) to R2.2 with an L, flag U1.2/U1.3, stub U1.8 to a label
    let a = pin(&s, "U1.1");
    let b = pin(&s, "R2.2");
    s.add_wire_auto(&l_route(a.at, a.out, b.at, None)).unwrap();
    for p in ["U1.2", "U1.3"] {
        let at = pin(&s, p).at;
        s.add_no_connect(at).unwrap();
    }
    let p8 = pin(&s, "U1.8");
    let end = (p8.at.0 + p8.out.0 * 2.54, p8.at.1 + p8.out.1 * 2.54);
    s.add_wire_auto(&[p8.at, end]).unwrap();
    s.add_label("VCC", LabelKind::Local, end, label_rotation_for(p8.out), None).unwrap();
    s.move_units("C1", (0.0, 2.54), true).unwrap();
    s.rotate_units("R?", 90.0).ok();
    s.add_text("checked by KiCad 10", (50.8, 25.4), 0.0, 1.27, &[], false, false).unwrap();
    let plan = s.annotate_plan(&s.references_used(), false);
    for p in &plan {
        s.set_unit_reference(&p.uuid, &p.to).unwrap();
    }
    std::fs::write(&path, s.to_text()).unwrap();
    let report = dir.join("erc.json");
    let out = Command::new(&svc).args(["sch", "erc"]).arg(&path).args(["--format", "json", "--out"]).arg(&report).output().expect("service-kicad runs");
    assert!(report.is_file(), "service-kicad sch erc wrote no report: {}", String::from_utf8_lossy(&out.stderr));
    let rep: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
    assert!(rep["kicad_version"].as_str().unwrap_or("").starts_with("10"), "KiCad 10 checked it: {}", rep["kicad_version"]);
    let flat = kicad_core::cli::flatten_violations(&rep);
    let unconnected: Vec<String> = flat.iter().filter(|v| v["type"] == "pin_not_connected").map(|v| serde_json::to_string(&v["items"]).unwrap()).collect();
    for p in ["U1 Pin 1 ", "U1 Pin 2 ", "U1 Pin 3 ", "U1 Pin 8 ", "R2 Pin 2 ", "C1 Pin 2 "] {
        assert!(!unconnected.iter().any(|u| u.contains(p)), "{p} still unconnected: {unconnected:?}");
    }
    assert!(!serde_json::to_string(&rep).unwrap().contains("Symbol R?"), "annotated");
    let _ = std::fs::remove_dir_all(&dir);
}