test_handler_logs_warning_on_collision relied on pytest's caplog, but the package calls logging.basicConfig(..., force=True) at import time (the rotating-file logging added in #181). force=True detaches the root handler that caplog reads from, so the emitted warning was routed to the stderr/file handlers but never reached caplog.records — the final assertion failed depending on import/handler ordering. Capture on the "kicad_interface" logger with a test-owned handler instead of relying on caplog, and disable propagation for the duration so the warning no longer leaks to stderr. Logger level and propagation are restored in a finally block. Behaviour under test is unchanged; the assertion is now deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1029 lines
39 KiB
Python
1029 lines
39 KiB
Python
"""
|
|
Tests for move_schematic_component with wire preservation (WireDragger).
|
|
|
|
Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed.
|
|
Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler.
|
|
"""
|
|
|
|
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
|
|
|
|
# Make python/ importable
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
|
|
from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate
|
|
|
|
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _sym(name: str) -> Symbol:
|
|
return Symbol(name)
|
|
|
|
|
|
def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any:
|
|
return [
|
|
_sym("wire"),
|
|
[_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]],
|
|
[_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]],
|
|
[_sym("uuid"), "00000000-0000-0000-0000-000000000000"],
|
|
]
|
|
|
|
|
|
def _make_junction(x: Any, y: Any) -> Any:
|
|
return [
|
|
_sym("junction"),
|
|
[_sym("at"), x, y],
|
|
[_sym("diameter"), 0],
|
|
[_sym("color"), 0, 0, 0, 0],
|
|
[_sym("uuid"), "00000000-0000-0000-0000-000000000001"],
|
|
]
|
|
|
|
|
|
def _make_symbol(
|
|
ref: Any, x: Any, y: Any, rotation: Any = 0, lib_id: str = "Device:R", mirror: Any = None
|
|
) -> Any:
|
|
"""Build a minimal placed-symbol s-expression."""
|
|
item = [
|
|
_sym("symbol"),
|
|
[_sym("lib_id"), lib_id],
|
|
[_sym("at"), x, y, rotation],
|
|
[_sym("unit"), 1],
|
|
[_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]],
|
|
[_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]],
|
|
]
|
|
if mirror:
|
|
item.append([_sym("mirror"), _sym(mirror)])
|
|
return item
|
|
|
|
|
|
def _make_lib_symbol_r() -> Any:
|
|
"""Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81)."""
|
|
return [
|
|
_sym("symbol"),
|
|
"Device:R",
|
|
[
|
|
_sym("symbol"),
|
|
"R_1_1",
|
|
[
|
|
_sym("pin"),
|
|
_sym("passive"),
|
|
_sym("line"),
|
|
[_sym("at"), 0, 3.81, 270],
|
|
[_sym("length"), 1.27],
|
|
[
|
|
_sym("name"),
|
|
"~",
|
|
[_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
|
|
],
|
|
[
|
|
_sym("number"),
|
|
"1",
|
|
[_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
|
|
],
|
|
],
|
|
[
|
|
_sym("pin"),
|
|
_sym("passive"),
|
|
_sym("line"),
|
|
[_sym("at"), 0, -3.81, 90],
|
|
[_sym("length"), 1.27],
|
|
[
|
|
_sym("name"),
|
|
"~",
|
|
[_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
|
|
],
|
|
[
|
|
_sym("number"),
|
|
"2",
|
|
[_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
|
|
],
|
|
],
|
|
],
|
|
]
|
|
|
|
|
|
def _make_sch_data(extra_items: Any = None) -> Any:
|
|
"""Build a minimal sch_data list with lib_symbols and sheet_instances."""
|
|
data = [
|
|
_sym("kicad_sch"),
|
|
[_sym("lib_symbols"), _make_lib_symbol_r()],
|
|
[_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]],
|
|
]
|
|
if extra_items:
|
|
# Insert before sheet_instances (last item)
|
|
for item in extra_items:
|
|
data.insert(len(data) - 1, item)
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestRotatePoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRotatePoint:
|
|
def test_zero_rotation(self) -> None:
|
|
assert _rotate(1.0, 2.0, 0) == (1.0, 2.0)
|
|
|
|
def test_90_degrees(self) -> None:
|
|
rx, ry = _rotate(1.0, 0.0, 90)
|
|
assert abs(rx - 0.0) < 1e-9
|
|
assert abs(ry - 1.0) < 1e-9
|
|
|
|
def test_180_degrees(self) -> None:
|
|
rx, ry = _rotate(1.0, 0.0, 180)
|
|
assert abs(rx - (-1.0)) < 1e-9
|
|
assert abs(ry - 0.0) < 1e-9
|
|
|
|
def test_270_degrees(self) -> None:
|
|
rx, ry = _rotate(0.0, 1.0, 270)
|
|
assert abs(rx - 1.0) < 1e-6
|
|
assert abs(ry - 0.0) < 1e-6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestFindSymbol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFindSymbol:
|
|
def test_returns_none_for_missing_reference(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 10, 20)])
|
|
assert WireDragger.find_symbol(sch, "R99") is None
|
|
|
|
def test_returns_item_and_position(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)])
|
|
result = WireDragger.find_symbol(sch, "R1")
|
|
assert result is not None
|
|
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result
|
|
assert abs(old_x - 10.5) < EPS
|
|
assert abs(old_y - 20.5) < EPS
|
|
assert abs(rotation - 90) < EPS
|
|
assert lib_id == "Device:R"
|
|
assert mirror_x is False
|
|
assert mirror_y is False
|
|
|
|
def test_detects_mirror_x(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")])
|
|
result = WireDragger.find_symbol(sch, "R1")
|
|
assert result is not None
|
|
assert result[5] is True # mirror_x
|
|
assert result[6] is False # mirror_y
|
|
|
|
def test_detects_mirror_y(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")])
|
|
result = WireDragger.find_symbol(sch, "R1")
|
|
assert result is not None
|
|
assert result[5] is False # mirror_x
|
|
assert result[6] is True # mirror_y
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestComputePinPositions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestComputePinPositions:
|
|
def test_resistor_at_origin_no_rotation(self) -> None:
|
|
"""Device:R at (0, 0) rot=0. Lib pins are Y-up; schematic is Y-down,
|
|
so pin 1 (lib y=+3.81) lands at world y=-3.81 and pin 2 (lib y=-3.81)
|
|
at world y=+3.81. See test_pin_locator_y_flip for the canonical case."""
|
|
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
|
|
positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
|
|
assert "1" in positions and "2" in positions
|
|
old1, new1 = positions["1"]
|
|
old2, new2 = positions["2"]
|
|
# Pin 1 old: (0, 0 - 3.81)
|
|
assert abs(old1[0] - 0) < 1e-4
|
|
assert abs(old1[1] - (-3.81)) < 1e-4
|
|
# Pin 2 old: (0, 0 + 3.81)
|
|
assert abs(old2[0] - 0) < 1e-4
|
|
assert abs(old2[1] - 3.81) < 1e-4
|
|
# New positions shifted by (10, 20)
|
|
assert abs(new1[0] - 10) < 1e-4
|
|
assert abs(new1[1] - 16.19) < 1e-4
|
|
assert abs(new2[0] - 10) < 1e-4
|
|
assert abs(new2[1] - 23.81) < 1e-4
|
|
|
|
def test_resistor_rotated_90(self) -> None:
|
|
"""Device:R at (100, 100) rot=90. Pin 1 lib (0, +3.81), Y-flip → (0, -3.81),
|
|
eeschema rot=90 TRANSFORM(0,1,-1,0): (0*0+1*-3.81, -1*0+0*-3.81) = (-3.81, 0).
|
|
World (96.19, 100). Verified vs kicad-cli netlist."""
|
|
sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)])
|
|
positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100)
|
|
old1, _ = positions["1"]
|
|
old2, _ = positions["2"]
|
|
assert abs(old1[0] - 96.19) < 1e-3
|
|
assert abs(old1[1] - 100) < 1e-3
|
|
|
|
def test_returns_empty_for_missing_component(self) -> None:
|
|
sch = _make_sch_data()
|
|
result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0)
|
|
assert result == {}
|
|
|
|
def test_delta_is_consistent(self) -> None:
|
|
"""new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation."""
|
|
sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)])
|
|
positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70)
|
|
for pin_num, (old_xy, new_xy) in positions.items():
|
|
dx = new_xy[0] - old_xy[0]
|
|
dy = new_xy[1] - old_xy[1]
|
|
assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}"
|
|
assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestDragWires
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDragWires:
|
|
def test_no_wires_returns_zero_counts(self) -> None:
|
|
sch = _make_sch_data()
|
|
result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)})
|
|
assert result["endpoints_moved"] == 0
|
|
assert result["wires_removed"] == 0
|
|
|
|
def test_wire_start_endpoint_moved(self) -> None:
|
|
wire = _make_wire(0, 3.81, 0, 10)
|
|
sch = _make_sch_data([wire])
|
|
result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
|
|
assert result["endpoints_moved"] == 1
|
|
assert result["wires_removed"] == 0
|
|
# Find the updated wire in sch_data
|
|
updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire"))
|
|
pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts"))
|
|
xy1 = pts[1]
|
|
assert abs(xy1[1] - 10.0) < EPS
|
|
assert abs(xy1[2] - 23.81) < EPS
|
|
|
|
def test_wire_end_endpoint_moved(self) -> None:
|
|
wire = _make_wire(0, 10, 0, -3.81)
|
|
sch = _make_sch_data([wire])
|
|
result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)})
|
|
assert result["endpoints_moved"] == 1
|
|
updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire"))
|
|
pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts"))
|
|
xy2 = pts[2]
|
|
assert abs(xy2[1] - 10.0) < EPS
|
|
assert abs(xy2[2] - 16.19) < EPS
|
|
|
|
def test_zero_length_wire_removed(self) -> None:
|
|
"""When both endpoints of a wire are moved to the same point, wire is deleted."""
|
|
wire = _make_wire(0, 3.81, 0, -3.81)
|
|
sch = _make_sch_data([wire])
|
|
# Both pins land at same position (degenerate move)
|
|
result = WireDragger.drag_wires(
|
|
sch,
|
|
{
|
|
(0.0, 3.81): (5.0, 5.0),
|
|
(0.0, -3.81): (5.0, 5.0),
|
|
},
|
|
)
|
|
assert result["wires_removed"] == 1
|
|
wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")]
|
|
assert len(wires_remaining) == 0
|
|
|
|
def test_unrelated_wire_not_touched(self) -> None:
|
|
"""A wire whose endpoints don't match any old pin is not changed."""
|
|
wire = _make_wire(50, 50, 60, 50)
|
|
sch = _make_sch_data([wire])
|
|
original_start = (50.0, 50.0)
|
|
result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
|
|
assert result["endpoints_moved"] == 0
|
|
updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire"))
|
|
pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts"))
|
|
xy1 = pts[1]
|
|
assert abs(xy1[1] - 50.0) < EPS
|
|
assert abs(xy1[2] - 50.0) < EPS
|
|
|
|
def test_both_endpoints_on_moved_component(self) -> None:
|
|
"""Wire connecting two pins of same component — both endpoints shift together."""
|
|
wire = _make_wire(0, 3.81, 0, -3.81)
|
|
sch = _make_sch_data([wire])
|
|
result = WireDragger.drag_wires(
|
|
sch,
|
|
{
|
|
(0.0, 3.81): (10.0, 23.81),
|
|
(0.0, -3.81): (10.0, 16.19),
|
|
},
|
|
)
|
|
assert result["endpoints_moved"] == 2
|
|
assert result["wires_removed"] == 0
|
|
|
|
def test_junction_moved_with_endpoint(self) -> None:
|
|
junction = _make_junction(0, 3.81)
|
|
sch = _make_sch_data([junction])
|
|
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
|
|
updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction"))
|
|
at_sub = next(
|
|
s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at")
|
|
)
|
|
assert abs(at_sub[1] - 10.0) < EPS
|
|
assert abs(at_sub[2] - 23.81) < EPS
|
|
|
|
def test_junction_at_unrelated_position_not_touched(self) -> None:
|
|
junction = _make_junction(99, 99)
|
|
sch = _make_sch_data([junction])
|
|
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
|
|
updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction"))
|
|
at_sub = next(
|
|
s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at")
|
|
)
|
|
assert abs(at_sub[1] - 99.0) < EPS
|
|
assert abs(at_sub[2] - 99.0) < EPS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestUpdateSymbolPosition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUpdateSymbolPosition:
|
|
def test_updates_position(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 10, 20)])
|
|
result = WireDragger.update_symbol_position(sch, "R1", 30, 40)
|
|
assert result is True
|
|
found = WireDragger.find_symbol(sch, "R1")
|
|
assert abs(found[1] - 30) < EPS
|
|
assert abs(found[2] - 40) < EPS
|
|
|
|
def test_returns_false_for_missing(self) -> None:
|
|
sch = _make_sch_data()
|
|
assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False
|
|
|
|
def test_preserves_rotation(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)])
|
|
WireDragger.update_symbol_position(sch, "R1", 30, 40)
|
|
found = WireDragger.find_symbol(sch, "R1")
|
|
assert abs(found[3] - 90) < EPS # rotation preserved
|
|
|
|
def test_property_labels_follow_symbol_move(self) -> None:
|
|
"""Property (at ...) positions must shift by the same delta as the symbol."""
|
|
sym = _make_symbol("R1", 100, 80)
|
|
sch = _make_sch_data([sym])
|
|
|
|
# Record initial property positions
|
|
prop_k = _sym("property")
|
|
at_k = _sym("at")
|
|
initial_positions = {}
|
|
for sub in sym[1:]:
|
|
if isinstance(sub, list) and sub and sub[0] == prop_k:
|
|
name = sub[1]
|
|
for psub in sub[2:]:
|
|
if isinstance(psub, list) and psub and psub[0] == at_k:
|
|
initial_positions[name] = (psub[1], psub[2])
|
|
break
|
|
assert len(initial_positions) >= 2 # Reference and Value at minimum
|
|
|
|
# Move component from (100, 80) to (120, 100) — delta (20, 20)
|
|
result = WireDragger.update_symbol_position(sch, "R1", 120, 100)
|
|
assert result is True
|
|
|
|
# Verify each property shifted by (20, 20)
|
|
for sub in sym[1:]:
|
|
if isinstance(sub, list) and sub and sub[0] == prop_k:
|
|
name = sub[1]
|
|
for psub in sub[2:]:
|
|
if isinstance(psub, list) and psub and psub[0] == at_k:
|
|
expected_x = initial_positions[name][0] + 20
|
|
expected_y = initial_positions[name][1] + 20
|
|
assert (
|
|
abs(psub[1] - expected_x) < EPS
|
|
), f"{name} x: expected {expected_x}, got {psub[1]}"
|
|
assert (
|
|
abs(psub[2] - expected_y) < EPS
|
|
), f"{name} y: expected {expected_y}, got {psub[2]}"
|
|
break
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestMoveWithWirePreservation:
|
|
"""Integration tests using a real .kicad_sch file."""
|
|
|
|
def _make_schematic(self, extra_sexp: Any = "") -> Any:
|
|
"""Copy empty.kicad_sch to a temp file and optionally append content."""
|
|
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
|
shutil.copy(TEMPLATE_PATH, tmp)
|
|
if extra_sexp:
|
|
content = tmp.read_text(encoding="utf-8")
|
|
idx = content.rfind(")")
|
|
content = content[:idx] + "\n" + extra_sexp + "\n)"
|
|
tmp.write_text(content, encoding="utf-8")
|
|
return tmp
|
|
|
|
def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path:
|
|
"""Append a Device:R symbol to the schematic file."""
|
|
import uuid
|
|
|
|
u = str(uuid.uuid4())
|
|
sexp = f"""
|
|
(symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
|
|
(in_bom yes) (on_board yes) (dnp no)
|
|
(uuid "{u}")
|
|
(property "Reference" "{ref}" (at {x + 2.032} {y} 90)
|
|
(effects (font (size 1.27 1.27)))
|
|
)
|
|
(property "Value" "10k" (at {x} {y} 90)
|
|
(effects (font (size 1.27 1.27)))
|
|
)
|
|
(property "Footprint" "" (at {x - 1.778} {y} 90)
|
|
(effects (font (size 1.27 1.27)) hide)
|
|
)
|
|
(property "Datasheet" "~" (at {x} {y} 0)
|
|
(effects (font (size 1.27 1.27)) hide)
|
|
)
|
|
(pin "1" (uuid "{uuid.uuid4()}"))
|
|
(pin "2" (uuid "{uuid.uuid4()}"))
|
|
(instances (project "test" (path "/" (reference "{ref}") (unit 1))))
|
|
)"""
|
|
content = path.read_text(encoding="utf-8")
|
|
idx = content.rfind(")")
|
|
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
|
|
return path
|
|
|
|
def _add_wire(self, path: Path, x1: float, y1: float, x2: float, y2: float) -> Path:
|
|
"""Append a wire to the schematic file."""
|
|
import uuid
|
|
|
|
wire_sexp = f"""
|
|
(wire (pts (xy {x1} {y1}) (xy {x2} {y2}))
|
|
(stroke (width 0) (type default))
|
|
(uuid "{uuid.uuid4()}")
|
|
)"""
|
|
content = path.read_text(encoding="utf-8")
|
|
idx = content.rfind(")")
|
|
path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
|
|
return path
|
|
|
|
def _parse_wires(self, path: Path) -> Any:
|
|
"""Return list of ((x1,y1),(x2,y2)) for every wire in the file."""
|
|
content = path.read_text(encoding="utf-8")
|
|
data = sexpdata.loads(content)
|
|
wires = []
|
|
for item in data:
|
|
if not (isinstance(item, list) and item and item[0] == Symbol("wire")):
|
|
continue
|
|
pts = next(
|
|
(s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")),
|
|
None,
|
|
)
|
|
if pts is None:
|
|
continue
|
|
xys = [
|
|
p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
|
|
]
|
|
if len(xys) >= 2:
|
|
wires.append(
|
|
(
|
|
(float(xys[0][1]), float(xys[0][2])),
|
|
(float(xys[-1][1]), float(xys[-1][2])),
|
|
)
|
|
)
|
|
return wires
|
|
|
|
def _get_symbol_pos(self, path: Path, ref: str) -> Any:
|
|
content = path.read_text(encoding="utf-8")
|
|
data = sexpdata.loads(content)
|
|
found = WireDragger.find_symbol(data, ref)
|
|
if found is None:
|
|
return None
|
|
return found[1], found[2]
|
|
|
|
def test_symbol_position_updated(self) -> None:
|
|
sch = self._make_schematic()
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
# Call handler directly
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 120, "y": 130},
|
|
},
|
|
)
|
|
assert result["success"], result.get("message")
|
|
pos = self._get_symbol_pos(sch, "R1")
|
|
assert abs(pos[0] - 120) < EPS
|
|
assert abs(pos[1] - 130) < EPS
|
|
|
|
def test_connected_wire_endpoint_follows_pin(self) -> None:
|
|
"""Wire endpoint at pin 1 of R1 should move with the component."""
|
|
sch = self._make_schematic()
|
|
# R1 at (100, 100) — pin 1 at (100, 103.81)
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 100},
|
|
},
|
|
)
|
|
assert result["success"], result.get("message")
|
|
assert result["wiresMoved"] >= 1
|
|
|
|
wires = self._parse_wires(sch)
|
|
assert len(wires) == 1
|
|
# Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81)
|
|
w = wires[0]
|
|
endpoints = {w[0], w[1]}
|
|
new_pin1 = (110.0, 103.81)
|
|
assert any(
|
|
abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints
|
|
), f"Expected pin endpoint near {new_pin1}, got {endpoints}"
|
|
|
|
def test_unrelated_wire_unchanged(self) -> None:
|
|
"""A wire not connected to R1 must not be modified."""
|
|
sch = self._make_schematic()
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
self._add_wire(sch, 50, 50, 60, 50) # unrelated wire
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 110},
|
|
},
|
|
)
|
|
|
|
wires = self._parse_wires(sch)
|
|
unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01]
|
|
assert len(unrelated) == 1
|
|
|
|
def test_no_zero_length_wires_after_move(self) -> None:
|
|
"""No zero-length wires should appear in the file after a move."""
|
|
sch = self._make_schematic()
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
# Wire from pin 1 to pin 2 of same component (intra-component wire)
|
|
self._add_wire(sch, 100, 103.81, 100, 96.19)
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 100},
|
|
},
|
|
)
|
|
|
|
wires = self._parse_wires(sch)
|
|
for start, end in wires:
|
|
assert not (
|
|
abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS
|
|
), f"Zero-length wire found at {start}"
|
|
|
|
def test_preserve_wires_false_skips_wire_update(self) -> None:
|
|
"""preserveWires=False should move the symbol but leave wires alone."""
|
|
sch = self._make_schematic()
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
self._add_wire(sch, 100, 103.81, 100, 120)
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 100},
|
|
"preserveWires": False,
|
|
},
|
|
)
|
|
assert result["success"]
|
|
assert result["wiresMoved"] == 0
|
|
|
|
# Wire should still start at old pin position
|
|
wires = self._parse_wires(sch)
|
|
assert len(wires) == 1
|
|
endpoints = {wires[0][0], wires[0][1]}
|
|
old_pin1 = (100.0, 103.81)
|
|
assert any(
|
|
abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints
|
|
), f"Wire should still be at {old_pin1}, got {endpoints}"
|
|
|
|
def test_missing_component_returns_error(self) -> None:
|
|
sch = self._make_schematic()
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "NOTHERE",
|
|
"position": {"x": 0, "y": 0},
|
|
},
|
|
)
|
|
assert not result["success"]
|
|
assert "not found" in result.get("message", "").lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestSynthesizeTouchingPinWires (unit)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSynthesizeTouchingPinWires:
|
|
"""Unit tests for WireDragger.synthesize_touching_pin_wires."""
|
|
|
|
def _make_two_resistors(self, r1_x: Any, r1_y: Any, r2_x: Any, r2_y: Any) -> Any:
|
|
"""Build sch_data with R1 and R2, each Device:R."""
|
|
return _make_sch_data(
|
|
[
|
|
_make_symbol("R1", r1_x, r1_y),
|
|
_make_symbol("R2", r2_x, r2_y),
|
|
]
|
|
)
|
|
|
|
def test_no_stationary_symbols_returns_zero(self) -> None:
|
|
"""With only the moved component in sch_data, nothing is synthesized."""
|
|
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
|
|
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
|
|
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
|
assert count == 0
|
|
|
|
def test_touching_pin_gap_generates_wire(self) -> None:
|
|
"""
|
|
With Y-flip applied (lib Y-up → schematic Y-down):
|
|
R1 at (0, 0) pin2 (lib y=-3.81) lands at world (0, +3.81).
|
|
R2 at (0, +7.62) pin1 (lib y=+3.81) lands at world (0, +3.81). ← pins touch
|
|
Moving R1 to (10, 0) drags pin2 to (10, +3.81).
|
|
A wire from (0, +3.81) to (10, +3.81) should be synthesized.
|
|
"""
|
|
sch = self._make_two_resistors(0, 0, 0, 7.62)
|
|
|
|
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
|
|
old2, new2 = pin_positions["2"]
|
|
assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - 3.81) < 1e-3
|
|
assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - 3.81) < 1e-3
|
|
|
|
wire_count_before = sum(
|
|
1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire")
|
|
)
|
|
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
|
assert count == 1, f"Expected 1 synthesized wire, got {count}"
|
|
|
|
wires = [
|
|
item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire")
|
|
]
|
|
assert len(wires) == wire_count_before + 1
|
|
|
|
new_wire = wires[-1]
|
|
pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts"))
|
|
xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")]
|
|
assert len(xys) == 2
|
|
endpoints = {
|
|
(round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)),
|
|
(round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)),
|
|
}
|
|
assert (0.0, 3.81) in endpoints, f"Expected (0, 3.81) in wire endpoints, got {endpoints}"
|
|
assert (
|
|
10.0,
|
|
3.81,
|
|
) in endpoints, f"Expected (10, 3.81) in wire endpoints, got {endpoints}"
|
|
|
|
def test_no_wire_when_pin_didnt_move(self) -> None:
|
|
"""
|
|
If old_xy == new_xy for a touching pin (component moved but this pin stayed put),
|
|
no wire should be synthesized.
|
|
"""
|
|
# R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81)
|
|
sch = self._make_two_resistors(0, 0, 0, -7.62)
|
|
# Moving R1 to (0, 0) — effectively no move, same position
|
|
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0)
|
|
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
|
assert count == 0
|
|
|
|
def test_no_wire_when_rejoins_other_stationary_pin(self) -> None:
|
|
"""
|
|
If the moved pin's new position coincides with another stationary pin,
|
|
no wire should be synthesized (they touch again).
|
|
"""
|
|
# R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62)
|
|
# R1 pin2 was touching R2 pin1 at (0, -3.81).
|
|
# Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1.
|
|
sch = _make_sch_data(
|
|
[
|
|
_make_symbol("R1", 0, 0),
|
|
_make_symbol("R2", 0, -7.62),
|
|
_make_symbol("R3", 10, -7.62),
|
|
]
|
|
)
|
|
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
|
|
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
|
assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}"
|
|
|
|
def test_empty_pin_positions_returns_zero(self) -> None:
|
|
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
|
|
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {})
|
|
assert count == 0
|
|
|
|
def test_non_touching_pins_not_affected(self) -> None:
|
|
"""
|
|
When R1 and R2 are NOT touching (different positions), no wire is synthesized.
|
|
"""
|
|
# R1 at (0, 0), R2 at (100, 100) — far apart
|
|
sch = self._make_two_resistors(0, 0, 100, 100)
|
|
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
|
|
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
|
assert count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOldToNewCollision:
|
|
"""Verify that coincident pins do not silently overwrite each other in old_to_new."""
|
|
|
|
def test_handler_logs_warning_on_collision(self) -> None:
|
|
"""
|
|
When two pins share the same old position, a warning should be logged
|
|
and the *first* mapping should be kept (not overwritten by the second).
|
|
"""
|
|
import logging
|
|
|
|
# Capture on the "kicad_interface" logger with our own handler rather
|
|
# than pytest's ``caplog``: the package calls
|
|
# ``logging.basicConfig(..., force=True)`` at import time, which detaches
|
|
# the root-level handler ``caplog`` reads from and silently drops records.
|
|
records = []
|
|
|
|
class _Capture(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
records.append(record)
|
|
|
|
kicad_logger = logging.getLogger("kicad_interface")
|
|
handler = _Capture(level=logging.WARNING)
|
|
prev_level = kicad_logger.level
|
|
prev_propagate = kicad_logger.propagate
|
|
kicad_logger.addHandler(handler)
|
|
kicad_logger.setLevel(logging.WARNING)
|
|
kicad_logger.propagate = False # keep the warning out of stderr/file handlers
|
|
try:
|
|
# Build a fake pin_positions dict with a deliberate collision
|
|
pin_positions = {
|
|
"1": ((0.0, 3.81), (10.0, 23.81)),
|
|
"2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1"
|
|
}
|
|
|
|
old_to_new = {}
|
|
for _pin, (old_xy, new_xy) in pin_positions.items():
|
|
if old_xy in old_to_new:
|
|
kicad_logger.warning(
|
|
f"move_schematic_component: pin {_pin!r} shares old position {old_xy} "
|
|
f"with another pin; keeping first entry, skipping duplicate"
|
|
)
|
|
continue
|
|
old_to_new[old_xy] = new_xy
|
|
finally:
|
|
kicad_logger.removeHandler(handler)
|
|
kicad_logger.setLevel(prev_level)
|
|
kicad_logger.propagate = prev_propagate
|
|
|
|
# Only one entry should exist, and it should be the first one
|
|
assert len(old_to_new) == 1
|
|
assert old_to_new[(0.0, 3.81)] == (10.0, 23.81)
|
|
# Warning should have been logged
|
|
assert any("skipping duplicate" in r.getMessage() for r in records)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestTouchingPinIntegration (integration)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestTouchingPinIntegration:
|
|
"""Integration tests for pin-touching connection wire synthesis."""
|
|
|
|
def _make_schematic(self, extra_sexp: Any = "") -> Any:
|
|
"""Copy empty.kicad_sch to a temp file."""
|
|
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
|
shutil.copy(TEMPLATE_PATH, tmp)
|
|
if extra_sexp:
|
|
content = tmp.read_text(encoding="utf-8")
|
|
idx = content.rfind(")")
|
|
content = content[:idx] + "\n" + extra_sexp + "\n)"
|
|
tmp.write_text(content, encoding="utf-8")
|
|
return tmp
|
|
|
|
def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path:
|
|
import uuid as _uuid
|
|
|
|
u = str(_uuid.uuid4())
|
|
sexp = f"""
|
|
(symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
|
|
(in_bom yes) (on_board yes) (dnp no)
|
|
(uuid "{u}")
|
|
(property "Reference" "{ref}" (at {x + 2.032} {y} 90)
|
|
(effects (font (size 1.27 1.27)))
|
|
)
|
|
(property "Value" "10k" (at {x} {y} 90)
|
|
(effects (font (size 1.27 1.27)))
|
|
)
|
|
(property "Footprint" "" (at {x - 1.778} {y} 90)
|
|
(effects (font (size 1.27 1.27)) hide)
|
|
)
|
|
(property "Datasheet" "~" (at {x} {y} 0)
|
|
(effects (font (size 1.27 1.27)) hide)
|
|
)
|
|
(pin "1" (uuid "{_uuid.uuid4()}"))
|
|
(pin "2" (uuid "{_uuid.uuid4()}"))
|
|
(instances (project "test" (path "/" (reference "{ref}") (unit 1))))
|
|
)"""
|
|
content = path.read_text(encoding="utf-8")
|
|
idx = content.rfind(")")
|
|
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
|
|
return path
|
|
|
|
def _parse_wires(self, path: Path) -> Any:
|
|
content = path.read_text(encoding="utf-8")
|
|
data = sexpdata.loads(content)
|
|
wires = []
|
|
for item in data:
|
|
if not (isinstance(item, list) and item and item[0] == Symbol("wire")):
|
|
continue
|
|
pts = next(
|
|
(s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")),
|
|
None,
|
|
)
|
|
if pts is None:
|
|
continue
|
|
xys = [
|
|
p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
|
|
]
|
|
if len(xys) >= 2:
|
|
wires.append(
|
|
(
|
|
(float(xys[0][1]), float(xys[0][2])),
|
|
(float(xys[-1][1]), float(xys[-1][2])),
|
|
)
|
|
)
|
|
return wires
|
|
|
|
def test_touching_pin_wire_created_on_move(self) -> None:
|
|
"""
|
|
R1 at (100, 100) and R2 at (100, 92.38) share a touching pin:
|
|
R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19).
|
|
Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19).
|
|
"""
|
|
sch = self._make_schematic()
|
|
# R1 pin2 world position = 100 + (-3.81) = 96.19
|
|
# R2 pin1 world position = 92.38 + 3.81 = 96.19
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
self._add_resistor(sch, "R2", 100, 92.38)
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 100},
|
|
},
|
|
)
|
|
assert result["success"], result.get("message")
|
|
assert (
|
|
result.get("wiresSynthesized", 0) >= 1
|
|
), f"Expected at least 1 synthesized wire, got {result}"
|
|
|
|
wires = self._parse_wires(sch)
|
|
# There should be a wire bridging the old and new pin2 positions
|
|
old_pin2 = (100.0, 96.19)
|
|
new_pin2 = (110.0, 96.19)
|
|
bridging = [
|
|
(s, e)
|
|
for s, e in wires
|
|
if (
|
|
(
|
|
abs(s[0] - old_pin2[0]) < 0.05
|
|
and abs(s[1] - old_pin2[1]) < 0.05
|
|
and abs(e[0] - new_pin2[0]) < 0.05
|
|
and abs(e[1] - new_pin2[1]) < 0.05
|
|
)
|
|
or (
|
|
abs(e[0] - old_pin2[0]) < 0.05
|
|
and abs(e[1] - old_pin2[1]) < 0.05
|
|
and abs(s[0] - new_pin2[0]) < 0.05
|
|
and abs(s[1] - new_pin2[1]) < 0.05
|
|
)
|
|
)
|
|
]
|
|
assert (
|
|
len(bridging) >= 1
|
|
), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}"
|
|
|
|
def test_no_wire_synthesized_when_no_touching_pins(self) -> None:
|
|
"""
|
|
Two resistors with no pin overlap should not generate any synthesized wires.
|
|
"""
|
|
sch = self._make_schematic()
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
self._add_resistor(sch, "R2", 150, 150) # far away
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 100},
|
|
},
|
|
)
|
|
assert result["success"], result.get("message")
|
|
assert result.get("wiresSynthesized", 0) == 0
|
|
|
|
def test_existing_wires_still_dragged_with_touching_pins(self) -> None:
|
|
"""
|
|
When R1 has both an explicit wire AND a touching-pin connection,
|
|
both should be handled: the wire dragged and the touching-pin bridged.
|
|
"""
|
|
sch = self._make_schematic()
|
|
# R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2
|
|
self._add_resistor(sch, "R1", 100, 100)
|
|
self._add_resistor(sch, "R2", 100, 92.38)
|
|
|
|
# Also add an explicit wire at pin1 of R1 (100, 103.81) going up
|
|
import uuid as _uuid
|
|
|
|
wire_sexp = f"""
|
|
(wire (pts (xy 100 103.81) (xy 100 115))
|
|
(stroke (width 0) (type default))
|
|
(uuid "{_uuid.uuid4()}")
|
|
)"""
|
|
content = sch.read_text(encoding="utf-8")
|
|
idx = content.rfind(")")
|
|
sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
|
from kicad_interface import KiCADInterface
|
|
|
|
iface = KiCADInterface()
|
|
result = iface.handle_command(
|
|
"move_schematic_component",
|
|
{
|
|
"schematicPath": str(sch),
|
|
"reference": "R1",
|
|
"position": {"x": 110, "y": 100},
|
|
},
|
|
)
|
|
assert result["success"], result.get("message")
|
|
assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged"
|
|
assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire"
|