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.
433 lines
16 KiB
Python
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"])
|