Files
kicad-mcp-server/tests/test_hierarchical_pad_net_map.py
Samuel Price 8a4281211e test: add unit tests for _build_hierarchical_pad_net_map
15 tests covering:
- Empty schematic → empty maps
- Global / local / hierarchical labels at pin endpoints
- Net propagation through one and two wire hops
- Unconnected pin stays absent from map
- Power (#PWR) and flag (#FLG) symbols excluded from component map
- Components in sub-sheets collected alongside top-level components
- 90° rotated symbol produces correct absolute pin positions

Uses MagicMock skip.Schematic objects patched in both the walker
import and commands.pin_locator module namespace; lib_symbols are
provided in real on-disk .kicad_sch files so sexpdata parsing runs
without mocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 12:52:02 -04:00

410 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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