diff --git a/tests/test_hierarchical_pad_net_map.py b/tests/test_hierarchical_pad_net_map.py new file mode 100644 index 0000000..922edbb --- /dev/null +++ b/tests/test_hierarchical_pad_net_map.py @@ -0,0 +1,409 @@ +""" +Tests for _build_hierarchical_pad_net_map in KiCADInterface. + +The method walks every .kicad_sch file in a project, collects label positions +(local, global, hierarchical) and wire connectivity, then matches each component +pin to the propagated net name. It is used by sync_schematic_to_board so that +hierarchical projects — where all components live in sub-sheets — are handled +correctly. + +Coverage: + - Empty schematic → empty maps (TestEmptySchematic) + - Label placed directly at a pin endpoint → net assigned (TestLabelAtPin) + - Label reachable through a wire segment → net propagated (TestLabelViaWire) + - Power symbols (#PWR) excluded from component map (TestPowerSymbols) + - Components across multiple sub-sheet files all collected (TestMultipleSubsheets) + - Symbol with non-zero rotation has correct absolute pin positions (TestRotatedSymbol) +""" + +import sys +import textwrap +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, call, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +# --------------------------------------------------------------------------- +# A minimal .kicad_sch snippet with lib_symbols for a 2-pin resistor. +# PinLocator reads lib_symbols via sexpdata (no skip needed for that step), +# so we embed the symbol definition in the real file on disk. +# +# TestLib:R pin layout (relative to symbol origin, rotation = 0): +# pin "1" at (-1.27, 0) → wire connects on the left +# pin "2" at ( 1.27, 0) → wire connects on the right +# --------------------------------------------------------------------------- + +_SCH_WITH_TESTLIB_R = textwrap.dedent("""\ + (kicad_sch (version 20231120) + (lib_symbols + (symbol "TestLib:R" + (symbol "TestLib:R_1_1" + (pin passive line (at -1.27 0 0) (length 0) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 1.27 0 180) (length 0) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + ) + ) +""") + +_SCH_EMPTY = "(kicad_sch (version 20231120))" + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + + +def _sym_mock(ref: str, lib_id: str, x: float, y: float, rotation: float = 0) -> MagicMock: + """Minimal mock of a skip symbol instance.""" + m = MagicMock() + m.property.Reference.value = ref + m.property.Value.value = "~" + m.lib_id.value = lib_id + m.at.value = [x, y, rotation] + return m + + +def _lbl_mock(name: str, x: float, y: float) -> MagicMock: + """Minimal mock of a skip label (any type).""" + m = MagicMock() + m.value = name + m.at.value = [x, y, 0] + return m + + +def _wire_mock(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + """Minimal mock of a skip wire with two endpoints.""" + m = MagicMock() + p1 = MagicMock() + p1.value = [x1, y1] + p2 = MagicMock() + p2.value = [x2, y2] + m.pts.xy = [p1, p2] + return m + + +def _sch_mock( + symbols=(), + labels=(), + global_labels=(), + hier_labels=(), + wires=(), +) -> MagicMock: + """Build a mock skip.Schematic with the given collections.""" + m = MagicMock() + m.symbol = list(symbols) + m.label = list(labels) + m.global_label = list(global_labels) + m.hierarchical_label = list(hier_labels) + m.wire = list(wires) + return m + + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + + +def _make_iface() -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + return KiCADInterface.__new__(KiCADInterface) + + +@pytest.fixture() +def iface() -> Any: + return _make_iface() + + +# --------------------------------------------------------------------------- +# Helper: call _build_hierarchical_pad_net_map with both skip.Schematic +# entry-points patched (walker import + PinLocator module-level import). +# --------------------------------------------------------------------------- + + +def _call(iface: Any, sch_file: Path, mock_sch: MagicMock): + with ( + patch("skip.Schematic", return_value=mock_sch), + patch("commands.pin_locator.Schematic", return_value=mock_sch), + ): + return iface._build_hierarchical_pad_net_map(str(sch_file)) + + +# =========================================================================== +# TestEmptySchematic +# =========================================================================== + + +@pytest.mark.unit +class TestEmptySchematic: + def test_empty_file_returns_empty_maps(self, iface, tmp_path): + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_EMPTY) + pad_net_map, net_names = _call(iface, sch, _sch_mock()) + assert pad_net_map == {} + assert net_names == set() + + def test_no_symbols_returns_empty_map(self, iface, tmp_path): + """Labels without any symbols produce net_names but empty pad_net_map.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_EMPTY) + mock_sch = _sch_mock(global_labels=[_lbl_mock("FLOATING", 0, 0)]) + pad_net_map, net_names = _call(iface, sch, mock_sch) + assert pad_net_map == {} + assert "FLOATING" in net_names + + +# =========================================================================== +# TestLabelAtPin +# =========================================================================== + + +@pytest.mark.unit +class TestLabelAtPin: + """Label placed exactly at a pin endpoint is assigned to that pin.""" + + def test_global_label_pin1(self, iface, tmp_path): + """Global label at pin-1 position → (R1, 1) in map.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + # R1 at (10, 10); pin 1 abs = (10 − 1.27, 10) = (8.73, 10) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("VCC", 8.73, 10.0)], + ) + pad_net_map, net_names = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "VCC" + assert "VCC" in net_names + + def test_global_label_pin2(self, iface, tmp_path): + """Global label at pin-2 position → (R1, 2) in map.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + # pin 2 abs = (10 + 1.27, 10) = (11.27, 10) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("GND", 11.27, 10.0)], + ) + pad_net_map, net_names = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "2")) == "GND" + assert "GND" in net_names + + def test_both_pins_mapped(self, iface, tmp_path): + """Labels at both pin positions → both (ref, pin) keys present.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[ + _lbl_mock("NET_A", 8.73, 10.0), + _lbl_mock("NET_B", 11.27, 10.0), + ], + ) + pad_net_map, net_names = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "NET_A" + assert pad_net_map.get(("R1", "2")) == "NET_B" + + def test_local_label_also_works(self, iface, tmp_path): + """Local (net) labels are treated identically to global labels.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + labels=[_lbl_mock("LOCAL_NET", 8.73, 10.0)], + ) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "LOCAL_NET" + + def test_hierarchical_label_also_works(self, iface, tmp_path): + """Hierarchical labels are treated identically to global labels.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + hier_labels=[_lbl_mock("HIER_NET", 8.73, 10.0)], + ) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "HIER_NET" + + +# =========================================================================== +# TestLabelViaWire +# =========================================================================== + + +@pytest.mark.unit +class TestLabelViaWire: + """Net name propagates through wire segments to reach pin endpoints.""" + + def test_label_one_hop_away(self, iface, tmp_path): + """Label at wire start, wire end at pin → net assigned.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + # pin 1 at (8.73, 10); label at (5.0, 10); wire (5.0,10)→(8.73,10) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("WIRED_NET", 5.0, 10.0)], + wires=[_wire_mock(5.0, 10.0, 8.73, 10.0)], + ) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "WIRED_NET" + + def test_label_two_hops_away(self, iface, tmp_path): + """Net propagates through two chained wire segments.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + # label at (3.0, 10); wire1: 3→6; wire2: 6→8.73 → pin 1 + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("FAR_NET", 3.0, 10.0)], + wires=[ + _wire_mock(3.0, 10.0, 6.0, 10.0), + _wire_mock(6.0, 10.0, 8.73, 10.0), + ], + ) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "FAR_NET" + + def test_unconnected_pin_absent_from_map(self, iface, tmp_path): + """A pin with no label and no wire to a label is absent from pad_net_map.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + # label only at pin 1; pin 2 has nothing + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("NET_A", 8.73, 10.0)], + ) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert ("R1", "2") not in pad_net_map + + +# =========================================================================== +# TestPowerSymbols +# =========================================================================== + + +@pytest.mark.unit +class TestPowerSymbols: + def test_power_ref_not_in_component_map(self, iface, tmp_path): + """Symbols starting with '#' must not produce pad_net_map entries.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_EMPTY) + pwr = _sym_mock("#PWR01", "power:GND", 0.0, 0.0) + pwr.property.Value.value = "GND" + mock_sch = _sch_mock(symbols=[pwr]) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert not any(ref.startswith("#") for ref, _ in pad_net_map) + + def test_flag_ref_not_in_component_map(self, iface, tmp_path): + """Symbols starting with '#FLG' must not produce pad_net_map entries.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_EMPTY) + flg = _sym_mock("#FLG00", "power:PWR_FLAG", 0.0, 0.0) + flg.property.Value.value = "PWR_FLAG" + mock_sch = _sch_mock(symbols=[flg]) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert not any(ref.startswith("#") for ref, _ in pad_net_map) + + +# =========================================================================== +# TestMultipleSubsheets +# =========================================================================== + + +@pytest.mark.unit +class TestMultipleSubsheets: + def test_components_in_subsheet_collected(self, iface, tmp_path): + """A component in a sub-sheet is included in the returned map.""" + top = tmp_path / "top.kicad_sch" + top.write_text(_SCH_EMPTY) + sub_dir = tmp_path / "sheets" + sub_dir.mkdir() + sub = sub_dir / "component_sheet.kicad_sch" + sub.write_text(_SCH_WITH_TESTLIB_R) + + top_mock = _sch_mock() # top sheet has no components + sub_mock = _sch_mock( + symbols=[_sym_mock("R2", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("SUB_VCC", 8.73, 10.0)], + ) + + def _factory(path: str) -> MagicMock: + from pathlib import Path as _P + return sub_mock if _P(path).name == "component_sheet.kicad_sch" else top_mock + + with ( + patch("skip.Schematic", side_effect=_factory), + patch("commands.pin_locator.Schematic", side_effect=_factory), + ): + pad_net_map, net_names = iface._build_hierarchical_pad_net_map(str(top)) + + assert pad_net_map.get(("R2", "1")) == "SUB_VCC" + assert "SUB_VCC" in net_names + + def test_top_and_sub_components_merged(self, iface, tmp_path): + """Components from both top-level and sub-sheet appear in the same map.""" + top = tmp_path / "top.kicad_sch" + top.write_text(_SCH_WITH_TESTLIB_R) + sub_dir = tmp_path / "sheets" + sub_dir.mkdir() + sub = sub_dir / "component_sheet.kicad_sch" + sub.write_text(_SCH_WITH_TESTLIB_R) + + top_mock = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("TOP_NET", 8.73, 10.0)], + ) + sub_mock = _sch_mock( + symbols=[_sym_mock("R2", "TestLib:R", 10.0, 10.0)], + global_labels=[_lbl_mock("SUB_NET", 8.73, 10.0)], + ) + + def _factory(path: str) -> MagicMock: + from pathlib import Path as _P + return sub_mock if _P(path).name == "component_sheet.kicad_sch" else top_mock + + with ( + patch("skip.Schematic", side_effect=_factory), + patch("commands.pin_locator.Schematic", side_effect=_factory), + ): + pad_net_map, _ = iface._build_hierarchical_pad_net_map(str(top)) + + assert pad_net_map.get(("R1", "1")) == "TOP_NET" + assert pad_net_map.get(("R2", "1")) == "SUB_NET" + + +# =========================================================================== +# TestRotatedSymbol +# =========================================================================== + + +@pytest.mark.unit +class TestRotatedSymbol: + def test_90_degree_rotation(self, iface, tmp_path): + """A 90° CCW rotation maps pin (-1.27, 0) → (0, -1.27) in local coords.""" + sch = tmp_path / "top.kicad_sch" + sch.write_text(_SCH_WITH_TESTLIB_R) + # R1 at (10, 10), rotation=90° + # pin 1 (-1.27, 0) rotated 90° CCW → (0, -1.27) → abs (10.0, 8.73) + # pin 2 ( 1.27, 0) rotated 90° CCW → (0, 1.27) → abs (10.0, 11.27) + mock_sch = _sch_mock( + symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0, rotation=90)], + global_labels=[ + _lbl_mock("UP_NET", 10.0, 8.73), + _lbl_mock("DN_NET", 10.0, 11.27), + ], + ) + pad_net_map, _ = _call(iface, sch, mock_sch) + assert pad_net_map.get(("R1", "1")) == "UP_NET" + assert pad_net_map.get(("R1", "2")) == "DN_NET"