"""Tests for the S-expression parser utilities. These tests do NOT require kicad-sch-api — they test raw file parsing. """ import os import tempfile import pytest from mckicad.utils.sexp_parser import ( generate_global_label_sexp, generate_label_sexp, insert_sexp_before_close, parse_global_labels, parse_lib_file_symbol_pins, parse_lib_symbol_pins, transform_pin_to_schematic, ) # Minimal .kicad_sch content with global labels and lib_symbols SAMPLE_SCHEMATIC = """\ (kicad_sch (version 20231120) (generator "eeschema") (uuid "abc123") (paper "A4") (lib_symbols (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0) ) (exclude_from_sim no) (in_bom yes) (on_board yes) (property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27) ) ) ) (symbol "Device:R_0_1" (polyline (pts (xy -1.016 -2.54) (xy -1.016 2.54) ) ) ) (symbol "Device:R_1_1" (pin passive line (at 0 3.81 270) (length 2.54) (name "~" (effects (font (size 1.27 1.27) ) ) ) (number "1" (effects (font (size 1.27 1.27) ) ) ) ) (pin passive line (at 0 -3.81 90) (length 2.54) (name "~" (effects (font (size 1.27 1.27) ) ) ) (number "2" (effects (font (size 1.27 1.27) ) ) ) ) ) ) (symbol "Espressif:ESP32-P4" (pin_names (offset 1.016) ) (exclude_from_sim no) (in_bom yes) (on_board yes) (symbol "Espressif:ESP32-P4_0_1" (pin input line (at -25.4 22.86 0) (length 2.54) (name "GPIO0" (effects (font (size 1.27 1.27) ) ) ) (number "1" (effects (font (size 1.27 1.27) ) ) ) ) (pin power_in line (at 0 30.48 270) (length 2.54) (name "VDD" (effects (font (size 1.27 1.27) ) ) ) (number "2" (effects (font (size 1.27 1.27) ) ) ) ) (pin output line (at 25.4 22.86 180) (length 2.54) (name "TX" (effects (font (size 1.27 1.27) ) ) ) (number "3" (effects (font (size 1.27 1.27) ) ) ) ) ) ) ) (global_label "ESP_3V3" (shape input) (at 127 95.25 180) (uuid "def456") (effects (font (size 1.27 1.27) ) (justify right) ) ) (global_label "GND" (shape input) (at 200.5 150.75 0) (uuid "ghi789") ) (global_label "SPI_CLK" (shape output) (at 300 200 90) (uuid "jkl012") ) (label "LOCAL_NET" (at 100 100 0) (uuid "mno345") ) ) """ @pytest.fixture def sample_schematic_file(): """Write the sample schematic to a temp file and return its path.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".kicad_sch", delete=False, encoding="utf-8" ) as f: f.write(SAMPLE_SCHEMATIC) path = f.name yield path os.unlink(path) class TestParseGlobalLabels: def test_finds_all_global_labels(self, sample_schematic_file): labels = parse_global_labels(sample_schematic_file) assert len(labels) == 3 def test_extracts_text_and_position(self, sample_schematic_file): labels = parse_global_labels(sample_schematic_file) texts = {lbl["text"] for lbl in labels} assert texts == {"ESP_3V3", "GND", "SPI_CLK"} esp = next(lbl for lbl in labels if lbl["text"] == "ESP_3V3") assert esp["x"] == pytest.approx(127.0) assert esp["y"] == pytest.approx(95.25) gnd = next(lbl for lbl in labels if lbl["text"] == "GND") assert gnd["x"] == pytest.approx(200.5) assert gnd["y"] == pytest.approx(150.75) def test_does_not_include_local_labels(self, sample_schematic_file): labels = parse_global_labels(sample_schematic_file) texts = {lbl["text"] for lbl in labels} assert "LOCAL_NET" not in texts def test_nonexistent_file_returns_empty(self): labels = parse_global_labels("/nonexistent/path.kicad_sch") assert labels == [] class TestParseLibSymbolPins: def test_finds_resistor_pins(self, sample_schematic_file): pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R") assert len(pins) == 2 nums = {p["number"] for p in pins} assert nums == {"1", "2"} pin1 = next(p for p in pins if p["number"] == "1") assert pin1["name"] == "~" assert pin1["type"] == "passive" assert pin1["x"] == pytest.approx(0.0) assert pin1["y"] == pytest.approx(3.81) def test_finds_custom_ic_pins(self, sample_schematic_file): pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4") assert len(pins) == 3 names = {p["name"] for p in pins} assert names == {"GPIO0", "VDD", "TX"} gpio = next(p for p in pins if p["name"] == "GPIO0") assert gpio["number"] == "1" assert gpio["type"] == "input" assert gpio["x"] == pytest.approx(-25.4) assert gpio["y"] == pytest.approx(22.86) assert gpio["rotation"] == pytest.approx(0.0) vdd = next(p for p in pins if p["name"] == "VDD") assert vdd["type"] == "power_in" def test_does_not_match_subunit_prefix(self, sample_schematic_file): # "Espressif:ESP32-P4_0_1" is a sub-unit, not the top-level symbol pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4_0") assert len(pins) == 0 def test_nonexistent_lib_id_returns_empty(self, sample_schematic_file): pins = parse_lib_symbol_pins(sample_schematic_file, "NoSuchLib:Missing") assert pins == [] def test_nonexistent_file_returns_empty(self): pins = parse_lib_symbol_pins("/nonexistent/path.kicad_sch", "Device:R") assert pins == [] class TestTransformPinToSchematic: def test_zero_rotation(self): sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 0) assert sx == pytest.approx(100.0) assert sy == pytest.approx(103.81) def test_90_degree_rotation(self): sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 90) assert sx == pytest.approx(100 - 3.81, abs=0.01) assert sy == pytest.approx(100.0, abs=0.01) def test_180_degree_rotation(self): sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 180) assert sx == pytest.approx(100.0, abs=0.01) assert sy == pytest.approx(100 - 3.81, abs=0.01) def test_270_degree_rotation(self): sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 270) assert sx == pytest.approx(100 + 3.81, abs=0.01) assert sy == pytest.approx(100.0, abs=0.01) def test_mirror_x(self): sx, sy = transform_pin_to_schematic(5, 0, 100, 100, 0, mirror_x=True) assert sx == pytest.approx(95.0) assert sy == pytest.approx(100.0) class TestGenerateLabelSexp: def test_basic_local_label(self): sexp = generate_label_sexp("NET_A", 100.5, 200.25) assert '(label "NET_A"' in sexp assert "(at 100.5 200.25 0)" in sexp assert "(uuid" in sexp assert "(effects" in sexp def test_custom_uuid(self): sexp = generate_label_sexp("X", 0, 0, uuid_str="test-uuid-123") assert '(uuid "test-uuid-123")' in sexp def test_rotation(self): sexp = generate_label_sexp("CLK", 50, 50, rotation=90) assert "(at 50 50 90)" in sexp def test_auto_uuid_is_unique(self): sexp1 = generate_label_sexp("A", 0, 0) sexp2 = generate_label_sexp("A", 0, 0) # Extract UUIDs import re uuids = re.findall(r'\(uuid "([^"]+)"\)', sexp1 + sexp2) assert len(uuids) == 2 assert uuids[0] != uuids[1] def test_quote_escaping(self): sexp = generate_label_sexp('NET"SPECIAL', 0, 0) assert r'(label "NET\"SPECIAL"' in sexp def test_backslash_escaping(self): sexp = generate_label_sexp("NET\\PATH", 0, 0) assert r'(label "NET\\PATH"' in sexp class TestGenerateGlobalLabelSexp: def test_basic_global_label(self): sexp = generate_global_label_sexp("VBUS_OUT", 187.96, 114.3) assert '(global_label "VBUS_OUT"' in sexp assert "(shape bidirectional)" in sexp assert "(at 187.96 114.3 0)" in sexp assert "Intersheetrefs" in sexp assert "${INTERSHEET_REFS}" in sexp def test_custom_shape(self): sexp = generate_global_label_sexp("CLK", 0, 0, shape="output") assert "(shape output)" in sexp def test_rotation(self): sexp = generate_global_label_sexp("SIG", 10, 20, rotation=180) assert "(at 10 20 180)" in sexp def test_round_trip_parse(self, tmp_path): """Generated global label should be parseable by parse_global_labels.""" sexp = generate_global_label_sexp("TEST_NET", 123.45, 67.89) # Wrap in a minimal schematic content = f"(kicad_sch\n (version 20231120)\n{sexp})\n" filepath = str(tmp_path / "test.kicad_sch") with open(filepath, "w") as f: f.write(content) labels = parse_global_labels(filepath) assert len(labels) == 1 assert labels[0]["text"] == "TEST_NET" assert labels[0]["x"] == pytest.approx(123.45) assert labels[0]["y"] == pytest.approx(67.89) class TestInsertSexpBeforeClose: def test_insert_into_minimal_schematic(self, tmp_path): filepath = str(tmp_path / "test.kicad_sch") with open(filepath, "w") as f: f.write("(kicad_sch\n (version 20231120)\n)\n") insert_sexp_before_close(filepath, ' (label "X"\n (at 0 0 0)\n )\n') with open(filepath) as f: content = f.read() assert '(label "X"' in content assert content.strip().endswith(")") assert content.startswith("(kicad_sch") def test_preserves_existing_content(self, tmp_path): filepath = str(tmp_path / "test.kicad_sch") original = '(kicad_sch\n (version 20231120)\n (uuid "abc")\n)\n' with open(filepath, "w") as f: f.write(original) insert_sexp_before_close(filepath, ' (label "Y"\n (at 1 2 0)\n )\n') with open(filepath) as f: content = f.read() assert '(uuid "abc")' in content assert '(label "Y"' in content def test_rejects_non_kicad_file(self, tmp_path): filepath = str(tmp_path / "bad.kicad_sch") with open(filepath, "w") as f: f.write("not a kicad file") with pytest.raises(ValueError, match="Not a KiCad schematic"): insert_sexp_before_close(filepath, "(label)") def test_multiple_insertions(self, tmp_path): filepath = str(tmp_path / "multi.kicad_sch") with open(filepath, "w") as f: f.write("(kicad_sch\n (version 20231120)\n)\n") insert_sexp_before_close(filepath, ' (label "A"\n (at 0 0 0)\n )\n') insert_sexp_before_close(filepath, ' (label "B"\n (at 10 10 0)\n )\n') with open(filepath) as f: content = f.read() assert '(label "A"' in content assert '(label "B"' in content def test_file_not_found(self, tmp_path): with pytest.raises(FileNotFoundError): insert_sexp_before_close(str(tmp_path / "missing.kicad_sch"), "(label)") class TestResolvePinPosition: """Tests for resolve_pin_position (requires mocking sch object).""" def test_returns_api_result_when_available(self): """When the API returns a position, use it directly.""" from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position sch = MagicMock() pos = MagicMock() pos.x = 100.0 pos.y = 200.0 sch.get_component_pin_position.return_value = pos result = resolve_pin_position(sch, "/fake/path.kicad_sch", "R1", "1") assert result == (100.0, 200.0) def test_returns_none_when_api_returns_none_and_no_component(self): from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position sch = MagicMock() sch.get_component_pin_position.return_value = None sch.components.get.return_value = None result = resolve_pin_position(sch, "/fake/path.kicad_sch", "U99", "1") assert result is None def test_falls_back_to_sexp(self, sample_schematic_file): """When API returns None, use sexp parsing for pin resolution.""" from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position sch = MagicMock() sch.get_component_pin_position.return_value = None # Mock a component at position (100, 100) with lib_id "Device:R" comp = MagicMock() comp.lib_id = "Device:R" comp.position = MagicMock() comp.position.x = 100.0 comp.position.y = 100.0 comp.rotation = 0 comp.mirror = None sch.components.get.return_value = comp result = resolve_pin_position(sch, sample_schematic_file, "R1", "1") assert result is not None # Pin 1 of Device:R is at (0, 3.81) in local coords # At component position (100, 100) with 0 rotation: (100, 103.81) assert result[0] == pytest.approx(100.0) assert result[1] == pytest.approx(103.81) # --------------------------------------------------------------------------- # External library file tests # --------------------------------------------------------------------------- # Minimal .kicad_sym library file with a custom TVS diode symbol SAMPLE_LIBRARY_FILE = """\ (kicad_symbol_lib (version 20231120) (generator "kicad_symbol_editor") (symbol "SMF5.0CA" (pin_names (offset 1.016) ) (exclude_from_sim no) (in_bom yes) (on_board yes) (symbol "SMF5.0CA_0_1" (pin passive line (at -10.16 2.54 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27) ) ) ) (number "1" (effects (font (size 1.27 1.27) ) ) ) ) (pin passive line (at 10.16 2.54 180) (length 2.54) (name "K" (effects (font (size 1.27 1.27) ) ) ) (number "2" (effects (font (size 1.27 1.27) ) ) ) ) ) ) ) """ # Schematic that references the library symbol but doesn't embed it SAMPLE_SCHEMATIC_NO_EMBED = """\ (kicad_sch (version 20231120) (generator "eeschema") (uuid "abc123") (paper "A4") (lib_symbols ) (symbol (lib_id "MyProject:SMF5.0CA") (at 100 100 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (uuid "d1-uuid") (property "Reference" "D1" (at 100 90 0) ) ) ) """ @pytest.fixture def external_lib_project(tmp_path): """Create a project structure with an external library file. tmp_path/ test_project.kicad_pro libs/ MyProject.kicad_sym sheets/ power.kicad_sch """ # Project file pro_file = tmp_path / "test_project.kicad_pro" pro_file.write_text('{"meta": {"filename": "test_project.kicad_pro"}}') # Library directory libs_dir = tmp_path / "libs" libs_dir.mkdir() lib_file = libs_dir / "MyProject.kicad_sym" lib_file.write_text(SAMPLE_LIBRARY_FILE) # Schematic in a subdirectory (common for multi-sheet projects) sheets_dir = tmp_path / "sheets" sheets_dir.mkdir() sch_file = sheets_dir / "power.kicad_sch" sch_file.write_text(SAMPLE_SCHEMATIC_NO_EMBED) return { "project_root": str(tmp_path), "lib_file": str(lib_file), "schematic": str(sch_file), } class TestParseLibFileSymbolPins: """Tests for parsing pins from standalone .kicad_sym library files.""" def test_finds_pins_in_library_file(self, external_lib_project): pins = parse_lib_file_symbol_pins( external_lib_project["lib_file"], "SMF5.0CA", ) assert len(pins) == 2 names = {p["name"] for p in pins} assert names == {"A", "K"} pin_a = next(p for p in pins if p["name"] == "A") assert pin_a["number"] == "1" assert pin_a["x"] == pytest.approx(-10.16) assert pin_a["y"] == pytest.approx(2.54) pin_k = next(p for p in pins if p["name"] == "K") assert pin_k["number"] == "2" assert pin_k["x"] == pytest.approx(10.16) def test_nonexistent_symbol_returns_empty(self, external_lib_project): pins = parse_lib_file_symbol_pins( external_lib_project["lib_file"], "DOES_NOT_EXIST", ) assert pins == [] def test_nonexistent_file_returns_empty(self): pins = parse_lib_file_symbol_pins("/nonexistent/lib.kicad_sym", "X") assert pins == [] class TestExternalLibraryFallback: """Tests for parse_lib_symbol_pins falling back to external .kicad_sym files.""" def test_embedded_symbol_found_first(self, sample_schematic_file): """When symbol is embedded, don't search external files.""" pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R") assert len(pins) == 2 # Found in embedded lib_symbols def test_external_library_fallback(self, external_lib_project): """When symbol not embedded, search external .kicad_sym files.""" pins = parse_lib_symbol_pins( external_lib_project["schematic"], "MyProject:SMF5.0CA", ) assert len(pins) == 2 names = {p["name"] for p in pins} assert names == {"A", "K"} def test_external_lib_not_found_returns_empty(self, tmp_path): """When no external library exists, return empty list.""" sch_file = tmp_path / "orphan.kicad_sch" sch_file.write_text( "(kicad_sch\n (version 20231120)\n (lib_symbols\n )\n)\n" ) pins = parse_lib_symbol_pins(str(sch_file), "NoSuchLib:Missing") assert pins == [] def test_resolve_pin_via_external_lib(self, external_lib_project): """resolve_pin_position should find pins via external library.""" from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position sch = MagicMock() sch.get_component_pin_position.return_value = None comp = MagicMock() comp.lib_id = "MyProject:SMF5.0CA" comp.position = MagicMock() comp.position.x = 100.0 comp.position.y = 100.0 comp.rotation = 0 comp.mirror = None sch.components.get.return_value = comp result = resolve_pin_position( sch, external_lib_project["schematic"], "D1", "1", ) assert result is not None # Pin 1 (A) at (-10.16, 2.54) local, component at (100, 100), 0 rotation assert result[0] == pytest.approx(100 - 10.16, abs=0.01) assert result[1] == pytest.approx(100 + 2.54, abs=0.01) class TestSymLibTableParsing: """Tests for sym-lib-table resolution.""" def test_sym_lib_table_resolution(self, tmp_path): """Library paths from sym-lib-table should be found.""" from mckicad.utils.sexp_parser import _find_library_file # Create project structure pro_file = tmp_path / "project.kicad_pro" pro_file.write_text('{}') custom_libs = tmp_path / "custom" / "symbols" custom_libs.mkdir(parents=True) lib_file = custom_libs / "CustomLib.kicad_sym" lib_file.write_text("(kicad_symbol_lib)") sym_lib_table = tmp_path / "sym-lib-table" sym_lib_table.write_text( '(sym_lib_table\n' ' (version 7)\n' ' (lib (name "CustomLib")(type "KiCad")' '(uri "${KIPRJMOD}/custom/symbols/CustomLib.kicad_sym")' '(options "")(descr ""))\n' ')\n' ) sch_path = str(tmp_path / "test.kicad_sch") result = _find_library_file(sch_path, "CustomLib") assert result is not None assert result == os.path.normpath(str(lib_file)) def test_libs_dir_search(self, external_lib_project): """Libraries in ../libs/ relative to schematic should be found.""" from mckicad.utils.sexp_parser import _find_library_file result = _find_library_file( external_lib_project["schematic"], "MyProject", ) assert result is not None assert result.endswith("MyProject.kicad_sym")