Netlists exceeding INLINE_RESULT_THRESHOLD (20 nets) now return a compact summary with the top nets by connection count as a preview. Full data is always written to the sidecar JSON file. Small netlists still return everything inline.
513 lines
19 KiB
Python
513 lines
19 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"])
|
|
|
|
def test_large_netlist_returns_preview(self, tmp_output_dir):
|
|
"""Netlists exceeding INLINE_RESULT_THRESHOLD return a preview, not full data."""
|
|
from mckicad.config import INLINE_RESULT_THRESHOLD
|
|
from mckicad.tools.netlist import import_netlist
|
|
|
|
# Generate a netlist with more nets than the threshold
|
|
net_count = INLINE_RESULT_THRESHOLD + 5
|
|
net_lines = []
|
|
comp_lines = []
|
|
refs_seen: set[str] = set()
|
|
for i in range(1, net_count + 1):
|
|
ref_a = f"R{i}"
|
|
ref_b = f"C{i}"
|
|
comp_lines.append(f'<comp ref="{ref_a}"><value>{i}k</value></comp>')
|
|
comp_lines.append(f'<comp ref="{ref_b}"><value>{i}00nF</value></comp>')
|
|
refs_seen.update([ref_a, ref_b])
|
|
net_lines.append(
|
|
f'<net code="{i}" name="NET{i}">'
|
|
f'<node ref="{ref_a}" pin="1"/>'
|
|
f'<node ref="{ref_b}" pin="2"/>'
|
|
f'</net>'
|
|
)
|
|
|
|
xml = (
|
|
'<export version="D"><components>'
|
|
+ "".join(comp_lines)
|
|
+ "</components><nets>"
|
|
+ "".join(net_lines)
|
|
+ "</nets></export>"
|
|
)
|
|
src = os.path.join(tmp_output_dir, "large.xml")
|
|
with open(src, "w") as f:
|
|
f.write(xml)
|
|
|
|
result = import_netlist(source_path=src)
|
|
assert result["success"] is True
|
|
assert result["statistics"]["net_count"] == net_count
|
|
|
|
# Should NOT have full nets inline
|
|
assert "nets" not in result
|
|
# Should have preview
|
|
assert "nets_preview" in result
|
|
assert len(result["nets_preview"]) == INLINE_RESULT_THRESHOLD
|
|
assert "nets_preview_note" in result
|
|
|
|
# Should have component refs list instead of full component dict
|
|
assert "component_refs" in result
|
|
assert "components" not in result
|
|
|
|
# Full data should be in the output file
|
|
assert os.path.isfile(result["output_path"])
|
|
with open(result["output_path"]) as f:
|
|
full_data = json.load(f)
|
|
assert len(full_data["nets"]) == net_count
|
|
|
|
def test_small_netlist_returns_inline(self, tmp_output_dir):
|
|
"""Netlists within INLINE_RESULT_THRESHOLD return full data inline."""
|
|
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, "small.xml")
|
|
with open(src, "w") as f:
|
|
f.write(xml)
|
|
|
|
result = import_netlist(source_path=src)
|
|
assert result["success"] is True
|
|
# Should have full data inline
|
|
assert "nets" in result
|
|
assert "components" in result
|
|
# Should NOT have preview keys
|
|
assert "nets_preview" not in result
|
|
assert "component_refs" not in result
|