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>
This commit is contained in:
@@ -420,17 +420,19 @@ class TestMultipleSubsheets:
|
||||
@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."""
|
||||
"""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)]))
|
||||
# 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),
|
||||
_lbl_mock("UP_NET", 10.0, 11.27),
|
||||
_lbl_mock("DN_NET", 10.0, 8.73),
|
||||
],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
|
||||
@@ -222,13 +222,14 @@ class TestComputePinPositions:
|
||||
assert abs(new2[1] - 23.81) < 1e-4
|
||||
|
||||
def test_resistor_rotated_90(self) -> None:
|
||||
"""Device:R at (100, 100) rot=90. Pin 1 lib (0, +3.81) → y-flipped to
|
||||
(0, -3.81) → rotated 90° → (3.81, 0) → world (103.81, 100)."""
|
||||
"""Device:R at (100, 100) rot=90. Pin 1 lib (0, +3.81), Y-flip → (0, -3.81),
|
||||
eeschema rot=90 TRANSFORM(0,1,-1,0): (0*0+1*-3.81, -1*0+0*-3.81) = (-3.81, 0).
|
||||
World (96.19, 100). Verified vs kicad-cli netlist."""
|
||||
sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)])
|
||||
positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100)
|
||||
old1, _ = positions["1"]
|
||||
old2, _ = positions["2"]
|
||||
assert abs(old1[0] - 103.81) < 1e-3
|
||||
assert abs(old1[0] - 96.19) < 1e-3
|
||||
assert abs(old1[1] - 100) < 1e-3
|
||||
|
||||
def test_returns_empty_for_missing_component(self) -> None:
|
||||
|
||||
@@ -105,8 +105,9 @@ def test_rotated_capacitor_pin_x_matches_render_convention():
|
||||
p1 = locator.get_pin_location(sch_path, "C1", "1")
|
||||
assert p1 is not None
|
||||
|
||||
# Device:C pin 1 is at symbol (0, +3.81). After y-negate → (0, -3.81).
|
||||
# Rotated 90° CCW in screen coords: (0, -3.81) → (3.81, 0).
|
||||
# Absolute: (150+3.81, 100+0) = (153.81, 100).
|
||||
assert p1[0] == pytest.approx(153.81), f"rotated pin 1 X wrong: {p1[0]}"
|
||||
# Device:C pin 1 lib (0, +3.81). parseXY(invertY=true) → internal (0, -3.81).
|
||||
# Rotation 90 in eeschema is CCW in screen Y-down: TRANSFORM(0,1,-1,0).
|
||||
# Apply: (0*0 + 1*(-3.81), -1*0 + 0*(-3.81)) = (-3.81, 0).
|
||||
# World: (150-3.81, 100) = (146.19, 100). Verified vs kicad-cli netlist.
|
||||
assert p1[0] == pytest.approx(146.19), f"rotated pin 1 X wrong: {p1[0]}"
|
||||
assert p1[1] == pytest.approx(100.0), f"rotated pin 1 Y wrong: {p1[1]}"
|
||||
|
||||
216
tests/test_pin_world_xy_eeschema_truth.py
Normal file
216
tests/test_pin_world_xy_eeschema_truth.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
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)"
|
||||
Reference in New Issue
Block a user