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