Auto-sync junctions on wire/symbol mutations

Replaces the manual add_schematic_junction tool with automatic junction
management. WireManager.sync_junctions inserts/removes junction dots
based on wire endpoints plus component pin positions and is invoked
after add_wire, add_polyline_wire, delete_wire, move, and rotate.

- Pin-aware: parses lib_symbols and applies KiCad's mirror/rotate/
  translate transform to compute world pin coordinates
- Multi-unit safe: filters lib_symbols sub-units by the placed
  symbol's (unit N) field plus the unit-0 common body
- Removes the now-unused WireManager.add_junction static method
- Updates CHANGELOG [Unreleased] with the tool removal notice
- Adds .mcp.json to .gitignore (machine-local paths)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-26 14:59:47 +01:00
parent c62a859e0b
commit 7a6558b9fa
8 changed files with 891 additions and 285 deletions

View File

@@ -1,7 +1,7 @@
"""
Tests for fix/tool-schema-descriptions branch changes:
Tests for schematic wire and junction handling:
- add_schematic_wire: waypoints param, pin snapping, polyline routing
- add_schematic_junction: new tool replacing add_schematic_connection
- Automatic junction sync (add_schematic_junction tool removed)
- Schema updates in tool_schemas.py
- ConnectionManager orphaned method removal
"""
@@ -91,22 +91,10 @@ class TestSchemas:
"add_schematic_connection" not in self.tools
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
def test_add_schematic_junction_present(self) -> None:
assert "add_schematic_junction" in self.tools
def test_add_schematic_junction_schema(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"]
props = schema["properties"]
assert "schematicPath" in props
assert "position" in props
assert set(schema["required"]) >= {"schematicPath", "position"}
def test_add_schematic_junction_position_is_array(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"]
pos = schema["properties"]["position"]
assert pos["type"] == "array"
assert pos.get("minItems") == 2
assert pos.get("maxItems") == 2
def test_add_schematic_junction_removed(self) -> None:
assert (
"add_schematic_junction" not in self.tools
), "add_schematic_junction was removed; junctions are now managed automatically"
# ---------------------------------------------------------------------------
@@ -137,7 +125,6 @@ class TestHandlerDispatch:
# Build the handler map the same way the real __init__ does
obj._tool_handlers = {
"add_schematic_wire": obj._handle_add_schematic_wire,
"add_schematic_junction": obj._handle_add_schematic_junction,
"add_schematic_net_label": obj._handle_add_schematic_net_label,
}
self.handlers = obj._tool_handlers
@@ -148,10 +135,10 @@ class TestHandlerDispatch:
# Just verify the class has the handler method
assert hasattr(KiCADInterface, "_handle_add_schematic_wire")
def test_add_schematic_junction_registered(self) -> None:
def test_add_schematic_junction_handler_removed(self) -> None:
from kicad_interface import KiCADInterface
assert hasattr(KiCADInterface, "_handle_add_schematic_junction")
assert not hasattr(KiCADInterface, "_handle_add_schematic_junction")
def test_add_schematic_connection_not_present(self) -> None:
from kicad_interface import KiCADInterface
@@ -418,67 +405,7 @@ class TestPinSnapping:
# ---------------------------------------------------------------------------
# 6. _handle_add_schematic_junction — unit tests
# ---------------------------------------------------------------------------
class TestHandleAddSchematicJunction:
@pytest.fixture(autouse=True)
def setup(self) -> Any:
import types
for mod in ["pcbnew", "skip"]:
sys.modules.setdefault(mod, types.ModuleType(mod))
from kicad_interface import KiCADInterface
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface)
def test_missing_schematic_path(self) -> None:
result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]})
assert result["success"] is False
assert "Schematic path" in result["message"]
def test_missing_position(self) -> None:
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False
assert "Position" in result["message"]
@patch("commands.wire_manager.WireManager.add_junction", return_value=True)
def test_success(self, mock_jct: Any) -> None:
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(sch),
"position": [25.4, 25.4],
}
)
assert result["success"] is True
assert "Junction added" in result["message"]
mock_jct.assert_called_once_with(sch, [25.4, 25.4])
finally:
shutil.rmtree(sch.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_junction", return_value=False)
def test_failure(self, _: Any) -> None:
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(sch),
"position": [25.4, 25.4],
}
)
assert result["success"] is False
assert "Failed" in result["message"]
finally:
shutil.rmtree(sch.parent, ignore_errors=True)
# ---------------------------------------------------------------------------
# 7. ConnectionManager — orphaned methods removed
# 6. ConnectionManager — orphaned methods removed
# ---------------------------------------------------------------------------
@@ -541,19 +468,6 @@ class TestIntegrationWireManager:
wires = _find_elements(data, "wire")
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
def test_add_junction_writes_junction_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager
ok = WireManager.add_junction(sch, [25.4, 25.4])
assert ok is True
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
# Verify position
at = junctions[0][1] # (at x y)
assert at[1] == 25.4
assert at[2] == 25.4
def test_wire_endpoint_coordinates_match(self, sch: Any) -> None:
from commands.wire_manager import WireManager
@@ -590,18 +504,6 @@ class TestIntegrationHandlerEndToEnd:
yield
shutil.rmtree(self.sch.parent, ignore_errors=True)
def test_junction_handler_writes_junction(self) -> None:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(self.sch),
"position": [50.8, 50.8],
}
)
assert result["success"] is True
data = _parse_sch(self.sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_wire_handler_two_points_writes_wire(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
@@ -934,7 +836,7 @@ class TestBreakWiresAtPoint:
@pytest.mark.integration
class TestIntegrationTJunction:
"""Integration tests for T-junction wire breaking during add_wire/add_junction."""
"""Integration tests for T-junction wire breaking during add_wire."""
@pytest.fixture(autouse=True)
def sch(self) -> Any:
@@ -966,28 +868,6 @@ class TestIntegrationTJunction:
wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}"
def test_add_junction_breaks_wire(self, sch: Any) -> None:
"""Adding a junction mid-wire should split that wire."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [30, 0])
WireManager.add_junction(sch, [15, 0])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}"
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_add_junction_at_wire_endpoint_no_split(self, sch: Any) -> None:
"""Junction at wire endpoint should not split it."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [20, 0])
WireManager.add_junction(sch, [20, 0])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
def test_polyline_breaks_existing_wire(self, sch: Any) -> None:
"""Polyline whose start/end hits mid-wire should break it."""
from commands.wire_manager import WireManager
@@ -1008,3 +888,589 @@ class TestIntegrationTJunction:
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 1
# ---------------------------------------------------------------------------
# 14. Auto junction sync
# ---------------------------------------------------------------------------
class TestSyncJunctionsUnit:
"""Unit tests for WireManager.sync_junctions operating on raw sch_data."""
def _base_data(self) -> Any:
"""Minimal sch_data with a sheet_instances section."""
return [Symbol("kicad_sch"), [Symbol("sheet_instances")]]
def _wire(self, x1: float, y1: float, x2: float, y2: float) -> list:
return [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), x1, y1], [Symbol("xy"), x2, y2]],
[Symbol("stroke"), [Symbol("width"), 0], [Symbol("type"), Symbol("default")]],
[Symbol("uuid"), "00000000-0000-0000-0000-000000000000"],
]
def _junction(self, x: float, y: float) -> list:
return [
Symbol("junction"),
[Symbol("at"), x, y],
[Symbol("diameter"), 0],
[Symbol("color"), 0, 0, 0, 0],
[Symbol("uuid"), "00000000-0000-0000-0000-000000000001"],
]
def test_t_junction_adds_junction(self) -> None:
"""Three wire endpoints meeting at one point → one junction added."""
from commands.wire_manager import WireManager
data = self._base_data()
# Horizontal wire split at (10,0): (0,0)→(10,0) and (10,0)→(20,0)
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
# Vertical wire whose endpoint is (10,0): 3 endpoints at (10,0)
data.insert(1, self._wire(10, -10, 10, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 1
assert removed == 0
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
at = next(p for p in junctions[0][1:] if isinstance(p, list) and p[0] == Symbol("at"))
assert abs(at[1] - 10) < 1e-6
assert abs(at[2] - 0) < 1e-6
def test_two_wire_join_no_junction(self) -> None:
"""Two wires meeting end-to-end (2 endpoints) → no junction."""
from commands.wire_manager import WireManager
data = self._base_data()
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 0
assert removed == 0
assert _find_elements(data, "junction") == []
def test_stale_junction_removed(self) -> None:
"""A junction with fewer than 3 wire endpoints at its position is removed."""
from commands.wire_manager import WireManager
data = self._base_data()
# Only two wires meeting at (10,0) — junction is stale
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
data.insert(1, self._junction(10, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 0
assert removed == 1
assert _find_elements(data, "junction") == []
def test_x_junction_gets_one_junction(self) -> None:
"""Four wire endpoints meeting (X-junction) → exactly one junction."""
from commands.wire_manager import WireManager
data = self._base_data()
# Simulate already-split X: four segments meeting at (10,10)
data.insert(1, self._wire(0, 10, 10, 10))
data.insert(1, self._wire(10, 10, 20, 10))
data.insert(1, self._wire(10, 0, 10, 10))
data.insert(1, self._wire(10, 10, 10, 20))
added, removed = WireManager.sync_junctions(data)
assert added == 1
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_idempotent_on_correct_junction(self) -> None:
"""Running sync_junctions when junction is already correct is a no-op."""
from commands.wire_manager import WireManager
data = self._base_data()
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
data.insert(1, self._wire(10, -10, 10, 0))
data.insert(1, self._junction(10, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 0
assert removed == 0
assert len(_find_elements(data, "junction")) == 1
@pytest.mark.integration
class TestSyncJunctionsIntegration:
"""Integration tests for automatic junction sync through add_wire / delete_wire."""
@pytest.fixture(autouse=True)
def sch(self) -> Any:
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_t_junction_auto_inserted_on_add_wire(self, sch: Any) -> None:
"""Adding a wire that creates a T-junction should auto-insert a junction dot."""
from commands.wire_manager import WireManager
# Horizontal wire, then split it manually, then add connecting vertical
WireManager.add_wire(sch, [0, 10], [10, 10])
WireManager.add_wire(sch, [10, 10], [20, 10])
WireManager.add_wire(sch, [10, 0], [10, 10]) # creates T at (10,10)
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1, f"Expected 1 junction, got {len(junctions)}"
def test_straight_join_no_junction(self, sch: Any) -> None:
"""Two wires joining end-to-end should not produce a junction."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [10, 0])
WireManager.add_wire(sch, [10, 0], [20, 0])
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert junctions == [], f"Expected no junctions, got {len(junctions)}"
def test_delete_wire_removes_stale_junction(self, sch: Any) -> None:
"""Removing a wire that was part of a T-junction should remove the junction."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 10], [10, 10])
WireManager.add_wire(sch, [10, 10], [20, 10])
WireManager.add_wire(sch, [10, 0], [10, 10]) # creates T, junction auto-inserted
data = _parse_sch(sch)
assert len(_find_elements(data, "junction")) == 1, "Pre-condition: junction present"
WireManager.delete_wire(sch, [10, 0], [10, 10]) # remove the branch
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert junctions == [], f"Expected junction removed, got {len(junctions)}"
def test_polyline_t_junction_auto_inserted(self, sch: Any) -> None:
"""Polyline whose endpoint hits a wire midpoint auto-inserts a junction."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 10], [20, 10]) # horizontal
# Polyline from above ending at (10,10) — mid-wire, wire breaks → T at (10,10)
WireManager.add_polyline_wire(sch, [[10, 0], [10, 10]])
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1, f"Expected 1 junction after polyline T, got {len(junctions)}"
# ---------------------------------------------------------------------------
# 15. Pin-aware junction sync
# ---------------------------------------------------------------------------
class TestSyncJunctionsPinAware:
"""Unit tests for junction sync when component pins are present."""
def _base_data_with_resistor(self, sym_x: float, sym_y: float, rotation: float = 0) -> Any:
"""Build minimal sch_data with a Device:R-like symbol.
Pin 1 is at (0, 3.81) in lib coords → world (sym_x, sym_y - 3.81) at rot=0.
Pin 2 is at (0, -3.81) in lib coords → world (sym_x, sym_y + 3.81) at rot=0.
"""
pin1_sexp = [
Symbol("pin"),
Symbol("passive"),
Symbol("line"),
[Symbol("at"), 0, 3.81, 270],
[Symbol("length"), 1.27],
[Symbol("name"), "~"],
[Symbol("number"), "1"],
]
pin2_sexp = [
Symbol("pin"),
Symbol("passive"),
Symbol("line"),
[Symbol("at"), 0, -3.81, 90],
[Symbol("length"), 1.27],
[Symbol("name"), "~"],
[Symbol("number"), "2"],
]
lib_symbol = [
Symbol("symbol"),
"Device:R",
[Symbol("pin_numbers"), Symbol("hide")],
[Symbol("symbol"), "R_1_1", pin1_sexp, pin2_sexp],
]
lib_symbols_section = [Symbol("lib_symbols"), lib_symbol]
placed_symbol = [
Symbol("symbol"),
[Symbol("lib_id"), "Device:R"],
[Symbol("at"), sym_x, sym_y, rotation],
[Symbol("unit"), 1],
[Symbol("property"), "Reference", "R1", [Symbol("at"), sym_x, sym_y, 0]],
[Symbol("property"), "Value", "10k", [Symbol("at"), sym_x, sym_y, 0]],
[Symbol("pin"), "1", [Symbol("uuid"), "pin1-uuid"]],
[Symbol("pin"), "2", [Symbol("uuid"), "pin2-uuid"]],
]
return [
Symbol("kicad_sch"),
lib_symbols_section,
placed_symbol,
[Symbol("sheet_instances")],
]
def _wire(self, x1: float, y1: float, x2: float, y2: float) -> list:
return [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), x1, y1], [Symbol("xy"), x2, y2]],
[Symbol("stroke"), [Symbol("width"), 0], [Symbol("type"), Symbol("default")]],
[Symbol("uuid"), "wire-uuid"],
]
def test_collect_pin_positions_no_rotation(self) -> None:
"""_collect_pin_positions returns correct world coords for R at (100,100) rot=0."""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor(100, 100, rotation=0)
pins = WireManager._collect_pin_positions(data)
# Pin 1: lib (0, 3.81) → y-negate → (0, -3.81) → world (100, 96.19)
# Pin 2: lib (0, -3.81) → y-negate → (0, 3.81) → world (100, 103.81)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 96.19) in pin_set
assert (100.0, 103.81) in pin_set
def test_collect_pin_positions_rotation_90(self) -> None:
"""_collect_pin_positions handles 90° rotation correctly."""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor(100, 100, rotation=90)
pins = WireManager._collect_pin_positions(data)
# Pin 1: lib (0, 3.81) → y-neg → (0, -3.81) → rot90 → (3.81, 0) → world (103.81, 100)
# Pin 2: lib (0, -3.81) → y-neg → (0, 3.81) → rot90 → (-3.81, 0) → world (96.19, 100)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (103.81, 100.0) in pin_set
assert (96.19, 100.0) in pin_set
def test_two_wires_plus_pin_gets_junction(self) -> None:
"""Two wires ending at a pin position → junction needed (total 3)."""
from commands.wire_manager import WireManager
# R at (100, 100): pin 1 world = (100, 96.19)
data = self._base_data_with_resistor(100, 100, rotation=0)
# Two wires both ending at pin 1
data.insert(2, self._wire(80, 96.19, 100, 96.19))
data.insert(2, self._wire(100, 96.19, 120, 96.19))
added, removed = WireManager.sync_junctions(data)
assert added == 1, f"Expected 1 junction added, got {added}"
assert removed == 0
def test_one_wire_plus_pin_no_junction(self) -> None:
"""Single wire connecting to a pin (total 2) → no junction."""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor(100, 100, rotation=0)
# Single wire ending at pin 1
data.insert(2, self._wire(80, 96.19, 100, 96.19))
added, removed = WireManager.sync_junctions(data)
assert added == 0, f"Expected no junction, got {added}"
assert removed == 0
def test_wire_midpoint_pin_gets_junction(self) -> None:
"""Wire passing through (broken at) a pin position + another wire → junction."""
from commands.wire_manager import WireManager
# R at (100, 100): pin 1 world = (100, 96.19)
# Simulate a horizontal wire already split at pin position
data = self._base_data_with_resistor(100, 100, rotation=0)
data.insert(2, self._wire(80, 96.19, 100, 96.19)) # left segment
data.insert(2, self._wire(100, 96.19, 120, 96.19)) # right segment
# wire_cnt=2, pin_cnt=1 → total=3 → junction
added, removed = WireManager.sync_junctions(data)
assert added == 1, f"Expected junction at wire-split + pin, got {added}"
# ------------------------------------------------------------------
# Mirror / mirror+rotation tests
# Transform order (per _collect_pin_positions docstring):
# 1. y-negate (lib y-up → schematic y-down)
# 2. mirror_x → ly = -ly
# mirror_y → lx = -lx
# 3. rotate by `rotation` degrees
# 4. translate by (sym_x, sym_y)
# TODO: verify against KiCad's actual transform order — see schematic_view.py
# ------------------------------------------------------------------
def _base_data_with_resistor_mirrored(
self,
sym_x: float,
sym_y: float,
rotation: float = 0,
mirror: str = "x",
) -> Any:
"""Like _base_data_with_resistor but injects a (mirror x) or (mirror y) clause
into the placed-symbol form."""
data = self._base_data_with_resistor(sym_x, sym_y, rotation)
# data[2] is the placed_symbol list
placed = data[2]
placed.append([Symbol("mirror"), Symbol(mirror)])
return data
def test_collect_pin_positions_mirror_x_rotation_0(self) -> None:
"""mirror_x at rotation=0: pins flip across the local x-axis (y-coords invert).
Pin 1: lib(0, 3.81) → y-neg → (0, -3.81) → mirror_x → (0, +3.81) → world (100, 103.81)
Pin 2: lib(0,-3.81) → y-neg → (0, +3.81) → mirror_x → (0, -3.81) → world (100, 96.19)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=0, mirror="x")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 103.81) in pin_set
assert (100.0, 96.19) in pin_set
def test_collect_pin_positions_mirror_y_rotation_0(self) -> None:
"""mirror_y at rotation=0: flips lx (which is 0 for both pins) → same world coords.
Pin 1: lib(0, 3.81) → y-neg → (0, -3.81) → mirror_y: lx=-0=0 → world (100, 96.19)
Pin 2: lib(0,-3.81) → y-neg → (0, +3.81) → mirror_y: lx=-0=0 → world (100, 103.81)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=0, mirror="y")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 96.19) in pin_set
assert (100.0, 103.81) in pin_set
def test_collect_pin_positions_mirror_x_rotation_90(self) -> None:
"""mirror_x combined with rotation=90.
Pin 1: lib(0, 3.81) → y-neg → (0,-3.81) → mirror_x → (0,+3.81)
→ rot90 (c=0,s=1): lx'=0*0-3.81*1=-3.81, ly'=0*1+3.81*0=0
→ world (96.19, 100.0)
Pin 2: lib(0,-3.81) → y-neg → (0,+3.81) → mirror_x → (0,-3.81)
→ rot90: lx'=0*0-(-3.81)*1=3.81, ly'=0*1+(-3.81)*0=0
→ world (103.81, 100.0)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=90, mirror="x")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (96.19, 100.0) in pin_set
assert (103.81, 100.0) in pin_set
def test_collect_pin_positions_mirror_y_rotation_180(self) -> None:
"""mirror_y combined with rotation=180.
Pin 1: lib(0, 3.81) → y-neg → (0,-3.81) → mirror_y: lx=-0=0, ly=-3.81
→ rot180 (c=-1,s=0): lx'=0*(-1)-(-3.81)*0=0, ly'=0*0+(-3.81)*(-1)=3.81
→ world (100.0, 103.81)
Pin 2: lib(0,-3.81) → y-neg → (0,+3.81) → mirror_y: lx=0, ly=3.81
→ rot180: lx'=0*(-1)-3.81*0=0, ly'=0*0+3.81*(-1)=-3.81
→ world (100.0, 96.19)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=180, mirror="y")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 103.81) in pin_set
assert (100.0, 96.19) in pin_set
# ---------------------------------------------------------------------------
# 16. Multi-unit IC pin-awareness (LM324-like quad op-amp)
# ---------------------------------------------------------------------------
class TestMultiUnitLibPins:
"""Unit tests for _parse_lib_pins unit-aware filtering (multi-unit ICs)."""
@staticmethod
def _make_lm324_lib_symbol() -> list:
"""Return a minimal lib_symbols entry modelled on the LM324 quad op-amp.
Structure:
(symbol "Amplifier_Operational:LM324"
(symbol "LM324_1_1" ;; unit 1 op-amp body A
(pin input line (at -5 2.54 0) ...) ; IN+
(pin input line (at -5 -2.54 0) ...) ; IN-
(pin output line (at 5 0 180) ...) ; OUT
)
(symbol "LM324_2_1" ;; unit 2 op-amp body B
(pin input line (at -5 2.54 0) ...)
(pin input line (at -5 -2.54 0) ...)
(pin output line (at 5 0 180) ...)
)
(symbol "LM324_3_1" ;; unit 3
...
)
(symbol "LM324_4_1" ;; unit 4
...
)
(symbol "LM324_5_1" ;; unit 5 = power (common)
(pin power_in line (at 0 5 270) ...) ; VCC
(pin power_in line (at 0 -5 90) ...) ; GND
)
)
"""
def _pin(px: float, py: float) -> list:
return [
Symbol("pin"),
Symbol("input"),
Symbol("line"),
[Symbol("at"), px, py, 0],
[Symbol("length"), 1.27],
[Symbol("name"), "~"],
[Symbol("number"), "1"],
]
def _sub(name: str, pins: list) -> list:
return [Symbol("symbol"), name] + pins
unit1 = _sub("LM324_1_1", [_pin(-5, 2.54), _pin(-5, -2.54), _pin(5, 0)])
unit2 = _sub("LM324_2_1", [_pin(-5, 12.54), _pin(-5, -12.54), _pin(5, 10)])
unit3 = _sub("LM324_3_1", [_pin(-5, 22.54), _pin(-5, -22.54), _pin(5, 20)])
unit4 = _sub("LM324_4_1", [_pin(-5, 32.54), _pin(-5, -32.54), _pin(5, 30)])
power = _sub("LM324_5_1", [_pin(0, 5), _pin(0, -5)]) # unit 5 = common/power
return [
Symbol("symbol"),
"Amplifier_Operational:LM324",
unit1,
unit2,
unit3,
unit4,
power,
]
def test_unit2_returns_only_unit2_and_common_pins(self) -> None:
"""Placing unit 2 must return 3 op-amp pins + 2 power (common) pins = 5 total."""
from commands.wire_manager import WireManager
sym_def = self._make_lm324_lib_symbol()
pins = WireManager._parse_lib_pins(sym_def, unit=2)
# Unit 2 contributes 3 pins; LM324_5_1 (unit 5 != 2 != 0) should be excluded.
# Only unit==2 OR unit==0 qualifies. LM324_5_1 has unit=5, so it is skipped.
assert (
len(pins) == 3
), f"Expected 3 pins (unit 2 only, power unit 5 excluded), got {len(pins)}: {pins}"
# The three unit-2 pins have distinct y-coords in our fixture
pin_coords = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (-5.0, 12.54) in pin_coords
assert (-5.0, -12.54) in pin_coords
assert (5.0, 10.0) in pin_coords
def test_unit1_does_not_include_unit2_pins(self) -> None:
"""Placing unit 1 must not return pins from units 2-5."""
from commands.wire_manager import WireManager
sym_def = self._make_lm324_lib_symbol()
pins = WireManager._parse_lib_pins(sym_def, unit=1)
assert len(pins) == 3, f"Expected 3 pins (unit 1 only), got {len(pins)}: {pins}"
pin_coords = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (-5.0, 2.54) in pin_coords
assert (-5.0, -2.54) in pin_coords
assert (5.0, 0.0) in pin_coords
def test_common_unit_zero_always_included(self) -> None:
"""A sub-unit named <base>_0_1 (unit=0) must appear for every placed unit."""
from commands.wire_manager import WireManager
# Build a symbol that has a _0_1 common sub-unit
def _pin(px: float, py: float) -> list:
return [
Symbol("pin"),
Symbol("power_in"),
Symbol("line"),
[Symbol("at"), px, py, 0],
[Symbol("length"), 1.27],
[Symbol("name"), "VCC"],
[Symbol("number"), "1"],
]
sym_def = [
Symbol("symbol"),
"Device:Foo",
[Symbol("symbol"), "Foo_0_1", _pin(0, 5)], # common (unit 0)
[Symbol("symbol"), "Foo_1_1", _pin(1, 1)], # unit 1
[Symbol("symbol"), "Foo_2_1", _pin(2, 2)], # unit 2
]
pins_u1 = WireManager._parse_lib_pins(sym_def, unit=1)
pins_u2 = WireManager._parse_lib_pins(sym_def, unit=2)
# Both units should include the unit-0 common pin
assert len(pins_u1) == 2, f"Unit 1: expected 2 pins (1 common + 1 own), got {len(pins_u1)}"
assert len(pins_u2) == 2, f"Unit 2: expected 2 pins (1 common + 1 own), got {len(pins_u2)}"
def test_no_spurious_junctions_for_multi_unit_ic(self) -> None:
"""Placing unit 2 of an LM324-like part must not create phantom junctions
from the pins of units 1, 3, 4, or 5."""
from commands.wire_manager import WireManager
sym_def = self._make_lm324_lib_symbol()
lib_symbols_section = [Symbol("lib_symbols"), sym_def]
# Place unit 2 at origin (0, 0), no rotation
placed = [
Symbol("symbol"),
[Symbol("lib_id"), "Amplifier_Operational:LM324"],
[Symbol("at"), 0, 0, 0],
[Symbol("unit"), 2],
]
# A single wire from (-30, 12.54) to (-5, 12.54) connects to unit-2 IN+ pin.
# world pin coords: lib (5, 12.54) → y-negate → (5, 12.54) → + origin = (5, 12.54)
# (The y-negation is part of KiCad's lib-to-schematic transform.)
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), -30, -12.54], [Symbol("xy"), -5, -12.54]],
[Symbol("stroke"), [Symbol("width"), 0], [Symbol("type"), Symbol("default")]],
[Symbol("uuid"), "test-wire"],
]
data = [
Symbol("kicad_sch"),
lib_symbols_section,
placed,
wire,
[Symbol("sheet_instances")],
]
added, removed = WireManager.sync_junctions(data)
# Wire endpoint at (5, 12.54) plus 1 pin = 2 total → no junction needed.
assert added == 0, (
f"Expected 0 junctions (wire endpoint + 1 pin = 2), got {added} added. "
"Phantom pins from other units may be causing false positives."
)