feat: add schematic analysis tools (read-only)
Add five new read-only schematic analysis MCP tools: - get_schematic_view_region: export cropped schematic region as PNG/SVG - find_unconnected_pins: list pins with no wire/label/power connection - find_overlapping_elements: detect duplicate symbols, stacked labels, collinear wire overlaps - get_elements_in_region: list all symbols/wires/labels in a bounding box - check_wire_collisions: detect wires passing through component bodies Includes Python handler dispatch, tool schemas, TypeScript server bindings, the schematic_analysis command module, and a full test suite (28 tests passing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
python/tests/__init__.py
Normal file
0
python/tests/__init__.py
Normal file
@@ -1,26 +1,41 @@
|
||||
"""
|
||||
Pytest configuration for python/tests.
|
||||
|
||||
Sets up sys.path so that the python/ package root is importable without
|
||||
installing the project, and provides shared fixtures.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make the python/ package root importable
|
||||
PYTHON_ROOT = Path(__file__).parent.parent
|
||||
if str(PYTHON_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PYTHON_ROOT))
|
||||
|
||||
# Stub out heavy KiCAD C-extension modules so tests can run without a real
|
||||
# KiCAD installation. Extend this list whenever a new import fails.
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard)
|
||||
# returns another MagicMock rather than raising AttributeError.
|
||||
for _stub_name in ("pcbnew", "skip"):
|
||||
if _stub_name not in sys.modules:
|
||||
_m = MagicMock(spec_set=None)
|
||||
_m.__name__ = _stub_name
|
||||
sys.modules[_stub_name] = _m
|
||||
"""
|
||||
Test configuration for python/tests.
|
||||
|
||||
Sets up sys.modules stubs for heavy KiCAD modules (pcbnew, skip) before any
|
||||
test module can trigger their import, preventing crashes on systems where the
|
||||
real KiCAD environment is not fully initialised for testing.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pcbnew stub — kicad_interface.py accesses pcbnew.__file__ and
|
||||
# pcbnew.GetBuildVersion() at module level. Use MagicMock so that any
|
||||
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
|
||||
# rather than raising AttributeError.
|
||||
# ---------------------------------------------------------------------------
|
||||
_pcbnew = MagicMock(name="pcbnew")
|
||||
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
|
||||
_pcbnew.__name__ = "pcbnew"
|
||||
_pcbnew.__spec__ = None
|
||||
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
|
||||
sys.modules["pcbnew"] = _pcbnew
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub: skip (kicad-skip — use real module if available, stub otherwise)
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
import skip as _skip_test # noqa: F401 — try importing real skip
|
||||
except ImportError:
|
||||
skip_mod = types.ModuleType("skip")
|
||||
|
||||
class _FakeSchematic:
|
||||
"""Minimal stand-in for skip.Schematic used in PinLocator cache."""
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.symbol = []
|
||||
|
||||
skip_mod.Schematic = _FakeSchematic # type: ignore[attr-defined]
|
||||
sys.modules["skip"] = skip_mod
|
||||
|
||||
398
python/tests/test_schematic_analysis.py
Normal file
398
python/tests/test_schematic_analysis.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Tests for schematic analysis tools (Tools 2–5).
|
||||
|
||||
Unit tests use mock data / synthetic S-expressions.
|
||||
Integration tests parse real .kicad_sch files via sexpdata.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
# Ensure the python/ package is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from commands.schematic_analysis import (
|
||||
_parse_wires,
|
||||
_parse_labels,
|
||||
_parse_symbols,
|
||||
_parse_no_connects,
|
||||
_load_sexp,
|
||||
_line_segment_intersects_aabb,
|
||||
_point_in_rect,
|
||||
_distance,
|
||||
compute_symbol_bbox,
|
||||
find_unconnected_pins,
|
||||
find_overlapping_elements,
|
||||
get_elements_in_region,
|
||||
check_wire_collisions,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch"
|
||||
|
||||
|
||||
def _make_temp_schematic(extra_sexp: str = "") -> Path:
|
||||
"""Copy empty.kicad_sch to a temp file and optionally append S-expression content."""
|
||||
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
||||
shutil.copy(TEMPLATE_PATH, tmp)
|
||||
if extra_sexp:
|
||||
content = tmp.read_text(encoding="utf-8")
|
||||
# Insert before the final closing paren
|
||||
idx = content.rfind(")")
|
||||
content = content[:idx] + "\n" + extra_sexp + "\n)"
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
return tmp
|
||||
|
||||
|
||||
import uuid as _uuid
|
||||
|
||||
|
||||
def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
|
||||
"""Generate a proper Device:R symbol S-expression that skip can parse."""
|
||||
u = str(_uuid.uuid4())
|
||||
return 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))
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
|
||||
"""Generate a proper Device:LED symbol S-expression (horizontal pin spread)."""
|
||||
u = str(_uuid.uuid4())
|
||||
return f"""
|
||||
(symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1)
|
||||
(in_bom yes) (on_board yes) (dnp no)
|
||||
(uuid "{u}")
|
||||
(property "Reference" "{ref}" (at {x} {y - 2.54} 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED" (at {x} {y + 2.54} 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at {x} {y} 0)
|
||||
(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))
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Unit tests — geometry helpers
|
||||
# ===================================================================
|
||||
|
||||
class TestGeometryHelpers:
|
||||
"""Test low-level geometry utilities."""
|
||||
|
||||
def test_point_in_rect_inside(self):
|
||||
assert _point_in_rect(5, 5, 0, 0, 10, 10) is True
|
||||
|
||||
def test_point_in_rect_outside(self):
|
||||
assert _point_in_rect(15, 5, 0, 0, 10, 10) is False
|
||||
|
||||
def test_point_in_rect_boundary(self):
|
||||
assert _point_in_rect(0, 0, 0, 0, 10, 10) is True
|
||||
|
||||
def test_distance_zero(self):
|
||||
assert _distance((0, 0), (0, 0)) == 0
|
||||
|
||||
def test_distance_unit(self):
|
||||
assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9
|
||||
|
||||
def test_aabb_intersection_crossing(self):
|
||||
# Line from (0,5) to (10,5) should intersect box (2,2)-(8,8)
|
||||
assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True
|
||||
|
||||
def test_aabb_intersection_miss(self):
|
||||
# Line from (0,0) to (10,0) should miss box (2,2)-(8,8)
|
||||
assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False
|
||||
|
||||
def test_aabb_intersection_inside(self):
|
||||
# Line entirely inside the box
|
||||
assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True
|
||||
|
||||
def test_aabb_intersection_diagonal(self):
|
||||
# Diagonal line crossing through box
|
||||
assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True
|
||||
|
||||
def test_aabb_intersection_parallel_outside(self):
|
||||
# Horizontal line above the box
|
||||
assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False
|
||||
|
||||
def test_aabb_intersection_touching_edge(self):
|
||||
# Line ending exactly at box edge
|
||||
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Unit tests — S-expression parsers
|
||||
# ===================================================================
|
||||
|
||||
class TestSexpParsers:
|
||||
"""Test S-expression parsing functions with synthetic data."""
|
||||
|
||||
def test_parse_wires_basic(self):
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(wire (pts (xy 10 20) (xy 30 40))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "abc"))
|
||||
)""")
|
||||
wires = _parse_wires(sexp)
|
||||
assert len(wires) == 1
|
||||
assert wires[0]["start"] == (10.0, 20.0)
|
||||
assert wires[0]["end"] == (30.0, 40.0)
|
||||
|
||||
def test_parse_wires_empty(self):
|
||||
sexp = sexpdata.loads("(kicad_sch)")
|
||||
assert _parse_wires(sexp) == []
|
||||
|
||||
def test_parse_labels_both_types(self):
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(label "VCC" (at 10 20 0))
|
||||
(global_label "GND" (at 30 40 0))
|
||||
)""")
|
||||
labels = _parse_labels(sexp)
|
||||
assert len(labels) == 2
|
||||
assert labels[0]["name"] == "VCC"
|
||||
assert labels[0]["type"] == "label"
|
||||
assert labels[1]["name"] == "GND"
|
||||
assert labels[1]["type"] == "global_label"
|
||||
|
||||
def test_parse_symbols(self):
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||
(property "Reference" "R1" (at 0 0 0)))
|
||||
(symbol (lib_id "power:VCC") (at 50 50 0)
|
||||
(property "Reference" "#PWR01" (at 0 0 0)))
|
||||
)""")
|
||||
symbols = _parse_symbols(sexp)
|
||||
assert len(symbols) == 2
|
||||
assert symbols[0]["reference"] == "R1"
|
||||
assert symbols[0]["is_power"] is False
|
||||
assert symbols[1]["reference"] == "#PWR01"
|
||||
assert symbols[1]["is_power"] is True
|
||||
|
||||
def test_parse_no_connects(self):
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(no_connect (at 10 20) (uuid "x"))
|
||||
(no_connect (at 30 40) (uuid "y"))
|
||||
)""")
|
||||
nc = _parse_no_connects(sexp)
|
||||
assert (10.0, 20.0) in nc
|
||||
assert (30.0, 40.0) in nc
|
||||
assert len(nc) == 2
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Unit tests — analysis functions with mocked PinLocator
|
||||
# ===================================================================
|
||||
|
||||
class TestFindOverlappingElements:
|
||||
"""Test overlapping detection logic."""
|
||||
|
||||
def test_no_overlaps_in_empty_schematic(self):
|
||||
tmp = _make_temp_schematic()
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
assert result["totalOverlaps"] == 0
|
||||
|
||||
def test_overlapping_symbols_detected(self):
|
||||
# Two symbols at nearly the same position
|
||||
extra = """
|
||||
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
(property "Value" "10k" (at 0 0 0)))
|
||||
(symbol (lib_id "Device:R") (at 100.1 100 0)
|
||||
(property "Reference" "R2" (at 0 0 0))
|
||||
(property "Value" "10k" (at 0 0 0)))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
assert result["totalOverlaps"] >= 1
|
||||
assert len(result["overlappingSymbols"]) >= 1
|
||||
|
||||
def test_well_separated_symbols_not_flagged(self):
|
||||
extra = """
|
||||
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
(property "Value" "10k" (at 0 0 0)))
|
||||
(symbol (lib_id "Device:R") (at 200 200 0)
|
||||
(property "Reference" "R2" (at 0 0 0))
|
||||
(property "Value" "10k" (at 0 0 0)))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
assert result["totalOverlaps"] == 0
|
||||
|
||||
def test_collinear_wire_overlap(self):
|
||||
extra = """
|
||||
(wire (pts (xy 10 50) (xy 30 50))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "w1"))
|
||||
(wire (pts (xy 20 50) (xy 40 50))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "w2"))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
assert len(result["overlappingWires"]) >= 1
|
||||
|
||||
|
||||
class TestGetElementsInRegion:
|
||||
"""Test region query logic."""
|
||||
|
||||
def test_elements_inside_region_found(self):
|
||||
extra = """
|
||||
(symbol (lib_id "Device:R") (at 50 50 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
(property "Value" "10k" (at 0 0 0)))
|
||||
(wire (pts (xy 45 50) (xy 55 50))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "w1"))
|
||||
(label "NET1" (at 50 50 0))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = get_elements_in_region(tmp, 40, 40, 60, 60)
|
||||
assert result["counts"]["symbols"] >= 1
|
||||
assert result["counts"]["wires"] >= 1
|
||||
assert result["counts"]["labels"] >= 1
|
||||
|
||||
def test_elements_outside_region_excluded(self):
|
||||
extra = """
|
||||
(symbol (lib_id "Device:R") (at 200 200 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
(property "Value" "10k" (at 0 0 0)))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = get_elements_in_region(tmp, 0, 0, 50, 50)
|
||||
assert result["counts"]["symbols"] == 0
|
||||
|
||||
|
||||
class TestComputeSymbolBbox:
|
||||
"""Test bounding box computation."""
|
||||
|
||||
def test_returns_none_for_unknown_symbol(self):
|
||||
tmp = _make_temp_schematic()
|
||||
from commands.pin_locator import PinLocator
|
||||
locator = PinLocator()
|
||||
result = compute_symbol_bbox(tmp, "NONEXISTENT", locator)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Integration tests — full schematic parsing
|
||||
# ===================================================================
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestIntegrationFindUnconnectedPins:
|
||||
"""Integration test using real schematic files."""
|
||||
|
||||
def test_component_with_no_wires_has_unconnected_pins(self):
|
||||
"""A resistor placed with no wires should have 2 unconnected pins."""
|
||||
extra = _make_resistor_sexp("R1", 100, 100)
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_unconnected_pins(tmp)
|
||||
r1_pins = [p for p in result if p["reference"] == "R1"]
|
||||
assert len(r1_pins) == 2
|
||||
|
||||
def test_pin_with_wire_is_connected(self):
|
||||
"""A wire endpoint exactly at a pin position should mark it connected."""
|
||||
# R1 at (100,100), rotation 0 → pin 1 at (100, 103.81), pin 2 at (100, 96.19)
|
||||
extra = _make_resistor_sexp("R1", 100, 100) + """
|
||||
(wire (pts (xy 100 103.81) (xy 100 120))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "w1"))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_unconnected_pins(tmp)
|
||||
r1_pins = [p for p in result if p["reference"] == "R1"]
|
||||
# Pin 1 should be connected (wire at 100, 103.81), pin 2 still unconnected
|
||||
assert len(r1_pins) == 1
|
||||
assert r1_pins[0]["pinNumber"] == "2"
|
||||
|
||||
def test_no_connect_suppresses_pin(self):
|
||||
"""A no_connect at a pin position should not report it as unconnected."""
|
||||
extra = _make_resistor_sexp("R1", 100, 100) + """
|
||||
(no_connect (at 100 96.19) (uuid "nc1"))
|
||||
(no_connect (at 100 103.81) (uuid "nc2"))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_unconnected_pins(tmp)
|
||||
r1_pins = [p for p in result if p["reference"] == "R1"]
|
||||
assert len(r1_pins) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestIntegrationCheckWireCollisions:
|
||||
"""Integration test for wire collision detection."""
|
||||
|
||||
def test_wire_not_touching_pins_is_collision(self):
|
||||
"""A wire passing through a component bbox without pin contact → collision."""
|
||||
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
|
||||
# Vertical wire from (100, 95) to (100, 105) crosses through the body
|
||||
# without touching either horizontal pin
|
||||
extra = _make_led_sexp("D1", 100, 100) + """
|
||||
(wire (pts (xy 100 95) (xy 100 105))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "w1"))
|
||||
"""
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = check_wire_collisions(tmp)
|
||||
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
|
||||
assert len(d1_collisions) >= 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestIntegrationGetElementsInRegion:
|
||||
"""Integration test for region query."""
|
||||
|
||||
def test_region_returns_pin_data(self):
|
||||
"""Symbols in region should include pin position data."""
|
||||
extra = _make_resistor_sexp("R1", 100, 100)
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = get_elements_in_region(tmp, 90, 90, 110, 110)
|
||||
assert result["counts"]["symbols"] == 1
|
||||
sym = result["symbols"][0]
|
||||
assert "pins" in sym
|
||||
assert len(sym["pins"]) == 2 # Resistor has 2 pins
|
||||
Reference in New Issue
Block a user