Files
kicad-mcp-server/tests/test_pin_world_xy_eeschema_truth.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

217 lines
8.0 KiB
Python

"""
Ground-truth regression for WireDragger.pin_world_xy + label snap.
The oracle is eeschema itself, accessed via `kicad-cli sch export netlist`.
For each (rotation, mirror) corner case, we:
1. Build a schematic with a polarized component (Device:D, K=pin1, A=pin2).
2. Apply the transform via the MCP rotate handler.
3. Snap a label "<ref>_K" to pin 1 and "<ref>_A" to pin 2 via PinLocator coords.
4. Run kicad-cli to extract the netlist.
5. Assert each label's net binds to the *named* pin in the netlist —
i.e. label "_K" must end up on pin 1 (K), not pin 2 (A).
If our pin coords agree with eeschema's render, the labels land on the
intended pins. If they disagree, the netlist swaps them, exposing the bug.
Skips if kicad-cli or the system Device library aren't available.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
from commands.component_schematic import ComponentManager # noqa: E402
from commands.pin_locator import PinLocator # noqa: E402
from commands.schematic import SchematicManager # noqa: E402
from commands.wire_dragger import WireDragger # noqa: E402
_KICAD_CLI = shutil.which("kicad-cli")
_DEVICE_LIB = Path("/usr/share/kicad/symbols/Device.kicad_sym")
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(_KICAD_CLI is None, reason="kicad-cli not on PATH"),
pytest.mark.skipif(not _DEVICE_LIB.exists(), reason="Device lib not installed"),
]
def _add_labels_to_file(sch_path: Path, labels: list[tuple[str, float, float]]) -> None:
"""Inject (label ...) tokens before the closing ')' of a .kicad_sch file."""
text = sch_path.read_text()
block = "\n"
for name, x, y in labels:
block += (
f' (label "{name}"\n'
f" (at {x} {y} 0)\n"
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
f' (uuid "00000000-0000-0000-0000-{abs(hash(name)) % 10**12:012d}")\n'
f" )\n"
)
last = text.rstrip().rfind(")")
sch_path.write_text(text[:last] + block + text[last:])
def _extract_pin_to_net(netlist_xml: Path) -> dict:
"""Return {(ref, pin_num): net_name} from a kicad XML netlist."""
tree = ET.parse(netlist_xml)
root = tree.getroot()
out = {}
for net in root.findall(".//net"):
net_name = net.attrib.get("name", "").lstrip("/")
for node in net.findall("node"):
ref = node.attrib.get("ref")
pin = node.attrib.get("pin")
if ref and pin:
out[(ref, pin)] = net_name
return out
def _build_diode_case(tmp: Path, rotation: int) -> tuple[Path, dict]:
"""Place a Device:D, rotate, snap labels, save. Returns (sch_path, expected_map)."""
sch_path = tmp / f"diode_rot{rotation}.kicad_sch"
template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch"
shutil.copy(template, sch_path)
sch = SchematicManager.load_schematic(str(sch_path))
ComponentManager.add_component(
sch,
{
"type": "D",
"reference": "D1",
"value": "1N4148",
"x": 100.0,
"y": 100.0,
"rotation": rotation,
},
sch_path,
)
SchematicManager.save_schematic(sch, str(sch_path))
locator = PinLocator()
p_k = locator.get_pin_location(sch_path, "D1", "1")
p_a = locator.get_pin_location(sch_path, "D1", "2")
assert p_k is not None and p_a is not None
_add_labels_to_file(sch_path, [("D1_K", p_k[0], p_k[1]), ("D1_A", p_a[0], p_a[1])])
return sch_path, {("D1", "1"): "D1_K", ("D1", "2"): "D1_A"}
def _build_mirror_case(tmp: Path, axis: str) -> tuple[Path, dict]:
sch_path = tmp / f"resistor_mirror_{axis}.kicad_sch"
template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch"
shutil.copy(template, sch_path)
sch = SchematicManager.load_schematic(str(sch_path))
ComponentManager.add_component(
sch,
{
"type": "R",
"reference": "R1",
"value": "10k",
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror": axis,
},
sch_path,
)
SchematicManager.save_schematic(sch, str(sch_path))
locator = PinLocator()
p1 = locator.get_pin_location(sch_path, "R1", "1")
p2 = locator.get_pin_location(sch_path, "R1", "2")
assert p1 is not None and p2 is not None
_add_labels_to_file(sch_path, [("R1_PIN1", p1[0], p1[1]), ("R1_PIN2", p2[0], p2[1])])
return sch_path, {("R1", "1"): "R1_PIN1", ("R1", "2"): "R1_PIN2"}
def _run_netlist(sch_path: Path) -> dict:
out = sch_path.with_suffix(".net")
env = {**os.environ, "KICAD_SYMBOL_DIR": "/usr/share/kicad/symbols"}
subprocess.run(
[
_KICAD_CLI,
"sch",
"export",
"netlist",
"--format",
"kicadxml",
"-o",
str(out),
str(sch_path),
],
check=True,
capture_output=True,
env=env,
)
return _extract_pin_to_net(out)
@pytest.mark.parametrize("rotation", [0, 90, 180, 270])
def test_diode_label_polarity_through_eeschema(rotation):
"""Snap-labelled K must show up on pin 1 in the kicad-cli netlist."""
with tempfile.TemporaryDirectory() as td:
sch_path, expected = _build_diode_case(Path(td), rotation)
actual = _run_netlist(sch_path)
for (ref, pin), net in expected.items():
assert actual.get((ref, pin)) == net, (
f"rotation={rotation}: D1.{pin} label landed on wrong pin. "
f"Expected net={net}, got {actual.get((ref, pin))}. "
f"Full mapping: {actual}"
)
@pytest.mark.parametrize("axis", ["x", "y"])
def test_mirrored_resistor_label_through_eeschema(axis):
"""Snap-labelled pin 1 must show up on pin 1 after (mirror x) / (mirror y)."""
with tempfile.TemporaryDirectory() as td:
sch_path, expected = _build_mirror_case(Path(td), axis)
actual = _run_netlist(sch_path)
for (ref, pin), net in expected.items():
assert actual.get((ref, pin)) == net, (
f"mirror={axis}: R1.{pin} label landed on wrong pin. "
f"Expected net={net}, got {actual.get((ref, pin))}. "
f"Full mapping: {actual}"
)
def test_pin_world_xy_rot90_matches_eeschema_transform():
"""Pure-math regression: pin_world_xy for Device:R rot=90 must match
eeschema's TRANSFORM(0,1,-1,0) applied to internal Y-flipped pin."""
# Device:R pin 1: lib (0, +3.81). parseXY(invertY=true) → internal (0, -3.81).
# TRANSFORM(0,1,-1,0) applied: (0*0 + 1*-3.81, -1*0 + 0*-3.81) = (-3.81, 0).
# Symbol at (100, 100) → world (96.19, 100).
wx, wy = WireDragger.pin_world_xy(0.0, 3.81, 100.0, 100.0, 90, False, False)
assert wx == pytest.approx(96.19), f"rot=90 X wrong: {wx} (expected 96.19)"
assert wy == pytest.approx(100.0), f"rot=90 Y wrong: {wy} (expected 100.0)"
def test_pin_world_xy_mirror_x_matches_eeschema():
"""(mirror x) = SYM_MIRROR_X = TRANSFORM(1,0,0,-1) → negates internal Y.
Device:R pin 1 lib (0, +3.81) → internal (0, -3.81) → mirror_x → (0, 3.81)
→ symbol (100, 100) → world (100, 103.81). Pin should NOT be at (100, 96.19)."""
wx, wy = WireDragger.pin_world_xy(0.0, 3.81, 100.0, 100.0, 0, True, False)
assert wx == pytest.approx(100.0)
assert wy == pytest.approx(103.81), f"mirror_x Y wrong: {wy} (expected 103.81)"
def test_pin_world_xy_mirror_y_matches_eeschema():
"""(mirror y) = SYM_MIRROR_Y = TRANSFORM(-1,0,0,1) → negates internal X.
Device:R pin 1 lib (0, +3.81) → internal (0, -3.81) → mirror_y → (0, -3.81)
→ world (100, 96.19). Y of pin 1 is unchanged by mirror across Y axis."""
wx, wy = WireDragger.pin_world_xy(0.0, 3.81, 100.0, 100.0, 0, False, True)
assert wx == pytest.approx(100.0)
assert wy == pytest.approx(96.19), f"mirror_y Y wrong: {wy} (expected 96.19)"