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