diff --git a/tests/test_hierarchical_pad_net_map.py b/tests/test_hierarchical_pad_net_map.py index 2c24780..6ff8a0f 100644 --- a/tests/test_hierarchical_pad_net_map.py +++ b/tests/test_hierarchical_pad_net_map.py @@ -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) diff --git a/tests/test_move_with_wire_preservation.py b/tests/test_move_with_wire_preservation.py index 65579cb..4edbd4b 100644 --- a/tests/test_move_with_wire_preservation.py +++ b/tests/test_move_with_wire_preservation.py @@ -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: diff --git a/tests/test_pin_locator_y_flip.py b/tests/test_pin_locator_y_flip.py index ec83f0d..e734171 100644 --- a/tests/test_pin_locator_y_flip.py +++ b/tests/test_pin_locator_y_flip.py @@ -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]}" diff --git a/tests/test_pin_world_xy_eeschema_truth.py b/tests/test_pin_world_xy_eeschema_truth.py new file mode 100644 index 0000000..454c5f5 --- /dev/null +++ b/tests/test_pin_world_xy_eeschema_truth.py @@ -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 "_K" to pin 1 and "_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)"