kicad-mcp/tests/test_netlist.py
Ryan Malloy a129b292e4 Fix KiCad 9 s-expression netlist import and export_netlist format flags
KiCad 9 defaults to s-expression netlist export, not XML. Add
_parse_kicad_sexp() parser with pinfunction/pintype metadata, update
auto-detection to distinguish (export from <export by content.

Fix export_netlist: use kicad-cli's actual format names (kicadsexpr,
kicadxml) instead of invalid 'kicad', use --format instead of -f, and
treat non-zero exit with valid output as success with warnings.
2026-03-05 15:27:59 -07:00

433 lines
16 KiB
Python

"""Tests for netlist import tools."""
import json
import os
import textwrap
import pytest
@pytest.mark.unit
class TestParseKicadXml:
"""Tests for KiCad XML netlist parsing."""
def test_parse_basic_netlist(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
xml_content = textwrap.dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<export version="D">
<components>
<comp ref="R1">
<value>10k</value>
<footprint>Resistor_SMD:R_0402</footprint>
<libsource lib="Device" part="R"/>
</comp>
<comp ref="R2">
<value>4.7k</value>
<footprint>Resistor_SMD:R_0402</footprint>
<libsource lib="Device" part="R"/>
</comp>
<comp ref="C1">
<value>100nF</value>
<footprint>Capacitor_SMD:C_0402</footprint>
<libsource lib="Device" part="C"/>
</comp>
</components>
<nets>
<net code="1" name="GND">
<node ref="R1" pin="2"/>
<node ref="C1" pin="2"/>
</net>
<net code="2" name="VCC">
<node ref="R1" pin="1"/>
<node ref="R2" pin="1"/>
</net>
<net code="3" name="SIG">
<node ref="R2" pin="2"/>
<node ref="C1" pin="1"/>
</net>
</nets>
</export>
""")
path = os.path.join(tmp_output_dir, "test.net")
with open(path, "w") as f:
f.write(xml_content)
result = import_netlist(source_path=path)
assert result["success"] is True
assert result["format_detected"] == "kicad_xml"
assert result["statistics"]["net_count"] == 3
assert result["statistics"]["component_count"] == 3
assert result["statistics"]["connection_count"] == 6
# Check nets structure
nets = result["nets"]
assert "GND" in nets
assert ["R1", "2"] in nets["GND"]
assert ["C1", "2"] in nets["GND"]
assert "VCC" in nets
assert len(nets["VCC"]) == 2
# Check components
assert "R1" in result["components"]
assert result["components"]["R1"]["value"] == "10k"
assert result["components"]["R1"]["lib"] == "Device"
def test_auto_detects_xml_format(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
xml = '<export version="D"><components/><nets/></export>'
path = os.path.join(tmp_output_dir, "test.xml")
with open(path, "w") as f:
f.write(xml)
result = import_netlist(source_path=path, format="auto")
assert result["success"] is True
assert result["format_detected"] == "kicad_xml"
def test_malformed_xml_returns_error(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
path = os.path.join(tmp_output_dir, "bad.net")
with open(path, "w") as f:
f.write("<export><broken>")
result = import_netlist(source_path=path)
assert result["success"] is False
assert "XML parse error" in result["error"]
def test_verify_connectivity_compatible(self, tmp_output_dir):
"""The nets dict is directly usable as verify_connectivity expected param."""
from mckicad.tools.netlist import import_netlist
xml = textwrap.dedent("""\
<export version="D">
<components>
<comp ref="U1"><value>IC</value></comp>
</components>
<nets>
<net code="1" name="NET1">
<node ref="U1" pin="1"/>
<node ref="R1" pin="2"/>
</net>
</nets>
</export>
""")
path = os.path.join(tmp_output_dir, "compat.net")
with open(path, "w") as f:
f.write(xml)
result = import_netlist(source_path=path)
assert result["success"] is True
# verify_connectivity expects: {"NET1": [["U1", "1"], ["R1", "2"]]}
nets = result["nets"]
assert "NET1" in nets
assert isinstance(nets["NET1"], list)
for pin_pair in nets["NET1"]:
assert isinstance(pin_pair, list)
assert len(pin_pair) == 2
@pytest.mark.unit
class TestParseKicadSexp:
"""Tests for KiCad s-expression netlist parsing (KiCad 9+ default)."""
def test_parse_sexp_netlist(self, tmp_output_dir):
"""Parse a KiCad 9 s-expression netlist with components, nets, and pin metadata."""
from mckicad.tools.netlist import import_netlist
sexp_content = textwrap.dedent("""\
(export (version "E")
(design
(source "/path/to/project.kicad_sch")
(tool "Eeschema 9.0.7"))
(components
(comp (ref "R1")
(value "10k")
(footprint "Resistor_SMD:R_0402")
(libsource (lib "Device") (part "R") (description "Resistor")))
(comp (ref "C1")
(value "100nF")
(footprint "Capacitor_SMD:C_0402")
(libsource (lib "Device") (part "C") (description "Capacitor")))
(comp (ref "U1")
(value "ESP32")
(libsource (lib "MCU") (part "ESP32") (description "MCU"))))
(nets
(net (code "1") (name "GND") (class "Default")
(node (ref "R1") (pin "2") (pinfunction "~") (pintype "passive"))
(node (ref "C1") (pin "2") (pinfunction "~") (pintype "passive")))
(net (code "2") (name "VCC") (class "Power")
(node (ref "R1") (pin "1") (pinfunction "~") (pintype "passive"))
(node (ref "U1") (pin "1") (pinfunction "VDD") (pintype "power_in")))
(net (code "3") (name "/SPI/MOSI") (class "Default")
(node (ref "U1") (pin "5") (pinfunction "MOSI") (pintype "bidirectional"))
(node (ref "C1") (pin "1") (pinfunction "~") (pintype "passive")))))
""")
path = os.path.join(tmp_output_dir, "test.net")
with open(path, "w") as f:
f.write(sexp_content)
result = import_netlist(source_path=path)
assert result["success"] is True
assert result["format_detected"] == "kicad_sexp"
assert result["statistics"]["net_count"] == 3
assert result["statistics"]["component_count"] == 3
assert result["statistics"]["connection_count"] == 6
# Check nets structure
nets = result["nets"]
assert "GND" in nets
assert ["R1", "2"] in nets["GND"]
assert ["C1", "2"] in nets["GND"]
assert "VCC" in nets
assert "/SPI/MOSI" in nets # hierarchical net name preserved
# Check components
assert "R1" in result["components"]
assert result["components"]["R1"]["value"] == "10k"
assert result["components"]["R1"]["lib"] == "Device"
assert result["components"]["U1"]["part"] == "ESP32"
def test_sexp_pin_metadata(self, tmp_output_dir):
"""S-expression format includes pinfunction and pintype metadata."""
from mckicad.tools.netlist import import_netlist
sexp_content = textwrap.dedent("""\
(export (version "E")
(components
(comp (ref "U1")
(value "IC")
(libsource (lib "MCU") (part "IC") (description "IC"))))
(nets
(net (code "1") (name "SDA") (class "Default")
(node (ref "U1") (pin "3") (pinfunction "SDA") (pintype "bidirectional"))
(node (ref "R1") (pin "1") (pinfunction "~") (pintype "passive")))))
""")
path = os.path.join(tmp_output_dir, "meta.net")
with open(path, "w") as f:
f.write(sexp_content)
result = import_netlist(source_path=path)
assert result["success"] is True
assert "pin_metadata" in result
# U1 pin 3 should have pinfunction and pintype
assert "U1" in result["pin_metadata"]
assert "3" in result["pin_metadata"]["U1"]
assert result["pin_metadata"]["U1"]["3"]["pinfunction"] == "SDA"
assert result["pin_metadata"]["U1"]["3"]["pintype"] == "bidirectional"
def test_auto_detects_sexp_format(self, tmp_output_dir):
"""Auto-detection picks kicad_sexp for s-expression .net files."""
from mckicad.tools.netlist import import_netlist
sexp = '(export (version "E")(components)(nets))'
path = os.path.join(tmp_output_dir, "test.net")
with open(path, "w") as f:
f.write(sexp)
result = import_netlist(source_path=path, format="auto")
assert result["success"] is True
assert result["format_detected"] == "kicad_sexp"
def test_sexp_verify_connectivity_compatible(self, tmp_output_dir):
"""The nets dict from sexp parsing matches verify_connectivity shape."""
from mckicad.tools.netlist import import_netlist
sexp = textwrap.dedent("""\
(export (version "E")
(components
(comp (ref "U1") (value "IC") (libsource (lib "x") (part "y") (description "z"))))
(nets
(net (code "1") (name "NET1") (class "Default")
(node (ref "U1") (pin "1") (pinfunction "A") (pintype "output"))
(node (ref "R1") (pin "2") (pinfunction "~") (pintype "passive")))))
""")
path = os.path.join(tmp_output_dir, "compat.net")
with open(path, "w") as f:
f.write(sexp)
result = import_netlist(source_path=path)
assert result["success"] is True
nets = result["nets"]
assert "NET1" in nets
assert isinstance(nets["NET1"], list)
for pin_pair in nets["NET1"]:
assert isinstance(pin_pair, list)
assert len(pin_pair) == 2
def test_sexp_empty_sections(self, tmp_output_dir):
"""Handle s-expression netlist with empty components and nets sections."""
from mckicad.tools.netlist import import_netlist
sexp = '(export (version "E")(components)(nets))'
path = os.path.join(tmp_output_dir, "empty_sections.net")
with open(path, "w") as f:
f.write(sexp)
result = import_netlist(source_path=path)
assert result["success"] is True
assert result["statistics"]["net_count"] == 0
assert result["statistics"]["component_count"] == 0
@pytest.mark.unit
class TestParseCsv:
"""Tests for CSV/TSV netlist parsing."""
def test_parse_csv_netlist(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
csv_content = textwrap.dedent("""\
Reference,Pin,Net
R1,1,VCC
R1,2,GND
C1,1,VCC
C1,2,GND
""")
path = os.path.join(tmp_output_dir, "test.csv")
with open(path, "w") as f:
f.write(csv_content)
result = import_netlist(source_path=path)
assert result["success"] is True
assert result["format_detected"] == "csv"
assert result["statistics"]["net_count"] == 2
assert result["statistics"]["component_count"] == 2
assert result["statistics"]["connection_count"] == 4
assert ["R1", "1"] in result["nets"]["VCC"]
assert ["C1", "2"] in result["nets"]["GND"]
def test_parse_tsv_netlist(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
tsv_content = "Reference\tPin\tNet\nR1\t1\tVCC\nR1\t2\tGND\n"
path = os.path.join(tmp_output_dir, "test.tsv")
with open(path, "w") as f:
f.write(tsv_content)
result = import_netlist(source_path=path, format="csv")
assert result["success"] is True
assert result["statistics"]["connection_count"] == 2
def test_csv_missing_columns(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
csv_content = "Name,Value\nR1,10k\n"
path = os.path.join(tmp_output_dir, "bad.csv")
with open(path, "w") as f:
f.write(csv_content)
result = import_netlist(source_path=path, format="csv")
assert result["success"] is False
assert "Reference" in result["error"]
def test_csv_alternative_column_names(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
csv_content = "Designator,Pin Number,Net Name\nU1,3,SDA\nU1,4,SCL\n"
path = os.path.join(tmp_output_dir, "alt.csv")
with open(path, "w") as f:
f.write(csv_content)
result = import_netlist(source_path=path, format="csv")
assert result["success"] is True
assert "SDA" in result["nets"]
assert "SCL" in result["nets"]
@pytest.mark.unit
class TestImportNetlistErrors:
"""Tests for error handling in import_netlist."""
def test_nonexistent_file(self):
from mckicad.tools.netlist import import_netlist
result = import_netlist(source_path="/tmp/nonexistent_12345.net")
assert result["success"] is False
assert "not found" in result["error"]
def test_empty_file(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
path = os.path.join(tmp_output_dir, "empty.net")
with open(path, "w") as f:
f.write("")
result = import_netlist(source_path=path)
assert result["success"] is False
assert "empty" in result["error"]
def test_unsupported_format(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
path = os.path.join(tmp_output_dir, "test.net")
with open(path, "w") as f:
f.write("some content")
result = import_netlist(source_path=path, format="spice")
assert result["success"] is False
assert "Unsupported" in result["error"]
def test_unknown_format_auto(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
path = os.path.join(tmp_output_dir, "mystery.dat")
with open(path, "w") as f:
f.write("random binary garbage 12345")
result = import_netlist(source_path=path)
assert result["success"] is False
assert "auto-detect" in result["error"]
@pytest.mark.unit
class TestOutputFile:
"""Tests for output file writing."""
def test_writes_output_json(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
xml = textwrap.dedent("""\
<export version="D">
<components><comp ref="R1"><value>1k</value></comp></components>
<nets>
<net code="1" name="GND"><node ref="R1" pin="2"/></net>
</nets>
</export>
""")
src = os.path.join(tmp_output_dir, "test.net")
with open(src, "w") as f:
f.write(xml)
out = os.path.join(tmp_output_dir, "output.json")
result = import_netlist(source_path=src, output_path=out)
assert result["success"] is True
assert result["output_path"] == out
assert os.path.isfile(out)
with open(out) as f:
data = json.load(f)
assert "nets" in data
assert "GND" in data["nets"]
def test_default_output_path(self, tmp_output_dir):
from mckicad.tools.netlist import import_netlist
xml = '<export version="D"><components/><nets/></export>'
src = os.path.join(tmp_output_dir, "test.net")
with open(src, "w") as f:
f.write(xml)
result = import_netlist(source_path=src)
assert result["success"] is True
assert "output_path" in result
assert os.path.isfile(result["output_path"])