"""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