kicad-mcp/tests/test_schematic_edit.py
Ryan Malloy 61ed7b3efe Add wire auditing, bulk wire removal, and net-to-pin verification tools
Refactors _build_connectivity() into a two-layer state builder so the
union-find internals (pin_at, label_at, wire_segments) are accessible
to new analysis tools without duplicating the 200-line connectivity engine.

New tools:
- audit_wiring: trace all wires connected to a component, report per-pin
  net membership with wire segment coordinates and connected pins
- remove_wires_by_criteria: bulk-remove wires by coordinate filters
  (y, x, min/max ranges, tolerance) with dry_run preview support
- verify_connectivity: compare actual wiring against an expected
  net-to-pin mapping, report matches/mismatches/missing nets

New sexp_parser utilities:
- parse_wire_segments: extract (wire ...) blocks with start/end/uuid
- remove_sexp_blocks_by_uuid: atomically remove blocks by UUID set
2026-03-04 23:12:13 -07:00

347 lines
10 KiB
Python

"""Tests for schematic editing tools."""
import os
import tempfile
import pytest
from tests.conftest import requires_sch_api
@requires_sch_api
@pytest.mark.unit
class TestModifyComponent:
"""Tests for the modify_component tool."""
def test_modify_value(self, populated_schematic):
from mckicad.tools.schematic_edit import modify_component
result = modify_component(
schematic_path=populated_schematic,
reference="R1",
value="22k",
)
assert result["success"] is True
assert any("value" in m for m in result["modified"])
def test_modify_nonexistent_component(self, populated_schematic):
from mckicad.tools.schematic_edit import modify_component
result = modify_component(
schematic_path=populated_schematic,
reference="Z99",
value="1k",
)
assert result["success"] is False
assert "error" in result
def test_modify_no_changes(self, populated_schematic):
from mckicad.tools.schematic_edit import modify_component
result = modify_component(
schematic_path=populated_schematic,
reference="R1",
)
assert result["success"] is True
assert result.get("modified") == []
@requires_sch_api
@pytest.mark.unit
class TestRemoveComponent:
"""Tests for the remove_component tool."""
def test_remove_existing(self, populated_schematic):
from mckicad.tools.schematic_edit import remove_component
result = remove_component(
schematic_path=populated_schematic,
reference="R2",
)
assert result["success"] is True
assert result["reference"] == "R2"
def test_remove_nonexistent(self, populated_schematic):
from mckicad.tools.schematic_edit import remove_component
result = remove_component(
schematic_path=populated_schematic,
reference="Z99",
)
assert result["success"] is False
@requires_sch_api
@pytest.mark.unit
class TestBackupSchematic:
"""Tests for the backup_schematic tool."""
def test_backup_creates_file(self, populated_schematic):
from mckicad.tools.schematic_edit import backup_schematic
result = backup_schematic(schematic_path=populated_schematic)
assert result["success"] is True
assert "backup_path" in result
assert os.path.isfile(result["backup_path"])
@pytest.mark.unit
class TestValidation:
"""Tests for input validation in edit tools."""
def test_invalid_path_extension(self):
from mckicad.tools.schematic_edit import modify_component
result = modify_component(
schematic_path="/tmp/test.txt",
reference="R1",
value="1k",
)
assert result["success"] is False
assert ".kicad_sch" in result["error"]
def test_empty_path(self):
from mckicad.tools.schematic_edit import remove_component
result = remove_component(schematic_path="", reference="R1")
assert result["success"] is False
def test_nonexistent_file(self):
from mckicad.tools.schematic_edit import set_title_block
result = set_title_block(
schematic_path="/tmp/nonexistent.kicad_sch",
title="Test",
)
assert result["success"] is False
@requires_sch_api
@pytest.mark.unit
class TestSetTitleBlock:
"""Tests for the set_title_block tool."""
def test_set_fields(self, populated_schematic):
from mckicad.tools.schematic_edit import set_title_block
result = set_title_block(
schematic_path=populated_schematic,
title="Test Circuit",
revision="1.0",
)
assert result["success"] is True
assert "title" in result.get("fields_set", [])
def test_set_author(self, populated_schematic):
from mckicad.tools.schematic_edit import set_title_block
result = set_title_block(
schematic_path=populated_schematic,
author="Test Author",
)
assert result["success"] is True
assert "author" in result.get("fields_set", [])
def test_no_fields_is_noop(self, populated_schematic):
from mckicad.tools.schematic_edit import set_title_block
result = set_title_block(schematic_path=populated_schematic)
assert result["success"] is True
assert result.get("fields_set") == []
@requires_sch_api
@pytest.mark.unit
class TestAnnotationTools:
"""Tests for add_text_annotation and add_no_connect."""
def test_add_text(self, populated_schematic):
from mckicad.tools.schematic_edit import add_text_annotation
result = add_text_annotation(
schematic_path=populated_schematic,
text="Test note",
x=50,
y=50,
)
assert result["success"] is True
def test_add_no_connect(self, populated_schematic):
from mckicad.tools.schematic_edit import add_no_connect
result = add_no_connect(
schematic_path=populated_schematic,
x=300,
y=300,
)
assert result["success"] is True
# Minimal schematic with wire segments for testing remove_wires_by_criteria.
# Does NOT require kicad-sch-api — works on raw s-expression files.
_WIRES_SCHEMATIC = """\
(kicad_sch
(version 20231120)
(generator "eeschema")
(uuid "test-root")
(paper "A4")
(lib_symbols
)
(wire (pts (xy 148.59 194.31) (xy 156.21 194.31))
(stroke (width 0) (type default))
(uuid "hw-1")
)
(wire (pts (xy 156.21 194.31) (xy 163.83 194.31))
(stroke (width 0) (type default))
(uuid "hw-2")
)
(wire (pts (xy 163.83 194.31) (xy 171.45 194.31))
(stroke (width 0) (type default))
(uuid "hw-3")
)
(wire (pts (xy 100.0 100.0) (xy 100.0 130.0))
(stroke (width 0) (type default))
(uuid "vw-1")
)
(wire (pts (xy 200.0 50.0) (xy 250.0 80.0))
(stroke (width 0) (type default))
(uuid "dw-1")
)
)
"""
@pytest.fixture
def wires_schematic():
"""Write a schematic with wires to a temp file for testing."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".kicad_sch", delete=False, encoding="utf-8",
) as f:
f.write(_WIRES_SCHEMATIC)
path = f.name
yield path
os.unlink(path)
@pytest.mark.unit
class TestRemoveWiresByCriteria:
"""Tests for the remove_wires_by_criteria tool."""
def test_dry_run_horizontal(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
y=194.31,
dry_run=True,
)
assert result["success"] is True
assert result["dry_run"] is True
assert result["matched_count"] == 3
assert result["removed_count"] == 0
def test_remove_horizontal_wires(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
y=194.31,
)
assert result["success"] is True
assert result["removed_count"] == 3
# Verify they're gone
from mckicad.utils.sexp_parser import parse_wire_segments
remaining = parse_wire_segments(wires_schematic)
remaining_uuids = {w["uuid"] for w in remaining}
assert "hw-1" not in remaining_uuids
assert "hw-2" not in remaining_uuids
assert "hw-3" not in remaining_uuids
assert "vw-1" in remaining_uuids
def test_remove_with_x_range(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
y=194.31,
min_x=148.0,
max_x=164.0,
)
assert result["success"] is True
assert result["matched_count"] == 2 # hw-1 and hw-2 only
def test_remove_vertical_wire(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
x=100.0,
)
assert result["success"] is True
assert result["matched_count"] == 1
assert result["removed_count"] == 1
def test_no_criteria_fails(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
)
assert result["success"] is False
assert "criterion" in result["error"].lower()
def test_no_matches(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
y=999.0,
)
assert result["success"] is True
assert result["matched_count"] == 0
def test_invalid_path(self):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path="/tmp/nonexistent.kicad_sch",
y=100.0,
)
assert result["success"] is False
def test_min_max_y_range(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
min_y=99.0,
max_y=131.0,
dry_run=True,
)
assert result["success"] is True
# vw-1 (100→130) fits, horizontal wires at 194.31 do not
assert result["matched_count"] == 1
def test_tolerance(self, wires_schematic):
from mckicad.tools.schematic_edit import remove_wires_by_criteria
# With tight tolerance, exact match
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
y=194.31,
tolerance=0.001,
dry_run=True,
)
assert result["matched_count"] == 3
# Offset slightly beyond tolerance
result = remove_wires_by_criteria(
schematic_path=wires_schematic,
y=194.35,
tolerance=0.01,
dry_run=True,
)
assert result["matched_count"] == 0