kicad-mcp/tests/test_netlist.py
Ryan Malloy 1b0a77f956 Add import_netlist tool for KiCad XML and CSV netlist ingestion
Parses external netlist files into the component-pin-net graph that
verify_connectivity and apply_batch consume. Supports KiCad XML (.net)
exported by kicad-cli, and CSV/TSV with flexible column name matching.

Auto-detects format from file extension and content. Output is directly
compatible with verify_connectivity's expected parameter, closing the
loop between "I have a design" and "I can build it in KiCad."

Requested by ESP32-P4 project (agent thread message 028).
2026-03-05 13:41:19 -07:00

287 lines
9.6 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 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"])