"""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("""\
10k
Resistor_SMD:R_0402
4.7k
Resistor_SMD:R_0402
100nF
Capacitor_SMD:C_0402
""")
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 = ''
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("")
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("""\
IC
""")
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("""\
1k
""")
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 = ''
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'{i}k')
comp_lines.append(f'{i}00nF')
refs_seen.update([ref_a, ref_b])
net_lines.append(
f''
f''
f''
f''
)
xml = (
''
+ "".join(comp_lines)
+ ""
+ "".join(net_lines)
+ ""
)
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("""\
1k
""")
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