Files
kicad-mcp-server/python/tests/test_wire_junction_changes.py
Eugene Mikhantyev c44bd9205d style: sort Python imports with isort
Add isort configuration (profile=black, line_length=100) to pyproject.toml,
add isort pre-commit hook, and auto-sort imports across all Python source files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 13:02:24 +01:00

1010 lines
39 KiB
Python

"""
Tests for fix/tool-schema-descriptions branch changes:
- add_schematic_wire: waypoints param, pin snapping, polyline routing
- add_schematic_junction: new tool replacing add_schematic_connection
- Schema updates in tool_schemas.py
- ConnectionManager orphaned method removal
"""
import shutil
import sys
import tempfile
from pathlib import Path
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
sys.path.insert(0, str(PYTHON_DIR))
TEMPLATES_DIR = PYTHON_DIR / "templates"
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_temp_sch():
"""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):
"""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, tag: str):
"""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):
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):
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):
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):
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):
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):
assert (
"add_schematic_connection" not in self.tools
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
def test_add_schematic_junction_present(self):
assert "add_schematic_junction" in self.tools
def test_add_schematic_junction_schema(self):
schema = self.tools["add_schematic_junction"]["inputSchema"]
props = schema["properties"]
assert "schematicPath" in props
assert "position" in props
assert set(schema["required"]) >= {"schematicPath", "position"}
def test_add_schematic_junction_position_is_array(self):
schema = self.tools["add_schematic_junction"]["inputSchema"]
pos = schema["properties"]["position"]
assert pos["type"] == "array"
assert pos.get("minItems") == 2
assert pos.get("maxItems") == 2
# ---------------------------------------------------------------------------
# 2. Handler dispatch tests
# ---------------------------------------------------------------------------
class TestHandlerDispatch:
"""Verify KiCADInterface registers the right tool handlers."""
@pytest.fixture(autouse=True)
def load_handler_map(self):
# 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_junction": obj._handle_add_schematic_junction,
"add_schematic_net_label": obj._handle_add_schematic_net_label,
}
self.handlers = obj._tool_handlers
def test_add_schematic_wire_registered(self):
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_registered(self):
from kicad_interface import KiCADInterface
assert hasattr(KiCADInterface, "_handle_add_schematic_junction")
def test_add_schematic_connection_not_present(self):
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):
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):
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):
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):
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):
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):
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):
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):
"""'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, _):
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):
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):
pass
@property
def symbol(self):
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, mock_wire):
"""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):
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):
"""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):
"""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):
"""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. _handle_add_schematic_junction — unit tests
# ---------------------------------------------------------------------------
class TestHandleAddSchematicJunction:
@pytest.fixture(autouse=True)
def setup(self):
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):
result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]})
assert result["success"] is False
assert "Schematic path" in result["message"]
def test_missing_position(self):
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False
assert "Position" in result["message"]
@patch("commands.wire_manager.WireManager.add_junction", return_value=True)
def test_success(self, mock_jct):
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(sch),
"position": [25.4, 25.4],
}
)
assert result["success"] is True
assert "Junction added" in result["message"]
mock_jct.assert_called_once_with(sch, [25.4, 25.4])
finally:
shutil.rmtree(sch.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_junction", return_value=False)
def test_failure(self, _):
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(sch),
"position": [25.4, 25.4],
}
)
assert result["success"] is False
assert "Failed" in result["message"]
finally:
shutil.rmtree(sch.parent, ignore_errors=True)
# ---------------------------------------------------------------------------
# 7. ConnectionManager — orphaned methods removed
# ---------------------------------------------------------------------------
class TestConnectionManagerOrphanedMethodsRemoved:
def test_add_wire_removed(self):
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):
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):
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):
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_writes_wire_element(self, sch):
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):
"""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_add_junction_writes_junction_element(self, sch):
from commands.wire_manager import WireManager
ok = WireManager.add_junction(sch, [25.4, 25.4])
assert ok is True
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
# Verify position
at = junctions[0][1] # (at x y)
assert at[1] == 25.4
assert at[2] == 25.4
def test_wire_endpoint_coordinates_match(self, sch):
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):
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_junction_handler_writes_junction(self):
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(self.sch),
"position": [50.8, 50.8],
}
)
assert result["success"] is True
data = _parse_sch(self.sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_wire_handler_two_points_writes_wire(self):
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):
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, py, x1, y1, x2, y2, eps=1e-6):
from commands.wire_manager import WireManager
return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps)
def test_horizontal_midpoint(self):
assert self._fn(5, 0, 0, 0, 10, 0) is True
def test_vertical_midpoint(self):
assert self._fn(0, 5, 0, 0, 0, 10) is True
def test_horizontal_at_start_endpoint(self):
"""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):
"""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):
assert self._fn(0, 0, 0, 0, 0, 10) is False
def test_vertical_at_end_endpoint(self):
assert self._fn(0, 10, 0, 0, 0, 10) is False
def test_point_off_horizontal_wire(self):
"""Point above a horizontal wire."""
assert self._fn(5, 1, 0, 0, 10, 0) is False
def test_point_off_vertical_wire(self):
"""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):
"""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):
"""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):
"""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):
"""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):
"""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):
"""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):
"""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):
from commands.wire_manager import WireManager
return WireManager._parse_wire(item)
def test_valid_wire(self):
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):
junction = [Symbol("junction"), [Symbol("at"), 10, 20]]
assert TestParseWire._fn(junction) is None
def test_non_list_returns_none(self):
assert TestParseWire._fn("not a list") is None
def test_empty_list_returns_none(self):
assert TestParseWire._fn([]) is None
def test_wire_with_no_pts_returns_none(self):
wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]]
assert TestParseWire._fn(wire) is None
def test_wire_with_only_one_xy_returns_none(self):
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):
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):
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):
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):
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):
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):
"""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):
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):
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):
"""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):
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):
"""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):
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):
"""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):
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/add_junction."""
@pytest.fixture(autouse=True)
def sch(self):
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_breaks_existing_horizontal_wire(self, sch):
"""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):
"""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_add_junction_breaks_wire(self, sch):
"""Adding a junction mid-wire should split that wire."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [30, 0])
WireManager.add_junction(sch, [15, 0])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}"
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_add_junction_at_wire_endpoint_no_split(self, sch):
"""Junction at wire endpoint should not split it."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [20, 0])
WireManager.add_junction(sch, [20, 0])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
def test_polyline_breaks_existing_wire(self, sch):
"""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):
"""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