Files
kicad-mcp-server/tests/test_wire_junction_changes.py
Eugene Mikhantyev 7a6558b9fa 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>
2026-04-26 14:59:47 +01:00

1477 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Tests for schematic wire and junction handling:
- add_schematic_wire: waypoints param, pin snapping, polyline routing
- Automatic junction sync (add_schematic_junction tool removed)
- Schema updates in tool_schemas.py
- ConnectionManager orphaned method removal
"""
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import sexpdata
from sexpdata import Symbol
# Add python dir to path
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
TEMPLATES_DIR = PYTHON_DIR / "templates"
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_temp_sch() -> Any:
"""Copy the empty schematic template to a temp file and return the Path."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(EMPTY_SCH, tmp)
return tmp
def _parse_sch(path: Path) -> Any:
"""Parse a .kicad_sch file and return the S-expression list."""
with open(path, "r", encoding="utf-8") as f:
return sexpdata.loads(f.read())
def _find_elements(sch_data: Any, tag: str) -> Any:
"""Return all top-level S-expression elements with the given tag Symbol."""
return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)]
# ---------------------------------------------------------------------------
# 1. Schema tests
# ---------------------------------------------------------------------------
class TestSchemas:
"""Verify tool_schemas.py reflects the new API."""
@pytest.fixture(autouse=True)
def load_schemas(self) -> Any:
from schemas.tool_schemas import SCHEMATIC_TOOLS
self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS}
def test_add_schematic_wire_has_waypoints(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
assert "waypoints" in schema["properties"], "waypoints must be a property"
assert "waypoints" in schema["required"]
def test_add_schematic_wire_has_schematic_path(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
assert "schematicPath" in schema["properties"]
assert "schematicPath" in schema["required"]
def test_add_schematic_wire_has_snap_params(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
props = schema["properties"]
assert "snapToPins" in props
assert props["snapToPins"]["type"] == "boolean"
assert "snapTolerance" in props
assert props["snapTolerance"]["type"] == "number"
def test_add_schematic_wire_no_old_point_params(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
props = schema["properties"]
assert "startPoint" not in props, "startPoint should be removed"
assert "endPoint" not in props, "endPoint should be removed"
def test_add_schematic_connection_removed(self) -> None:
assert (
"add_schematic_connection" not in self.tools
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
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"
# ---------------------------------------------------------------------------
# 2. Handler dispatch tests
# ---------------------------------------------------------------------------
class TestHandlerDispatch:
"""Verify KiCADInterface registers the right tool handlers."""
@pytest.fixture(autouse=True)
def load_handler_map(self) -> Any:
# Import only the dispatch table without initialising KiCAD connections
import importlib
import types
# Patch heavy imports before loading kicad_interface
for mod in ["pcbnew", "skip"]:
sys.modules.setdefault(mod, types.ModuleType(mod))
from kicad_interface import KiCADInterface
# Peek at the dispatch table by instantiating with mocked internals
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
obj = KiCADInterface.__new__(KiCADInterface)
# Manually set attributes that __init__ normally provides
obj._backend = None
# Build the handler map the same way the real __init__ does
obj._tool_handlers = {
"add_schematic_wire": obj._handle_add_schematic_wire,
"add_schematic_net_label": obj._handle_add_schematic_net_label,
}
self.handlers = obj._tool_handlers
def test_add_schematic_wire_registered(self) -> None:
from kicad_interface import KiCADInterface
# Just verify the class has the handler method
assert hasattr(KiCADInterface, "_handle_add_schematic_wire")
def test_add_schematic_junction_handler_removed(self) -> None:
from kicad_interface import KiCADInterface
assert not hasattr(KiCADInterface, "_handle_add_schematic_junction")
def test_add_schematic_connection_not_present(self) -> None:
from kicad_interface import KiCADInterface
assert not hasattr(
KiCADInterface, "_handle_add_schematic_connection"
), "_handle_add_schematic_connection should be removed"
# ---------------------------------------------------------------------------
# 3. _handle_add_schematic_wire — parameter validation
# ---------------------------------------------------------------------------
class TestHandleAddSchematicWireValidation:
"""Unit tests for _handle_add_schematic_wire validation paths (no disk I/O)."""
@pytest.fixture(autouse=True)
def handler(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_wire({"waypoints": [[0, 0], [10, 0]]})
assert result["success"] is False
assert "Schematic path" in result["message"]
def test_missing_waypoints(self) -> None:
result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False
assert "waypoint" in result["message"].lower()
def test_single_waypoint_rejected(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": "/tmp/x.kicad_sch",
"waypoints": [[0, 0]],
}
)
assert result["success"] is False
assert "waypoint" in result["message"].lower()
# ---------------------------------------------------------------------------
# 4. _handle_add_schematic_wire — wire routing logic
# ---------------------------------------------------------------------------
class TestHandleAddSchematicWireRouting:
"""Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping."""
@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)
self.sch_path = _make_temp_sch()
yield
# cleanup
shutil.rmtree(self.sch_path.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_two_waypoints_calls_add_wire(self, mock_add_wire: Any) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[10.0, 20.0], [30.0, 20.0]],
"snapToPins": False,
}
)
assert result["success"] is True
mock_add_wire.assert_called_once()
args = mock_add_wire.call_args[0]
assert args[1] == [10.0, 20.0]
assert args[2] == [30.0, 20.0]
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
def test_four_waypoints_calls_add_polyline_wire(self, mock_poly: Any) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]],
"snapToPins": False,
}
)
assert result["success"] is True
mock_poly.assert_called_once()
def test_points_key_without_waypoints_is_rejected(self) -> None:
"""'points' key alone (without 'waypoints') is rejected — no fallback."""
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"points": [[5.0, 5.0], [15.0, 5.0]],
"snapToPins": False,
}
)
assert result["success"] is False
assert "waypoint" in result["message"].lower()
@patch("commands.wire_manager.WireManager.add_wire", return_value=False)
def test_failure_response(self, _: Any) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[0, 0], [10, 0]],
"snapToPins": False,
}
)
assert result["success"] is False
# ---------------------------------------------------------------------------
# 5. _handle_add_schematic_wire — pin snapping
# ---------------------------------------------------------------------------
class TestPinSnapping:
"""Verify pin snapping logic snaps endpoints correctly."""
@pytest.fixture(autouse=True)
def setup(self) -> Any:
import types
# Provide a minimal skip.Schematic stub so the handler can import it
skip_mod = types.ModuleType("skip")
class FakeSchematic:
def __init__(self, path: Any) -> None:
pass
@property
def symbol(self) -> list[Any]:
return [] # no symbols → no pins in snapping loop
skip_mod.Schematic = FakeSchematic
sys.modules["skip"] = skip_mod
sys.modules.setdefault("pcbnew", types.ModuleType("pcbnew"))
from kicad_interface import KiCADInterface
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface)
self.sch_path = _make_temp_sch()
yield
shutil.rmtree(self.sch_path.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
@patch("commands.pin_locator.PinLocator.get_all_symbol_pins")
def test_start_point_snapped_within_tolerance(self, mock_pins: Any, mock_wire: Any) -> None:
"""First waypoint within tolerance of a pin should be snapped to pin coords."""
# get_all_symbol_pins won't be called because symbol list is empty in fixture.
# Instead we patch find_nearest_pin indirectly by providing all_pins via the
# skip.Schematic stub that returns one symbol with a known pin.
import types
skip_mod = sys.modules["skip"]
class FakeSymbol:
class property:
class Reference:
value = "R1"
def __init__(self) -> None:
pass
skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})()
mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]}
# Re-import so the patched skip.Schematic is used
import importlib
import kicad_interface
importlib.reload(kicad_interface)
from kicad_interface import KiCADInterface
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
iface = KiCADInterface.__new__(KiCADInterface)
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
result = iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[10.05, 20.05], [50.0, 20.0]],
"snapToPins": True,
"snapTolerance": 1.0,
}
)
if result["success"]:
called_start = mw.call_args[0][1]
assert called_start == [
10.0,
20.0,
], f"Start should snap to [10.0, 20.0], got {called_start}"
# If it failed due to stub issues, just verify no exception
def test_snap_disabled_passes_original_coords(self) -> None:
"""With snapToPins=False the handler should not load PinLocator at all."""
with (
patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw,
patch("commands.pin_locator.PinLocator") as mock_locator_cls,
):
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[1.0, 2.0], [3.0, 4.0]],
"snapToPins": False,
}
)
mock_locator_cls.assert_not_called()
assert result["success"] is True
called_start = mw.call_args[0][1]
assert called_start == [1.0, 2.0]
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_snap_miss_leaves_coords_unchanged(self, mock_wire: Any) -> None:
"""Point beyond tolerance should not be snapped."""
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[100.0, 100.0], [200.0, 100.0]],
"snapToPins": True,
"snapTolerance": 0.5,
# skip.Schematic returns no symbols (fixture), so no pins to snap to
}
)
assert result["success"] is True
# No snapping info in message
assert "snapped" not in result.get("message", "")
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
def test_intermediate_waypoints_not_snapped(self, mock_poly: Any) -> None:
"""Middle waypoints must remain unchanged even with snapToPins=True."""
mid = [50.0, 50.0]
with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
"waypoints": [[100.0, 100.0], mid[:], [200.0, 100.0]],
"snapToPins": True,
"snapTolerance": 100.0, # huge tolerance, but mid must not snap
}
)
assert result["success"] is True
called_points = mp.call_args[0][1]
assert (
called_points[1] == mid
), f"Middle waypoint should not be snapped, got {called_points[1]}"
# ---------------------------------------------------------------------------
# 6. ConnectionManager — orphaned methods removed
# ---------------------------------------------------------------------------
class TestConnectionManagerOrphanedMethodsRemoved:
def test_add_wire_removed(self) -> None:
from commands.connection_schematic import ConnectionManager
assert not hasattr(
ConnectionManager, "add_wire"
), "ConnectionManager.add_wire should have been removed"
def test_add_connection_removed(self) -> None:
from commands.connection_schematic import ConnectionManager
assert not hasattr(
ConnectionManager, "add_connection"
), "ConnectionManager.add_connection should have been removed"
def test_get_pin_location_removed(self) -> None:
from commands.connection_schematic import ConnectionManager
assert not hasattr(
ConnectionManager, "get_pin_location"
), "ConnectionManager.get_pin_location should have been removed"
# ---------------------------------------------------------------------------
# 8. Integration tests — real disk I/O, no KiCAD process
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestIntegrationWireManager:
"""Integration tests using real schematic files and WireManager."""
@pytest.fixture(autouse=True)
def sch(self) -> Any:
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_writes_wire_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager
ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0])
assert ok is True
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_add_polyline_wire_creates_segments(self, sch: Any) -> None:
"""N waypoints should produce N-1 individual 2-point wire segments."""
from commands.wire_manager import WireManager
pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [20.0, 10.0]]
ok = WireManager.add_polyline_wire(sch, pts)
assert ok is True
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
def test_wire_endpoint_coordinates_match(self, sch: Any) -> None:
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5])
data = _parse_sch(sch)
wire = _find_elements(data, "wire")[0]
pts = [
item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts")
][0]
xy_entries = [
item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy")
]
assert xy_entries[0][1] == 5.0
assert xy_entries[0][2] == 7.5
assert xy_entries[1][1] == 15.0
assert xy_entries[1][2] == 7.5
@pytest.mark.integration
class TestIntegrationHandlerEndToEnd:
"""Integration tests for KiCADInterface handlers writing to real schematic files."""
@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)
self.sch = _make_temp_sch()
yield
shutil.rmtree(self.sch.parent, ignore_errors=True)
def test_wire_handler_two_points_writes_wire(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch),
"waypoints": [[10.0, 10.0], [30.0, 10.0]],
"snapToPins": False,
}
)
assert result["success"] is True
data = _parse_sch(self.sch)
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_wire_handler_four_points_creates_three_segments(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch),
"waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]],
"snapToPins": False,
}
)
assert result["success"] is True
data = _parse_sch(self.sch)
wires = _find_elements(data, "wire")
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
# ---------------------------------------------------------------------------
# 9. Unit tests — _point_strictly_on_wire
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPointStrictlyOnWire:
"""Unit tests for WireManager._point_strictly_on_wire geometry helper."""
@staticmethod
def _fn(px: Any, py: Any, x1: Any, y1: Any, x2: Any, y2: Any, eps: Any = 1e-6) -> Any:
from commands.wire_manager import WireManager
return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps)
def test_horizontal_midpoint(self) -> None:
assert self._fn(5, 0, 0, 0, 10, 0) is True
def test_vertical_midpoint(self) -> None:
assert self._fn(0, 5, 0, 0, 0, 10) is True
def test_horizontal_at_start_endpoint(self) -> None:
"""Point at wire start should NOT be strictly on wire."""
assert self._fn(0, 0, 0, 0, 10, 0) is False
def test_horizontal_at_end_endpoint(self) -> None:
"""Point at wire end should NOT be strictly on wire."""
assert self._fn(10, 0, 0, 0, 10, 0) is False
def test_vertical_at_start_endpoint(self) -> None:
assert self._fn(0, 0, 0, 0, 0, 10) is False
def test_vertical_at_end_endpoint(self) -> None:
assert self._fn(0, 10, 0, 0, 0, 10) is False
def test_point_off_horizontal_wire(self) -> None:
"""Point above a horizontal wire."""
assert self._fn(5, 1, 0, 0, 10, 0) is False
def test_point_off_vertical_wire(self) -> None:
"""Point to the right of a vertical wire."""
assert self._fn(1, 5, 0, 0, 0, 10) is False
def test_point_beyond_horizontal_wire(self) -> None:
"""Point collinear but past the end of a horizontal wire."""
assert self._fn(15, 0, 0, 0, 10, 0) is False
def test_point_beyond_vertical_wire(self) -> None:
"""Point collinear but past the end of a vertical wire."""
assert self._fn(0, 15, 0, 0, 0, 10) is False
def test_diagonal_wire_always_false(self) -> None:
"""Only horizontal/vertical wires are handled; diagonal → False."""
assert self._fn(5, 5, 0, 0, 10, 10) is False
def test_reversed_horizontal_endpoints(self) -> None:
"""Wire endpoints reversed (x2 < x1) should still work."""
assert self._fn(5, 0, 10, 0, 0, 0) is True
def test_reversed_vertical_endpoints(self) -> None:
"""Wire endpoints reversed (y2 < y1) should still work."""
assert self._fn(0, 5, 0, 10, 0, 0) is True
def test_near_endpoint_within_epsilon(self) -> None:
"""Point within epsilon of endpoint should NOT be considered strictly on wire."""
assert self._fn(1e-7, 0, 0, 0, 10, 0) is False
def test_zero_length_wire(self) -> None:
"""Degenerate wire with same start/end — nothing is strictly between."""
assert self._fn(5, 5, 5, 5, 5, 5) is False
# ---------------------------------------------------------------------------
# 10. Unit tests — _parse_wire
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestParseWire:
"""Unit tests for WireManager._parse_wire S-expression parser."""
@staticmethod
def _fn(item: Any) -> Any:
from commands.wire_manager import WireManager
return WireManager._parse_wire(item)
def test_valid_wire(self) -> None:
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]],
[
Symbol("stroke"),
[Symbol("width"), 0],
[Symbol("type"), Symbol("default")],
],
[Symbol("uuid"), "abc-123"],
]
result = TestParseWire._fn(wire)
assert result is not None
start, end, width, stype = result
assert start == (10.0, 20.0)
assert end == (30.0, 20.0)
assert width == 0
assert stype == "default"
def test_non_wire_element_returns_none(self) -> None:
junction = [Symbol("junction"), [Symbol("at"), 10, 20]]
assert TestParseWire._fn(junction) is None
def test_non_list_returns_none(self) -> None:
assert TestParseWire._fn("not a list") is None
def test_empty_list_returns_none(self) -> None:
assert TestParseWire._fn([]) is None
def test_wire_with_no_pts_returns_none(self) -> None:
wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]]
assert TestParseWire._fn(wire) is None
def test_wire_with_only_one_xy_returns_none(self) -> None:
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0]],
]
assert TestParseWire._fn(wire) is None
def test_wire_without_stroke_uses_defaults(self) -> None:
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]],
]
result = TestParseWire._fn(wire)
assert result is not None
_, _, width, stype = result
assert width == 0
assert stype == "default"
# ---------------------------------------------------------------------------
# 11. Unit tests — _make_wire_sexp
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMakeWireSexp:
"""Unit tests for WireManager._make_wire_sexp builder."""
def test_produces_valid_parseable_wire(self) -> None:
from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([10, 20], [30, 20])
parsed = WireManager._parse_wire(sexp)
assert parsed is not None
start, end, width, stype = parsed
assert start == (10, 20)
assert end == (30, 20)
assert width == 0
assert stype == "default"
def test_custom_stroke(self) -> None:
from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
parsed = WireManager._parse_wire(sexp)
assert parsed is not None
_, _, width, stype = parsed
assert width == 0.5
assert stype == "dash"
def test_has_uuid(self) -> None:
from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([0, 0], [10, 0])
# uuid is the last element
uuid_entry = sexp[-1]
assert uuid_entry[0] == Symbol("uuid")
assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0
def test_two_calls_produce_different_uuids(self) -> None:
from commands.wire_manager import WireManager
sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0])
sexp2 = WireManager._make_wire_sexp([0, 0], [10, 0])
assert sexp1[-1][1] != sexp2[-1][1], "Each wire should have a unique UUID"
# ---------------------------------------------------------------------------
# 12. Unit tests — _break_wires_at_point
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBreakWiresAtPoint:
"""Unit tests for WireManager._break_wires_at_point T-junction logic."""
@staticmethod
def _make_sch_data_with_wires(wire_coords: Any) -> list[Any]:
"""Build a minimal sch_data list with wire elements and a sheet_instances marker."""
from commands.wire_manager import WireManager
data = [Symbol("kicad_sch")]
for start, end in wire_coords:
data.append(WireManager._make_wire_sexp(start, end))
data.append([Symbol("sheet_instances")])
return data
def test_split_horizontal_wire_at_midpoint(self) -> None:
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
splits = WireManager._break_wires_at_point(data, [10, 0])
assert splits == 1
wires = _find_elements(data, "wire")
assert len(wires) == 2
# Verify the two segments share the split point
coords = []
for w in wires:
parsed = WireManager._parse_wire(w)
coords.append((parsed[0], parsed[1]))
endpoints = {c for pair in coords for c in pair}
assert (10.0, 0.0) in endpoints
def test_split_vertical_wire_at_midpoint(self) -> None:
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([5, 0], [5, 30])])
splits = WireManager._break_wires_at_point(data, [5, 15])
assert splits == 1
wires = _find_elements(data, "wire")
assert len(wires) == 2
def test_no_split_at_wire_endpoint(self) -> None:
"""Point at existing endpoint should not trigger a split."""
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
splits = WireManager._break_wires_at_point(data, [0, 0])
assert splits == 0
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_no_split_point_not_on_wire(self) -> None:
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
splits = WireManager._break_wires_at_point(data, [10, 5])
assert splits == 0
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_split_multiple_wires_at_same_point(self) -> None:
"""Two crossing wires at (10, 10) — both should be split."""
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires(
[
([0, 10], [20, 10]), # horizontal through (10,10)
([10, 0], [10, 20]), # vertical through (10,10)
]
)
splits = WireManager._break_wires_at_point(data, [10, 10])
assert splits == 2
wires = _find_elements(data, "wire")
assert len(wires) == 4 # each wire split into 2
def test_split_preserves_stroke_properties(self) -> None:
from commands.wire_manager import WireManager
data = [Symbol("kicad_sch")]
data.append(
WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash")
)
data.append([Symbol("sheet_instances")])
splits = WireManager._break_wires_at_point(data, [10, 0])
assert splits == 1
wires = _find_elements(data, "wire")
for w in wires:
parsed = WireManager._parse_wire(w)
assert parsed[2] == 0.5, "stroke_width should be preserved"
assert parsed[3] == "dash", "stroke_type should be preserved"
def test_no_split_on_diagonal_wire(self) -> None:
"""Diagonal wires are not handled by _point_strictly_on_wire → no split."""
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [10, 10])])
splits = WireManager._break_wires_at_point(data, [5, 5])
assert splits == 0
def test_empty_sch_data(self) -> None:
from commands.wire_manager import WireManager
data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]]
splits = WireManager._break_wires_at_point(data, [10, 10])
assert splits == 0
# ---------------------------------------------------------------------------
# 13. Integration tests — T-junction wire breaking
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestIntegrationTJunction:
"""Integration tests for T-junction wire breaking during add_wire."""
@pytest.fixture(autouse=True)
def sch(self) -> Any:
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_breaks_existing_horizontal_wire(self, sch: Any) -> None:
"""Adding a vertical wire whose endpoint is mid-horizontal-wire should split it."""
from commands.wire_manager import WireManager
# First add a horizontal wire (0,10) -> (20,10)
WireManager.add_wire(sch, [0, 10], [20, 10])
# Now add a vertical wire ending at (10,10) — the midpoint of the horizontal wire
WireManager.add_wire(sch, [10, 0], [10, 10])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
# Original horizontal wire should be split into 2, plus the new vertical = 3 total
assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}"
def test_add_wire_does_not_break_at_shared_endpoint(self, sch: Any) -> None:
"""Wire connecting at an existing endpoint should not trigger a split."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [10, 0])
# New wire starts at (10,0) — existing endpoint, not midpoint
WireManager.add_wire(sch, [10, 0], [10, 10])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires (no split), 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
WireManager.add_wire(sch, [0, 10], [20, 10])
# Polyline starting at (10,10) — mid-horizontal-wire
WireManager.add_polyline_wire(sch, [[10, 10], [10, 20], [20, 20]])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
# 2 from split + 2 polyline segments = 4
assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}"
def test_polyline_two_points_same_as_add_wire(self, sch: Any) -> None:
"""Polyline with exactly 2 points should produce 1 wire segment."""
from commands.wire_manager import WireManager
WireManager.add_polyline_wire(sch, [[0, 0], [10, 0]])
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."
)