"""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 ( clamp_stub_length, compute_label_placement, parse_global_labels, parse_lib_file_symbol_pins, parse_lib_symbol_pin_units, parse_lib_symbol_pins, parse_wire_segments, remove_sexp_blocks_by_uuid, resolve_label_collision, 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(96.19) 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 TestResolveLabelCollision: def test_no_collision_empty_occupied(self): occupied: dict[tuple[float, float], str] = {} x, y = resolve_label_collision(100.0, 50.0, 0, "NET_A", occupied) assert (x, y) == (100.0, 50.0) def test_no_collision_different_position(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(200.0, 50.0, 0, "NET_B", occupied) assert (x, y) == (200.0, 50.0) def test_no_collision_same_net(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 0, "NET_A", occupied) assert (x, y) == (100.0, 50.0) def test_collision_different_net_angle_270(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 270, "NET_B", occupied) assert x == pytest.approx(100.0) assert y == pytest.approx(50.0 - 1.27) def test_collision_different_net_angle_90(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 90, "NET_B", occupied) assert x == pytest.approx(100.0) assert y == pytest.approx(50.0 + 1.27) def test_collision_different_net_angle_180(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 180, "NET_B", occupied) assert x == pytest.approx(100.0 + 1.27) assert y == pytest.approx(50.0) def test_collision_different_net_angle_0(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 0, "NET_B", occupied) assert x == pytest.approx(100.0 - 1.27) assert y == pytest.approx(50.0) def test_occupied_dict_updated_after_resolution(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 270, "NET_B", occupied) assert (round(x, 2), round(y, 2)) in occupied assert occupied[(round(x, 2), round(y, 2))] == "NET_B" def test_custom_offset(self): occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"} x, y = resolve_label_collision(100.0, 50.0, 0, "NET_B", occupied, offset=2.54) assert x == pytest.approx(100.0 - 2.54) assert y == pytest.approx(50.0) 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 (Y-up) # At component position (100, 100) with 0 rotation: (100, 96.19) in Y-down assert result[0] == pytest.approx(100.0) assert result[1] == pytest.approx(96.19) # --------------------------------------------------------------------------- # 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 (Y-up), 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") # --------------------------------------------------------------------------- # Wire segment parsing and removal tests # --------------------------------------------------------------------------- SAMPLE_SCHEMATIC_WITH_WIRES = """\ (kicad_sch (version 20231120) (generator "eeschema") (uuid "root-uuid") (paper "A4") (lib_symbols ) (wire (pts (xy 148.59 194.31) (xy 156.21 194.31)) (stroke (width 0) (type default)) (uuid "wire-1") ) (wire (pts (xy 156.21 194.31) (xy 163.83 194.31)) (stroke (width 0) (type default)) (uuid "wire-2") ) (wire (pts (xy 100.0 100.0) (xy 100.0 120.0)) (stroke (width 0) (type default)) (uuid "wire-3") ) (wire (pts (xy 200.5 50.25) (xy 210.75 50.25)) (stroke (width 0) (type default)) (uuid "wire-4") ) ) """ @pytest.fixture def schematic_with_wires(tmp_path): """Write a schematic with wire segments to a temp file.""" filepath = tmp_path / "wired.kicad_sch" filepath.write_text(SAMPLE_SCHEMATIC_WITH_WIRES) return str(filepath) class TestParseWireSegments: def test_finds_all_wires(self, schematic_with_wires): wires = parse_wire_segments(schematic_with_wires) assert len(wires) == 4 def test_extracts_coordinates(self, schematic_with_wires): wires = parse_wire_segments(schematic_with_wires) w1 = next(w for w in wires if w["uuid"] == "wire-1") assert w1["start"]["x"] == pytest.approx(148.59) assert w1["start"]["y"] == pytest.approx(194.31) assert w1["end"]["x"] == pytest.approx(156.21) assert w1["end"]["y"] == pytest.approx(194.31) def test_extracts_uuids(self, schematic_with_wires): wires = parse_wire_segments(schematic_with_wires) uuids = {w["uuid"] for w in wires} assert uuids == {"wire-1", "wire-2", "wire-3", "wire-4"} def test_vertical_wire(self, schematic_with_wires): wires = parse_wire_segments(schematic_with_wires) w3 = next(w for w in wires if w["uuid"] == "wire-3") assert w3["start"]["x"] == pytest.approx(100.0) assert w3["start"]["y"] == pytest.approx(100.0) assert w3["end"]["x"] == pytest.approx(100.0) assert w3["end"]["y"] == pytest.approx(120.0) def test_nonexistent_file_returns_empty(self): wires = parse_wire_segments("/nonexistent/path.kicad_sch") assert wires == [] def test_schematic_without_wires(self, tmp_path): filepath = tmp_path / "empty.kicad_sch" filepath.write_text("(kicad_sch\n (version 20231120)\n)\n") wires = parse_wire_segments(str(filepath)) assert wires == [] class TestRemoveSexpBlocksByUuid: def test_remove_single_wire(self, schematic_with_wires): removed = remove_sexp_blocks_by_uuid(schematic_with_wires, {"wire-1"}) assert removed == 1 # Verify wire is gone remaining = parse_wire_segments(schematic_with_wires) remaining_uuids = {w["uuid"] for w in remaining} assert "wire-1" not in remaining_uuids assert len(remaining) == 3 def test_remove_multiple_wires(self, schematic_with_wires): removed = remove_sexp_blocks_by_uuid( schematic_with_wires, {"wire-1", "wire-2"}, ) assert removed == 2 remaining = parse_wire_segments(schematic_with_wires) remaining_uuids = {w["uuid"] for w in remaining} assert remaining_uuids == {"wire-3", "wire-4"} def test_remove_all_wires(self, schematic_with_wires): removed = remove_sexp_blocks_by_uuid( schematic_with_wires, {"wire-1", "wire-2", "wire-3", "wire-4"}, ) assert removed == 4 remaining = parse_wire_segments(schematic_with_wires) assert remaining == [] # File should still be valid with open(schematic_with_wires) as f: content = f.read() assert content.strip().startswith("(kicad_sch") assert content.strip().endswith(")") def test_remove_nonexistent_uuid(self, schematic_with_wires): removed = remove_sexp_blocks_by_uuid( schematic_with_wires, {"nonexistent-uuid"}, ) assert removed == 0 remaining = parse_wire_segments(schematic_with_wires) assert len(remaining) == 4 def test_empty_uuid_set(self, schematic_with_wires): removed = remove_sexp_blocks_by_uuid(schematic_with_wires, set()) assert removed == 0 def test_preserves_other_content(self, schematic_with_wires): remove_sexp_blocks_by_uuid(schematic_with_wires, {"wire-1"}) with open(schematic_with_wires) as f: content = f.read() assert "(version 20231120)" in content assert '(uuid "root-uuid")' in content assert "(lib_symbols" in content # --------------------------------------------------------------------------- # compute_label_placement tests # --------------------------------------------------------------------------- class TestComputeLabelPlacement: """Test label offset + rotation for all 4 pin body directions.""" def test_pin_body_right_label_goes_left(self): """Pin body direction 0° (right) -> label placed to the left.""" result = compute_label_placement(100.0, 50.0, 0, stub_length=2.54) assert result["label_x"] == pytest.approx(100 - 2.54) assert result["label_y"] == pytest.approx(50.0) assert result["label_rotation"] == 180 assert result["stub_start_x"] == 100.0 assert result["stub_start_y"] == 50.0 assert result["stub_end_x"] == result["label_x"] assert result["stub_end_y"] == result["label_y"] def test_pin_body_down_label_goes_up(self): """Pin body direction 90° (down) -> label placed upward.""" result = compute_label_placement(100.0, 50.0, 90, stub_length=2.54) assert result["label_x"] == pytest.approx(100.0) assert result["label_y"] == pytest.approx(50 - 2.54) assert result["label_rotation"] == 90 def test_pin_body_left_label_goes_right(self): """Pin body direction 180° (left) -> label placed to the right.""" result = compute_label_placement(100.0, 50.0, 180, stub_length=2.54) assert result["label_x"] == pytest.approx(100 + 2.54) assert result["label_y"] == pytest.approx(50.0) assert result["label_rotation"] == 0 def test_pin_body_up_label_goes_down(self): """Pin body direction 270° (up) -> label placed downward.""" result = compute_label_placement(100.0, 50.0, 270, stub_length=2.54) assert result["label_x"] == pytest.approx(100.0) assert result["label_y"] == pytest.approx(50 + 2.54) assert result["label_rotation"] == 270 def test_custom_stub_length(self): """Non-default stub length should scale the offset.""" result = compute_label_placement(0, 0, 180, stub_length=5.08) assert result["label_x"] == pytest.approx(5.08) assert result["label_y"] == pytest.approx(0) def test_stub_endpoints_match_pin_and_label(self): """Wire stub starts at pin tip and ends at label position.""" result = compute_label_placement(200, 100, 0, stub_length=2.54) assert result["stub_start_x"] == 200 assert result["stub_start_y"] == 100 assert result["stub_end_x"] == result["label_x"] assert result["stub_end_y"] == result["label_y"] # --------------------------------------------------------------------------- # clamp_stub_length tests # --------------------------------------------------------------------------- class TestClampStubLength: """Test collision-aware stub length clamping.""" def test_no_obstacles_returns_proposed(self): """No obstacles -> full proposed length.""" result = clamp_stub_length(100, 100, 180, 7.62, []) assert result == 7.62 def test_obstacle_in_path_shortens_stub_right(self): """Obstacle 5mm to the right of pin with rot=180 (stub goes right).""" result = clamp_stub_length(100, 100, 180, 7.62, [(105, 100)]) # Should shorten: 5.0 - 1.27 = 3.73 assert result == pytest.approx(3.73) def test_obstacle_in_path_shortens_stub_left(self): """Obstacle 5mm to the left of pin with rot=0 (stub goes left).""" result = clamp_stub_length(100, 100, 0, 7.62, [(95, 100)]) # Should shorten: 5.0 - 1.27 = 3.73 assert result == pytest.approx(3.73) def test_obstacle_in_path_shortens_stub_up(self): """Obstacle 5mm above pin with rot=90 (stub goes up).""" result = clamp_stub_length(100, 100, 90, 7.62, [(100, 95)]) assert result == pytest.approx(3.73) def test_obstacle_in_path_shortens_stub_down(self): """Obstacle 5mm below pin with rot=270 (stub goes down).""" result = clamp_stub_length(100, 100, 270, 7.62, [(100, 105)]) assert result == pytest.approx(3.73) def test_obstacle_off_axis_ignored(self): """Obstacle far off the stub axis -> no clamping.""" # Stub goes right (rot=180), obstacle is 10mm above result = clamp_stub_length(100, 100, 180, 7.62, [(105, 110)]) assert result == 7.62 def test_obstacle_behind_pin_ignored(self): """Obstacle behind the pin (opposite direction) -> no clamping.""" # Stub goes right (rot=180), obstacle is to the left result = clamp_stub_length(100, 100, 180, 7.62, [(95, 100)]) assert result == 7.62 def test_minimum_floor(self): """Very close obstacle -> clamped to minimum, not zero.""" # Obstacle 1mm away, clearance 1.27 -> would give negative, clamped to 2.54 result = clamp_stub_length(100, 100, 180, 7.62, [(101, 100)]) assert result == 2.54 def test_small_passive_scenario(self): """Simulates a 5.08mm resistor with stubs on both pins.""" # Pin 1 at (100, 100), pin 2 at (105.08, 100) # Pin 1 stub goes left (rot=0), pin 2 is to the right -> no collision r1 = clamp_stub_length(100, 100, 0, 7.62, [(105.08, 100)]) assert r1 == 7.62 # pin 2 is behind, no collision # Pin 2 stub goes right (rot=180), pin 1 is to the left -> no collision r2 = clamp_stub_length(105.08, 100, 180, 7.62, [(100, 100)]) assert r2 == 7.62 # pin 1 is behind, no collision def test_opposing_stubs_on_vertical_component(self): """Two pins on a vertical component with stubs pointing toward each other.""" # Pin 1 at (100, 100) stub goes down (rot=270) # Pin 2 at (100, 115) is in the downward path r1 = clamp_stub_length(100, 100, 270, 7.62, [(100, 115)]) # dist=15, but that's beyond 7.62, so no clamping assert r1 == 7.62 # Now with closer spacing (5mm apart) # Pin 1 at (100, 100) stub goes down, pin 2 at (100, 105) r2 = clamp_stub_length(100, 100, 270, 7.62, [(100, 105)]) assert r2 == pytest.approx(3.73) def test_custom_clearance(self): """Custom clearance value is respected.""" result = clamp_stub_length( 100, 100, 180, 7.62, [(105, 100)], clearance=2.54, ) # 5.0 - 2.54 = 2.46, below minimum -> clamped to 2.54 assert result == 2.54 def test_custom_minimum(self): """Custom minimum value is respected.""" result = clamp_stub_length( 100, 100, 180, 7.62, [(101, 100)], minimum=1.27, ) assert result == 1.27 def test_multiple_obstacles_uses_nearest(self): """Multiple obstacles -> shortest safe length wins.""" result = clamp_stub_length( 100, 100, 180, 7.62, [(103, 100), (106, 100), (110, 100)], ) # Nearest is 3mm away: 3.0 - 1.27 = 1.73, below minimum -> 2.54 assert result == 2.54 def test_obstacle_behind_stub_not_clamped(self): """Obstacle behind the stub direction doesn't clamp (dist <= 0).""" # Pin at (302.26, 220.54), stub goes down (rot=270). # Obstacle at (302.26, 215.46) is ABOVE (behind) — dist is negative. result = clamp_stub_length( 302.26, 220.54, 270, 7.62, [(302.26, 215.46)], ) assert result == 7.62 # Behind stub — no clamping def test_obstacle_in_front_clamps(self): """Obstacle in the stub's path clamps regardless.""" # Pin at (100, 100), stub goes right (rot=180). # Obstacle at (105, 100) is 5mm in front. result = clamp_stub_length(100, 100, 180, 7.62, [(105, 100)]) assert result == pytest.approx(3.73) # 5.0 - 1.27 # --------------------------------------------------------------------------- # resolve_pin_position_and_orientation tests # --------------------------------------------------------------------------- class TestResolvePinPositionAndOrientation: def test_returns_position_and_rotation(self, sample_schematic_file): """Sexp fallback returns x, y, and schematic_rotation.""" from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position_and_orientation sch = MagicMock() # API path should fail so we exercise the sexp fallback sch.get_component_pin_position.return_value = None comp = MagicMock() comp.lib_id = "Espressif:ESP32-P4" comp.position = MagicMock() comp.position.x = 200.0 comp.position.y = 150.0 comp.rotation = 0 comp.mirror = None sch.components.get.return_value = comp result = resolve_pin_position_and_orientation( sch, sample_schematic_file, "U1", "1", ) assert result is not None # Pin 1 (GPIO0) at local (-25.4, 22.86) rotation 0° (Y-up) # Component at (200, 150) with 0° rotation, Y negated for schematic Y-down assert result["x"] == pytest.approx(200 - 25.4, abs=0.01) assert result["y"] == pytest.approx(150 - 22.86, abs=0.01) assert result["schematic_rotation"] == pytest.approx(0) def test_returns_none_for_missing_component(self, sample_schematic_file): from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position_and_orientation sch = MagicMock() # API path should fail sch.get_component_pin_position.return_value = None sch.components.get.return_value = None result = resolve_pin_position_and_orientation( sch, sample_schematic_file, "U99", "1", ) assert result is None def test_mirrored_component_flips_rotation(self, sample_schematic_file): """Mirror-x should reflect the pin rotation (180 - rot) % 360.""" from unittest.mock import MagicMock from mckicad.utils.sexp_parser import resolve_pin_position_and_orientation sch = MagicMock() # API path should fail so we exercise the sexp fallback sch.get_component_pin_position.return_value = None comp = MagicMock() comp.lib_id = "Espressif:ESP32-P4" comp.position = MagicMock() comp.position.x = 200.0 comp.position.y = 150.0 comp.rotation = 0 comp.mirror = "x" sch.components.get.return_value = comp result = resolve_pin_position_and_orientation( sch, sample_schematic_file, "U1", "1", ) assert result is not None # Pin 1 rotation is 0° local. With mirror_x: (180-0)%360 = 180° assert result["schematic_rotation"] == pytest.approx(180) class TestCheckWireCollision: """Tests for axis-aligned wire segment collision detection.""" def test_no_collision_empty_list(self): from mckicad.utils.sexp_parser import check_wire_collision result = check_wire_collision( 0, 0, 0, 2.54, "NET_A", [], ) assert result is False def test_no_collision_different_axis(self): """Vertical and horizontal wires on different axes never collide.""" from mckicad.utils.sexp_parser import check_wire_collision placed = [((0.0, 0.0), (2.54, 0.0), "NET_A")] # horizontal result = check_wire_collision( 0, 0, 0, 2.54, "NET_B", placed, # vertical ) assert result is False def test_no_collision_same_net(self): """Same-net wires overlapping is harmless.""" from mckicad.utils.sexp_parser import check_wire_collision placed = [((0.0, 0.0), (0.0, 2.54), "NET_A")] result = check_wire_collision( 0, 0, 0, 5.08, "NET_A", placed, ) assert result is False def test_collision_vertical_overlap(self): """Two vertical wires on same X with overlapping Y ranges collide.""" from mckicad.utils.sexp_parser import check_wire_collision placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")] result = check_wire_collision( 100, 101, 100, 103.54, "NET_B", placed, ) assert result is True def test_collision_horizontal_overlap(self): """Two horizontal wires on same Y with overlapping X ranges collide.""" from mckicad.utils.sexp_parser import check_wire_collision placed = [((100.0, 100.0), (102.54, 100.0), "NET_A")] result = check_wire_collision( 101, 100, 103.54, 100, "NET_B", placed, ) assert result is True def test_no_collision_parallel_but_offset(self): """Parallel vertical wires at different X don't collide.""" from mckicad.utils.sexp_parser import check_wire_collision placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")] result = check_wire_collision( 101.27, 100, 101.27, 102.54, "NET_B", placed, ) assert result is False def test_no_collision_non_overlapping_range(self): """Collinear wires with non-overlapping ranges don't collide.""" from mckicad.utils.sexp_parser import check_wire_collision placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")] result = check_wire_collision( 100, 105, 100, 107.54, "NET_B", placed, ) assert result is False class TestResolveWireCollision: """Tests for wire collision resolution with perpendicular shifting.""" def test_no_collision_returns_original(self): from mckicad.utils.sexp_parser import resolve_wire_collision placed = [] result = resolve_wire_collision( 0, 0, 0, 2.54, 270, "NET_A", placed, ) assert result == (0, 0, 0, 2.54) assert len(placed) == 1 def test_vertical_collision_shifts_label_only(self): """Vertical stub collision shifts only the label end, not the pin start.""" from mckicad.utils.sexp_parser import resolve_wire_collision placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")] result = resolve_wire_collision( 100, 101, 100, 103.54, 270, "NET_B", placed, ) # Start stays anchored at pin, label shifts +1.27 in X assert result == (100, 101, 101.27, 103.54) assert len(placed) == 2 def test_horizontal_collision_shifts_label_only(self): """Horizontal stub collision shifts only the label end, not the pin start.""" from mckicad.utils.sexp_parser import resolve_wire_collision placed = [((100.0, 100.0), (102.54, 100.0), "NET_A")] result = resolve_wire_collision( 101, 100, 103.54, 100, 0, "NET_B", placed, ) # Start stays anchored at pin, label shifts -1.27 in Y assert result == (101, 100, 103.54, 98.73) assert len(placed) == 2 def test_custom_offset(self): """Custom offset value is used for perpendicular shift.""" from mckicad.utils.sexp_parser import resolve_wire_collision placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")] result = resolve_wire_collision( 100, 101, 100, 103.54, 270, "NET_B", placed, offset=2.54, ) # Start anchored, label shifts by custom offset assert result == (100, 101, 102.54, 103.54) def test_placed_wires_updated(self): """The placed_wires list is updated with the final segment.""" from mckicad.utils.sexp_parser import resolve_wire_collision placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")] resolve_wire_collision( 100, 101, 100, 103.54, 270, "NET_B", placed, ) assert len(placed) == 2 # Start stays at pin, end is shifted new_seg = placed[1] assert new_seg[2] == "NET_B" assert new_seg[0][0] == 100 # start X anchored at pin assert new_seg[1][0] == 101.27 # end X shifted # --------------------------------------------------------------------------- # Multi-unit pin-to-unit mapping # --------------------------------------------------------------------------- # TL072 has 3 units: unit 1 (pins 1,2,3), unit 2 (pins 5,6,7), unit 3 (pins 4,8) MULTI_UNIT_SCHEMATIC = """\ (kicad_sch (version 20231120) (generator "test") (uuid "root-uuid") (lib_symbols (symbol "Amplifier_Operational:TL072" (symbol "TL072_1_1" (pin output line (at 5.08 0 180) (length 2.54) (name "~") (number "1")) (pin input line (at -5.08 2.54 0) (length 2.54) (name "+In") (number "3")) (pin input inverted (at -5.08 -2.54 0) (length 2.54) (name "-In") (number "2")) ) (symbol "TL072_2_1" (pin output line (at 5.08 0 180) (length 2.54) (name "~") (number "7")) (pin input line (at -5.08 2.54 0) (length 2.54) (name "+In") (number "5")) (pin input inverted (at -5.08 -2.54 0) (length 2.54) (name "-In") (number "6")) ) (symbol "TL072_3_1" (pin power_in line (at -2.54 3.81 270) (length 2.54) (name "V+") (number "8")) (pin power_in line (at -2.54 -3.81 90) (length 2.54) (name "V-") (number "4")) ) ) ) ) """ class TestParseLibSymbolPinUnits: """Tests for parse_lib_symbol_pin_units (pin-to-unit mapping).""" def test_maps_pins_to_units(self): """Each pin number maps to its owning unit.""" with tempfile.NamedTemporaryFile( suffix=".kicad_sch", mode="w", delete=False, ) as f: f.write(MULTI_UNIT_SCHEMATIC) path = f.name try: pin_units = parse_lib_symbol_pin_units( path, "Amplifier_Operational:TL072", ) assert pin_units["1"] == 1 assert pin_units["2"] == 1 assert pin_units["3"] == 1 assert pin_units["5"] == 2 assert pin_units["6"] == 2 assert pin_units["7"] == 2 assert pin_units["4"] == 3 assert pin_units["8"] == 3 finally: os.unlink(path) def test_single_unit_returns_empty(self): """Single-unit symbols have no sub-symbols, so map is empty.""" with tempfile.NamedTemporaryFile( suffix=".kicad_sch", mode="w", delete=False, ) as f: f.write(SAMPLE_SCHEMATIC) path = f.name try: pin_units = parse_lib_symbol_pin_units(path, "Device:R") # Device:R has sub-symbols R_0_1 and R_1_1 which encode unit 0 and 1 # but single-unit symbols may still have sub-symbols # The key test is that single-unit resolution still works assert isinstance(pin_units, dict) finally: os.unlink(path) def test_nonexistent_symbol_returns_empty(self): """Missing symbol returns empty dict.""" with tempfile.NamedTemporaryFile( suffix=".kicad_sch", mode="w", delete=False, ) as f: f.write(MULTI_UNIT_SCHEMATIC) path = f.name try: pin_units = parse_lib_symbol_pin_units(path, "Nonexistent:Symbol") assert pin_units == {} finally: os.unlink(path) def test_nonexistent_file_returns_empty(self): """Missing file returns empty dict.""" pin_units = parse_lib_symbol_pin_units( "/tmp/does_not_exist.kicad_sch", "Amplifier_Operational:TL072", ) assert pin_units == {} class TestFindComponentForPin: """Tests for _find_component_for_pin (multi-unit unit selection).""" def test_finds_correct_unit_for_pin(self, tmp_path): """Pin 8 (V+) belongs to unit 3 — returns the unit 3 instance.""" from mckicad.utils.sch_document import SchDocument from mckicad.utils.sexp_parser import _find_component_for_pin path = str(tmp_path / "multi.kicad_sch") with open(path, "w") as f: f.write(MULTI_UNIT_SCHEMATIC) sch = SchDocument(path) sch.components.add( lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072", x=300, y=102, unit=1, ) sch.components.add( lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072", x=300, y=145, unit=2, ) sch.components.add( lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072", x=300, y=175, unit=3, ) sch.save(path) # Reload to ensure sexp is parsed sch = SchDocument(path) # Pin 8 → unit 3 comp = _find_component_for_pin( sch, "U2", "8", path, "Amplifier_Operational:TL072", ) assert comp is not None assert comp.unit == 3 # Pin 1 → unit 1 comp = _find_component_for_pin( sch, "U2", "1", path, "Amplifier_Operational:TL072", ) assert comp is not None assert comp.unit == 1 # Pin 5 → unit 2 comp = _find_component_for_pin( sch, "U2", "5", path, "Amplifier_Operational:TL072", ) assert comp is not None assert comp.unit == 2 def test_single_unit_returns_get(self, tmp_path): """Single-unit symbol falls back to sch.components.get().""" from mckicad.utils.sch_document import SchDocument from mckicad.utils.sexp_parser import _find_component_for_pin path = str(tmp_path / "single.kicad_sch") with open(path, "w") as f: f.write(SAMPLE_SCHEMATIC) sch = SchDocument(path) sch.components.add( lib_id="Device:R", reference="R1", value="10k", x=100, y=100, ) sch.save(path) sch = SchDocument(path) comp = _find_component_for_pin( sch, "R1", "1", path, "Device:R", ) assert comp is not None assert comp.reference == "R1" class TestMultiUnitPinResolution: """Integration: resolve_pin_position returns correct coords per unit.""" def test_pin_resolves_to_correct_unit_position(self, tmp_path): """Pin 8 (unit 3 at y=175) resolves near y=175, not y=102.""" from mckicad.utils.sch_document import SchDocument from mckicad.utils.sexp_parser import resolve_pin_position path = str(tmp_path / "multi_resolve.kicad_sch") with open(path, "w") as f: f.write(MULTI_UNIT_SCHEMATIC) sch = SchDocument(path) sch.components.add( lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072", x=300, y=102, unit=1, ) sch.components.add( lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072", x=300, y=145, unit=2, ) sch.components.add( lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072", x=300, y=175, unit=3, ) sch.save(path) # Reload for clean state sch = SchDocument(path) # Pin 8 (V+) → unit 3 at y=175 pos_8 = resolve_pin_position(sch, path, "U2", "8") assert pos_8 is not None # Y should be near 175 (unit 3), not near 102 (unit 1) assert abs(pos_8[1] - 175) < 10, ( f"Pin 8 y={pos_8[1]:.1f}, expected near 175 (unit 3)" ) # Pin 1 (output) → unit 1 at y=102 pos_1 = resolve_pin_position(sch, path, "U2", "1") assert pos_1 is not None assert abs(pos_1[1] - 102) < 10, ( f"Pin 1 y={pos_1[1]:.1f}, expected near 102 (unit 1)" ) # Pin 5 (+In) → unit 2 at y=145 pos_5 = resolve_pin_position(sch, path, "U2", "5") assert pos_5 is not None assert abs(pos_5[1] - 145) < 10, ( f"Pin 5 y={pos_5[1]:.1f}, expected near 145 (unit 2)" )