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
//! Schematic hierarchy, connectivity, checks and review on the KiCad 10 fixture project in
//! tests/fixtures/sch_hier: hier.kicad_sch (root) with sheets Power and MCU, and Filter nested
//! in Power (2 levels), plus checks.kicad_sch with one of each connectivity defect. The
//! fixtures were written by eeschema-format code and loaded by KiCad 10.0.2 (service-kicad ERC
//! and SVG export) before being committed. When `service-kicad` is on PATH, files these
//! modules write are run through KiCad 10's ERC too.

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

use kicad_core::netlist::parse_kicadxml;
use kicad_core::sch_check::{self, CheckOpts};
use kicad_core::sch_hier::{self, Hierarchy, Paper, TitleEdit};
use kicad_core::sch_model::{self, Connectivity, Item};
use kicad_core::sch_review;
use kicad_core::sch_sheets::Workspace;
use kicad_core::schematic::{new_uuid, Schematic};

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

fn copy_project(tag: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("kc-schhier-{tag}-{}-{}", std::process::id(), new_uuid()));
    std::fs::create_dir_all(&d).unwrap();
    for e in std::fs::read_dir(fixtures()).unwrap().flatten() {
        std::fs::copy(e.path(), d.join(e.file_name())).unwrap();
    }
    d
}

#[test]
fn fixtures_round_trip_byte_identical() {
    for f in ["hier", "power", "filter", "mcu", "checks"] {
        let p = fixtures().join(format!("{f}.kicad_sch"));
        let text = std::fs::read_to_string(&p).unwrap();
        let s = Schematic::parse(&text).unwrap();
        assert_eq!(s.to_text(), text, "{f} round trip");
        assert_eq!(s.version().unwrap(), sch_hier::SCH_VERSION);
    }
}

#[test]
fn hierarchy_walks_two_levels() {
    let h = Hierarchy::load(&fixtures().join("hier.kicad_sch")).unwrap();
    let human: Vec<&str> = h.instances.iter().map(|i| i.human.as_str()).collect();
    assert_eq!(human, vec!["/", "/Power/", "/Power/Filter/", "/MCU/"]);
    let pages: Vec<&str> = h.instances.iter().map(|i| i.page.as_deref().unwrap_or("")).collect();
    assert_eq!(pages, vec!["1", "2", "3", "4"]);
    assert_eq!(h.docs.len(), 4);
    let filter = &h.instances[2];
    assert_eq!(filter.depth, 2);
    assert_eq!(filter.path.matches('/').count(), 3);
    // the instance path is what the child's symbols carry
    let fs = h.sch(2).unwrap();
    let m = sch_model::extract(fs, Some(&filter.path));
    assert!(m.symbols.iter().any(|s| s.reference == "D1"));
    assert!(h.references().contains("U2"));
    assert_eq!(h.project_name(), "hier");
    assert_eq!(sch_hier::project_root_for(&fixtures().join("mcu.kicad_sch")).unwrap(), fixtures().join("hier.kicad_sch"));
}

#[test]
fn pins_of_rotated_and_mirrored_symbols() {
    let s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
    let m = sch_model::extract(&s, None);
    // R2 is placed at 90 degrees: its pins run horizontally
    let r2: Vec<(&str, (f64, f64))> = m.pins.iter().filter(|p| p.reference == "R2").map(|p| (p.number.as_str(), p.at)).collect();
    assert_eq!(r2, vec![("1", (72.39, 46.99)), ("2", (80.01, 46.99))]);
    // the regulator's pins carry their electrical types from lib_symbols
    let en = m.pins.iter().find(|p| p.reference == "U1" && p.number == "3").unwrap();
    assert_eq!((en.name.as_str(), en.electrical.as_str()), ("EN", "input"));
    let gnd = m.pins.iter().find(|p| p.reference == "#PWR02").unwrap();
    assert!(gnd.power_symbol);
    assert_eq!(gnd.power_net.as_deref(), Some("GND"));
    let r4 = m.symbols.iter().find(|s| s.reference == "R4").unwrap().bbox.unwrap();
    assert!((r4.1 - 46.99).abs() < 1e-6 && (r4.3 - 54.61).abs() < 1e-6, "{r4:?}");
}

/// Connectivity rules on small hand-written KiCad 10 sheets.
fn sheet(body: &str) -> Schematic {
    let (blank, _) = sch_hier::blank_sheet(&Paper::parse("A4", None, None, false).unwrap(), &TitleEdit::default());
    let text = blank.replacen("\t(sheet_instances", &format!("{body}\t(sheet_instances"), 1);
    Schematic::parse(&text).unwrap()
}

fn wire(a: (f64, f64), b: (f64, f64)) -> String {
    format!("\t(wire\n\t\t(pts\n\t\t\t(xy {} {}) (xy {} {})\n\t\t)\n\t\t(uuid \"{}\")\n\t)\n", a.0, a.1, b.0, b.1, new_uuid())
}

fn label(t: &str, at: (f64, f64)) -> String {
    format!("\t(label \"{t}\"\n\t\t(at {} {} 0)\n\t\t(uuid \"{}\")\n\t)\n", at.0, at.1, new_uuid())
}

fn junction(at: (f64, f64)) -> String {
    format!("\t(junction\n\t\t(at {} {})\n\t\t(diameter 0)\n\t\t(uuid \"{}\")\n\t)\n", at.0, at.1, new_uuid())
}

#[test]
fn connectivity_rules() {
    // a T: a wire end on another wire's interior joins it; a label anywhere on a wire names it
    let s = sheet(&[wire((0.0, 0.0), (20.0, 0.0)), wire((10.0, 0.0), (10.0, 10.0)), label("A", (5.0, 0.0))].concat());
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    assert_eq!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
    assert_eq!(c.net_name(&m, c.of[&Item::Wire(1)]), "A");
    // a crossing without a junction does not connect; with one it does
    let s = sheet(&[wire((0.0, 0.0), (20.0, 0.0)), wire((10.0, -10.0), (10.0, 10.0))].concat());
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    assert_ne!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
    let t = sch_check::trace_point(&m, &c, (10.0, 0.0), 0.05);
    assert_eq!(t["crossingWithoutJunction"], true);
    let s = sheet(&[wire((0.0, 0.0), (20.0, 0.0)), wire((10.0, -10.0), (10.0, 10.0)), junction((10.0, 0.0))].concat());
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    assert_eq!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
    // same-named local labels join two separate wires
    let s = sheet(&[wire((0.0, 0.0), (10.0, 0.0)), wire((0.0, 20.0), (10.0, 20.0)), label("N", (0.0, 0.0)), label("N", (10.0, 20.0))].concat());
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    assert_eq!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
    assert!(sch_check::dangling_ends(&m).len() == 2, "the far ends dangle");
}

#[test]
fn a_pin_mid_wire_needs_a_junction() {
    // the fixture's R6 and R7 sit mid-wire WITH junctions: both are on NETA
    let s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    let pin = |r: &str, n: &str| m.pins.iter().position(|p| p.reference == r && p.number == n).unwrap();
    assert_eq!(c.of[&Item::Pin(pin("R6", "1"))], c.of[&Item::Pin(pin("R7", "1"))]);
    // drop the junctions: the pins fall off the wire
    let text = std::fs::read_to_string(fixtures().join("checks.kicad_sch")).unwrap();
    let mut s2 = Schematic::parse(&text).unwrap();
    let js: Vec<String> = m.junctions.iter().map(|j| j.uuid.clone()).collect();
    for u in js {
        s2.delete(&u).unwrap();
    }
    let m2 = sch_model::extract(&s2, None);
    let c2 = Connectivity::build(&m2);
    let wire_net = c2.of[&Item::Wire(m2.wires.iter().position(|w| w.a == (50.8, 76.2)).unwrap())];
    assert_ne!(c2.of[&Item::Pin(pin("R6", "1"))], wire_net);
}

#[test]
fn check_on_the_defect_sheet() {
    let s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    let r = sch_check::check(&m, &c, &CheckOpts { overlaps: true });
    let count = |k: &str| r.findings.iter().filter(|f| f.check == k).count();
    assert_eq!(count("dangling_wire_end"), 2);
    assert_eq!(count("floating_label"), 1);
    assert_eq!(count("unconnected_pin"), 1);
    assert_eq!(count("shorted_nets"), 1);
    assert_eq!(count("single_pin_net"), 3);
    assert!(count("overlap") >= 2);
    // the regulator's nets carry power names, so they are judged cross-sheet, not flagged
    assert_eq!(r.cross_sheet_skipped, 3);
    // the power sheet: +3V3 next to the VOUT hierarchical label is a soft "multiple names"
    let s = Schematic::load(&fixtures().join("power.kicad_sch")).unwrap();
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    let r = sch_check::check(&m, &c, &CheckOpts::default());
    let sh: Vec<_> = r.findings.iter().filter(|f| f.check == "shorted_nets").collect();
    assert_eq!(sh.len(), 1);
    assert_eq!(sh[0].severity, "warning");
    assert_eq!(sh[0].nets, vec!["+3V3", "VOUT"]);
}

#[test]
fn two_rails_on_one_wire_are_an_error() {
    let s = sheet(&[
        wire((0.0, 0.0), (20.0, 0.0)),
        "\t(global_label \"+5V\"\n\t\t(shape input)\n\t\t(at 0 0 180)\n\t\t(uuid \"g1\")\n\t)\n".to_string(),
        "\t(global_label \"+3V3\"\n\t\t(shape input)\n\t\t(at 20 0 0)\n\t\t(uuid \"g2\")\n\t)\n".to_string(),
    ]
    .concat());
    let m = sch_model::extract(&s, None);
    let c = Connectivity::build(&m);
    let r = sch_check::check(&m, &c, &CheckOpts::default());
    let f = r.findings.iter().find(|f| f.check == "shorted_nets").unwrap();
    assert_eq!(f.severity, "error");
}

#[test]
fn fix_plan_and_apply_round_trip() {
    let mut s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
    let m = sch_model::extract(&s, None);
    let (moves, left) = sch_check::plan_fixes(&m, 0.5);
    assert_eq!(moves.len(), 1);
    assert_eq!(left.len(), 1);
    assert!(!moves[0].diagonal);
    assert_eq!(sch_check::apply_moves(&mut s, &moves), 1);
    let s2 = Schematic::parse(&s.to_text()).unwrap();
    let m2 = sch_model::extract(&s2, None);
    assert_eq!(sch_check::dangling_ends(&m2).len(), 1);
    // a tolerance too tight to reach finds nothing
    assert!(sch_check::plan_fixes(&m, 0.2).0.is_empty());
}

#[test]
fn review_from_the_hierarchy() {
    let h = Hierarchy::load(&fixtures().join("hier.kicad_sch")).unwrap();
    let m = sch_review::from_hierarchy(&h);
    assert_eq!(m.sheets, 4);
    // nets join across the hierarchy: the root's VOUT-to-VDD wire, +3V3 and the MCU's VDD are one
    let u2_vdd = m.parts.iter().find(|p| p.reference == "U2").unwrap().pins.iter().find(|p| p.number == "8").unwrap();
    assert_eq!(u2_vdd.net, "+3V3");
    let j2 = m.parts.iter().find(|p| p.reference == "J2").unwrap();
    assert_eq!(j2.pins[0].net, "/MCU/SDA");
    let d1 = m.parts.iter().find(|p| p.reference == "D1").unwrap();
    assert!(d1.pins.iter().any(|p| p.net == "+5V"), "VIN in Filter is +5V through the sheet pin");
    let f = sch_review::review(&m, None);
    let checks: Vec<&str> = f.iter().map(|x| x.check).collect();
    for want in ["decoupling", "i2c_pullups", "reset_enable", "led_resistor", "regulator_caps", "bulk_capacitance"] {
        assert!(checks.contains(&want), "missing {want}: {checks:?}");
    }
    assert!(!checks.contains(&"annotation"));
    let s = sch_review::summary(&m, &f);
    assert_eq!(s["errors"], 1);
}

/// The same checks on kicad-cli's netlist shape (canned kicadxml, as kicad-cli 10 writes it).
#[test]
fn review_from_a_kicad_cli_netlist() {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<export version="E">
  <components>
    <comp ref="U1"><value>AP2112K-3.3</value><footprint>Package_TO_SOT_SMD:SOT-23-5</footprint><libsource lib="Regulator_Linear" part="AP2112K-3.3" description="LDO"/></comp>
    <comp ref="C1"><value>10u</value><libsource lib="Device" part="C"/></comp>
    <comp ref="C2"><value>10u</value><libsource lib="Device" part="C"/></comp>
    <comp ref="U2"><value>BME280</value><libsource lib="Sensor" part="BME280"/></comp>
    <comp ref="R1"><value>4k7</value><libsource lib="Device" part="R"/></comp>
    <comp ref="R2"><value>4k7</value><libsource lib="Device" part="R"/></comp>
    <comp ref="C3"><value>100n</value><libsource lib="Device" part="C"/></comp>
    <comp ref="D1"><value>RED</value><libsource lib="Device" part="LED"/></comp>
    <comp ref="R3"><value>1k</value><libsource lib="Device" part="R"/></comp>
    <comp ref="R3"><value>1k</value><libsource lib="Device" part="R"/></comp>
  </components>
  <nets>
    <net code="1" name="+5V"><node ref="U1" pin="1" pinfunction="VIN" pintype="power_in"/><node ref="U1" pin="3" pinfunction="EN" pintype="input"/><node ref="C1" pin="1" pintype="passive"/></net>
    <net code="2" name="GND"><node ref="U1" pin="2" pinfunction="GND" pintype="power_in"/><node ref="C1" pin="2" pintype="passive"/><node ref="C2" pin="2" pintype="passive"/><node ref="U2" pin="1" pinfunction="GND" pintype="power_in"/><node ref="C3" pin="2" pintype="passive"/><node ref="D1" pin="1" pinfunction="K" pintype="passive"/></net>
    <net code="3" name="+3V3"><node ref="U1" pin="5" pinfunction="VOUT" pintype="power_out"/><node ref="C2" pin="1" pintype="passive"/><node ref="U2" pin="8" pinfunction="VDD" pintype="power_in"/><node ref="U2" pin="6" pinfunction="VDDIO" pintype="power_in"/><node ref="R1" pin="2" pintype="passive"/><node ref="R2" pin="2" pintype="passive"/><node ref="C3" pin="1" pintype="passive"/></net>
    <net code="4" name="/SDA"><node ref="U2" pin="3" pinfunction="SDI" pintype="bidirectional"/><node ref="R1" pin="1" pintype="passive"/></net>
    <net code="5" name="/SCL"><node ref="U2" pin="4" pinfunction="SCK" pintype="input"/><node ref="R2" pin="1" pintype="passive"/></net>
    <net code="6" name="Net-(D1-A)"><node ref="D1" pin="2" pinfunction="A" pintype="passive"/><node ref="R3" pin="1" pintype="passive"/></net>
    <net code="7" name="+5V_LED"><node ref="R3" pin="2" pintype="passive"/></net>
  </nets>
</export>"#;
    let n = parse_kicadxml(xml).unwrap();
    let m = sch_review::from_netlist(&n);
    assert_eq!(m.source, "netlist");
    let f = sch_review::review(&m, None);
    let checks: Vec<&str> = f.iter().map(|x| x.check).collect();
    // a good design: decoupled, pulled up, EN tied to VIN, LED limited, caps at the LDO
    for clean in ["decoupling", "i2c_pullups", "reset_enable", "led_resistor", "regulator_caps", "bulk_capacitance"] {
        assert!(!checks.contains(&clean), "{clean} flagged: {:#?}", f);
    }
    // the netlist folds the two R3s into one part: duplicates come from the files
    assert!(f.is_empty(), "{f:#?}");
    let mut m = m;
    m.occurrences.push(("R3".into(), 1, "/ other".into()));
    let f = sch_review::review(&m, Some(&["annotation".to_string()]));
    assert_eq!(f.len(), 1);
    assert!(f[0].message.contains("Reference R3 is used by 2"));
    // the files' occurrences of the fixture: every symbol once
    let h = Hierarchy::load(&fixtures().join("hier.kicad_sch")).unwrap();
    assert_eq!(sch_review::occurrences(&h).len(), 10);
}

#[test]
fn workspace_ops_write_files_that_parse_and_load() {
    let d = copy_project("ws");
    let root = d.join("hier.kicad_sch");
    // add a sheet to a SUB-sheet: the instance path runs through the root
    let mut ws = Workspace::open(&d.join("mcu.kicad_sch"), None).unwrap();
    assert_eq!(ws.paths().len(), 1);
    let v = ws.add_sheet("Debug", "debug.kicad_sch", Some((50.8, 120.65)), None, false).unwrap();
    assert_eq!(v["pages"][0]["page"], "5");
    assert!(v["pages"][0]["path"].as_str().unwrap().starts_with(&ws.paths()[0]));
    let w = ws.commit().unwrap();
    assert_eq!(w.created.len(), 1);
    assert_eq!(w.changed.len(), 1);
    let h = Hierarchy::load(&root).unwrap();
    assert!(h.instances.iter().any(|i| i.human == "/MCU/Debug/" && i.doc.is_some()));
    // commit refuses to overwrite a file it was asked to create
    let mut ws = Workspace::open(&root, None).unwrap();
    ws.add_sheet("Again", "debug2.kicad_sch", None, None, false).unwrap();
    std::fs::write(d.join("debug2.kicad_sch"), "taken").unwrap();
    assert!(ws.commit().is_err());
    assert_eq!(std::fs::read_to_string(d.join("debug2.kicad_sch")).unwrap(), "taken");
    // reuse an existing sheet file: its parts get their own references for the new use
    std::fs::remove_file(d.join("debug2.kicad_sch")).unwrap();
    let mut ws = Workspace::open(&root, None).unwrap();
    let v = ws.add_sheet("Filter2", "filter.kicad_sch", None, None, true).unwrap();
    assert_eq!(v["pinsAdded"][0]["name"], "VIN");
    let renamed: Vec<String> = v["renamed"].as_array().unwrap().iter().map(|r| r["to"].as_str().unwrap().to_string()).collect();
    assert!(renamed.contains(&"D2".to_string()), "{renamed:?}");
    ws.commit().unwrap();
    let h = Hierarchy::load(&root).unwrap();
    let rm = sch_review::from_hierarchy(&h);
    let anno = sch_review::review(&rm, Some(&["annotation".to_string()]));
    assert!(anno.is_empty(), "{anno:#?}");
    for (p, _) in &h.docs {
        Schematic::load(p).unwrap();
    }
    let _ = std::fs::remove_dir_all(&d);
}

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's own verdict on what these modules write: a blank sheet with a custom page and
/// title block, a parent with a new sheet and imported pins, and a duplicated sheet. ERC must
/// produce a report (KiCad refusing a file fails here).
#[test]
fn service_kicad_loads_what_we_write() {
    let Some(svc) = service_kicad() else {
        eprintln!("service-kicad not on PATH; skipping the live KiCad 10 test");
        return;
    };
    let d = copy_project("live");
    let (blank, _) = sch_hier::blank_sheet(&Paper::parse("custom", Some(250.0), Some(180.0), false).unwrap(), &TitleEdit { title: Some("Live".into()), comments: vec![(1, "c1".into())], ..Default::default() });
    std::fs::write(d.join("blank.kicad_sch"), blank).unwrap();
    let mut ws = Workspace::open(&d.join("hier.kicad_sch"), None).unwrap();
    ws.duplicate_sheet("MCU", Some("MCU_B"), None, None).unwrap();
    ws.add_sheet("FilterReuse", "filter.kicad_sch", None, None, true).unwrap();
    ws.add_pin("Power", "EXTRA", "passive", None, None).unwrap();
    ws.commit().unwrap();
    for f in ["blank.kicad_sch", "hier.kicad_sch", "mcu_copy.kicad_sch", "filter.kicad_sch"] {
        let out = d.join(format!("{f}.erc.json"));
        let r = Command::new(&svc).args(["sch", "erc", "--format", "json", "--out"]).arg(&out).arg(d.join(f)).output().expect("service-kicad runs");
        let report = std::fs::read_to_string(&out).unwrap_or_default();
        let v: serde_json::Value = serde_json::from_str(&report).unwrap_or_else(|_| panic!("{f}: KiCad 10 wrote no ERC report: {}", String::from_utf8_lossy(&r.stderr)));
        assert!(v["kicad_version"].as_str().unwrap_or("").starts_with("10."), "{f}: {v}");
        assert!(v["sheets"].is_array(), "{f}");
    }
    let _ = std::fs::remove_dir_all(&d);
}