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>
1477 lines
58 KiB
Python
1477 lines
58 KiB
Python
"""
|
||
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."
|
||
)
|