test(pin_locator): add asymmetric multi-pin Y-flip regression (#188)

The existing y_flip tests cover Device:R (symmetric two-pin) and Device:C
rotated 90° (still electrically symmetric). The original bug — a double
Y-flip in get_pin_location — was invisible on symmetric passives because
pin 1 and pin 2 are interchangeable; it only showed up on asymmetric
multi-pin ICs like RF_Module:ESP32-WROOM-32, where labels meant for pin 3
(EN) silently landed on pin 35 (TXD0).

This adds a third test using an inline 6-pin asymmetric symbol with pins
at both positive and negative library Y on both sides. It asserts every
pin lands at the formula-predicted (symbol_x + lib_px, symbol_y - lib_py)
position, with an explicit cross-check that lib +Y pins resolve *above*
the placement centre in schematic Y-down space.

No system-library dependency — the symbol is constructed inline so the
test runs anywhere pytest does.

Fixes #135
This commit is contained in:
NiNjA-CodE
2026-05-18 21:02:17 -06:00
committed by GitHub
parent 457e4e30ad
commit aff498ae76

View File

@@ -111,3 +111,132 @@ def test_rotated_capacitor_pin_x_matches_render_convention():
# World: (150-3.81, 100) = (146.19, 100). Verified vs kicad-cli netlist. # 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[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]}" assert p1[1] == pytest.approx(100.0), f"rotated pin 1 Y wrong: {p1[1]}"
# ---------------------------------------------------------------------------
# Asymmetric multi-pin regression (the case where the original bug hit hardest)
# ---------------------------------------------------------------------------
#
# The symmetric-symbol tests above pass even with the pre-fix code because
# Device:R and Device:C are pin-equivalent — swap pin 1 and pin 2 in the
# netlist and the circuit still works. The bug only became visible on
# asymmetric, multi-pin ICs like RF_Module:ESP32-WROOM-32, where pin 3 (EN)
# and pin 35 (TXD0/IO1) are not interchangeable. With the original code,
# pins at library y=+30.48 (top of the symbol) were reported at world
# y=symbol_y+30.48 (bottom of the schematic), so labels meant for EN landed
# on TXD0, and ERC reported either a dangling label or a wrong connection.
#
# Issue #135 specifically asked for an asymmetric-symbol regression. Rather
# than depend on system libraries (RF_Module is not bundled with the repo),
# this fixture builds a minimal 6-pin asymmetric symbol inline and asserts
# every pin lands at the formula-predicted absolute position.
_ASYMMETRIC_FIXTURE = """(kicad_sch
(version 20231120) (generator "test_pin_locator_y_flip")
(uuid "00000000-0000-0000-0000-000000000001")
(paper "A4")
(lib_symbols
(symbol "Test:Asym6"
(pin_numbers hide) (pin_names (offset 0.508))
(in_bom yes) (on_board yes)
(property "Reference" "U" (at 0 15.24 0)
(effects (font (size 1.27 1.27))))
(property "Value" "Asym6" (at 0 -15.24 0)
(effects (font (size 1.27 1.27))))
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide))
(symbol "Asym6_0_1"
(rectangle (start -7.62 12.7) (end 7.62 -12.7)
(stroke (width 0.254) (type default))
(fill (type none))))
(symbol "Asym6_1_1"
(pin input line (at -10.16 10.16 0) (length 2.54)
(name "TOP_LEFT" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))
(pin input line (at -10.16 5.08 0) (length 2.54)
(name "MID_HIGH_L" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27)))))
(pin input line (at -10.16 -5.08 0) (length 2.54)
(name "MID_LOW_L" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27)))))
(pin input line (at -10.16 -10.16 0) (length 2.54)
(name "BOT_LEFT" (effects (font (size 1.27 1.27))))
(number "4" (effects (font (size 1.27 1.27)))))
(pin output line (at 10.16 10.16 180) (length 2.54)
(name "TOP_RIGHT" (effects (font (size 1.27 1.27))))
(number "5" (effects (font (size 1.27 1.27)))))
(pin output line (at 10.16 -10.16 180) (length 2.54)
(name "BOT_RIGHT" (effects (font (size 1.27 1.27))))
(number "6" (effects (font (size 1.27 1.27))))))))
(symbol
(lib_id "Test:Asym6") (at 100 100 0) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "11111111-1111-1111-1111-111111111111")
(property "Reference" "U1" (at 100 84.76 0)
(effects (font (size 1.27 1.27))))
(property "Value" "Asym6" (at 100 115.24 0)
(effects (font (size 1.27 1.27))))
(property "Footprint" "" (at 100 100 0)
(effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "" (at 100 100 0)
(effects (font (size 1.27 1.27)) hide))
(instances
(project "test"
(path "/00000000-0000-0000-0000-000000000001"
(reference "U1") (unit 1))))))
"""
@pytest.mark.unit
def test_asymmetric_multi_pin_symbol_y_flip():
"""A 6-pin asymmetric IC must report every pin at the correct side of centre.
Pre-#135 fix, library y=+10.16 pins were reported at schematic y=+110.16
(below the placement centre at y=100). The render-correct answer is
schematic y=89.84 (above the centre, smaller numerical y in Y-down).
Each side (left at lib x=-10.16, right at lib x=+10.16) carries pins at
both positive and negative library Y — so a Y-flip bug or a left/right
swap would visibly mis-place at least one pin. This is the asymmetric
multi-pin regression requested in issue #135 (originally hit on
RF_Module:ESP32-WROOM-32 in the wild; same arithmetic, smaller fixture).
"""
with tempfile.TemporaryDirectory() as tmp:
sch_path = Path(tmp) / "asym6_regression.kicad_sch"
sch_path.write_text(_ASYMMETRIC_FIXTURE, encoding="utf-8")
locator = PinLocator()
# Placement: U1 at (100, 100) rotation 0, no mirror.
# Formula in screen Y-down (after the Y-flip fix):
# world_x = symbol_x + lib_px
# world_y = symbol_y + (-lib_py)
# so the lib +Y pins (top of symbol) end up at smaller world_y, i.e.
# *above* the placement centre.
expected = {
"1": (100 - 10.16, 100 - 10.16), # 89.84, 89.84
"2": (100 - 10.16, 100 - 5.08), # 89.84, 94.92
"3": (100 - 10.16, 100 + 5.08), # 89.84, 105.08
"4": (100 - 10.16, 100 + 10.16), # 89.84, 110.16
"5": (100 + 10.16, 100 - 10.16), # 110.16, 89.84
"6": (100 + 10.16, 100 + 10.16), # 110.16, 110.16
}
for pin_num, (exp_x, exp_y) in expected.items():
loc = locator.get_pin_location(sch_path, "U1", pin_num)
assert loc is not None, f"PinLocator returned None for pin {pin_num}"
assert loc[0] == pytest.approx(exp_x), (
f"pin {pin_num} X wrong: got {loc[0]}, expected {exp_x}"
)
assert loc[1] == pytest.approx(exp_y), (
f"pin {pin_num} Y wrong: got {loc[1]}, expected {exp_y}"
)
# Cross-check the side-asymmetry directly: top-left (lib y=+10.16) must
# be ABOVE centre, bottom-left (lib y=-10.16) BELOW centre. Pre-fix
# these would be swapped because of the double-Y-flip.
p1 = locator.get_pin_location(sch_path, "U1", "1")
p4 = locator.get_pin_location(sch_path, "U1", "4")
assert p1[1] < 100, f"top pin must be above centre, got y={p1[1]}"
assert p4[1] > 100, f"bottom pin must be below centre, got y={p4[1]}"