Files
kicad-mcp-server/tests/test_hierarchical_pad_net_map.py
Eugene Mikhantyev f7660e15ad test: encode eeschema kicad-cli ground truth (currently RED)
Update existing rotation/mirror assertions and add a kicad-cli-grounded
regression suite. eeschema's pin transform is verified against:
  - rotation: TRANSFORM(0,1,-1,0) for rot=90 — CCW in screen Y-down
  - mirror x: SYM_MIRROR_X = TRANSFORM(1,0,0,-1) — negates internal Y
  - mirror y: SYM_MIRROR_Y = TRANSFORM(-1,0,0,1) — negates internal X

Updated assertions:
  - test_pin_locator_y_flip: rotated cap pin 1 X 153.81 → 146.19
  - test_move_with_wire_preservation::test_resistor_rotated_90:
    pin 1 X 103.81 → 96.19
  - test_hierarchical_pad_net_map::test_90_degree_rotation:
    swapped UP_NET / DN_NET label coords

New file tests/test_pin_world_xy_eeschema_truth.py:
  - 4 parametrized diode tests via kicad-cli netlist (oracle = eeschema)
  - 2 parametrized resistor mirror tests via kicad-cli netlist
  - 3 pure-math pin_world_xy assertions for rot=90, mirror_x, mirror_y

Currently RED on rotation 90/270 (rotation direction) and mirror_x/y
(axis semantics swapped). Production fix in WireDragger.pin_world_xy
to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:24:14 +01:00

441 lines
17 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
# ---------------------------------------------------------------------------
_LIB_SYMBOLS_BLOCK = textwrap.dedent("""\
(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_WITH_TESTLIB_R = "(kicad_sch (version 20231120)\n" + _LIB_SYMBOLS_BLOCK + ")\n"
_SCH_EMPTY = "(kicad_sch (version 20231120))"
def _build_sch_with_instances(
instances: "list[tuple[str, str, float, float, float]] | None" = None,
) -> str:
"""Build a .kicad_sch text with TestLib:R lib_symbols + the given symbol instances.
Each instance tuple: (reference, lib_id, x, y, rotation).
Required because PinLocator._get_symbol_transform reads the symbol position,
rotation, and lib_id directly from disk via sexpdata (it does not consult
the kicad-skip cache that the tests mock for labels/wires).
"""
parts = ["(kicad_sch (version 20231120)", _LIB_SYMBOLS_BLOCK]
for i, (ref, lib_id, x, y, rot) in enumerate(instances or [], start=1):
uuid = f"00000000-0000-0000-0000-{i:012d}"
parts.append(
f' (symbol (lib_id "{lib_id}") (at {x} {y} {rot}) (unit 1)\n'
f" (uuid {uuid})\n"
f' (property "Reference" "{ref}" (at {x} {y - 2.54} 0))\n'
f' (property "Value" "~" (at {x} {y + 2.54} 0))\n'
f' (pin "1" (uuid {uuid[:-1]}a))\n'
f' (pin "2" (uuid {uuid[:-1]}b))\n'
f" )\n"
)
parts.append(")\n")
return "".join(parts)
# ---------------------------------------------------------------------------
# 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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
# 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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
# 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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
# 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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
# 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(_build_sch_with_instances([("R2", "TestLib:R", 10.0, 10.0, 0)]))
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(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 0)]))
sub_dir = tmp_path / "sheets"
sub_dir.mkdir()
sub = sub_dir / "component_sheet.kicad_sch"
sub.write_text(_build_sch_with_instances([("R2", "TestLib:R", 10.0, 10.0, 0)]))
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):
"""eeschema rot=90 is CCW in screen Y-down: TRANSFORM(0,1,-1,0).
Pin 1 lib (-1.27, 0) → internal (-1.27, 0) → (0,1,-1,0) applied = (0, 1.27)
→ world (10.0, 11.27).
Pin 2 lib ( 1.27, 0) → internal ( 1.27, 0) → (0,1,-1,0) applied = (0, -1.27)
→ world (10.0, 8.73).
Verified vs kicad-cli netlist on a Device:D rotated 90."""
sch = tmp_path / "top.kicad_sch"
sch.write_text(_build_sch_with_instances([("R1", "TestLib:R", 10.0, 10.0, 90)]))
mock_sch = _sch_mock(
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0, rotation=90)],
global_labels=[
_lbl_mock("UP_NET", 10.0, 11.27),
_lbl_mock("DN_NET", 10.0, 8.73),
],
)
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"