Files
kicad-mcp-server/tests/test_add_schematic_component.py
Matthew Runo 9843d75c91 fix(add_schematic_component): support hierarchical sub-sheets (#169)
DynamicSymbolLoader.create_component_instance only handled root
schematics: it located its insertion point via `(sheet_instances`,
which exists only in the root .kicad_sch. Adding a component to any
hierarchical sub-sheet raised "Could not find insertion point in
schematic" and aborted the call.

Fall back to inserting just before the closing paren of the outer
(kicad_sch ...) form when the marker is absent. WireManager.add_label
already uses the same fallback for hierarchical sub-sheets.

Adds three regression tests in TestCreateComponentInstanceSubSheet
covering successful insertion, paren balance, and sexpdata round-trip
on a sub-sheet without (sheet_instances).
2026-05-18 14:05:14 -04:00

356 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Tests for add_schematic_component handler, focusing on the unit parameter
for multi-unit symbols (e.g. quad optocouplers, dual op-amps).
"""
import re
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
TEMPLATES_DIR = Path(__file__).parent.parent / "python" / "templates"
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
def _write_temp_sch(content: str) -> Path:
tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
tmp.write(content)
tmp.close()
return Path(tmp.name)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _unit_values_in_file(path: Path) -> list[int]:
"""Return all (unit N) values written for symbol instances in the schematic."""
content = path.read_text()
# Match top-level symbol instances: (symbol (lib_id ...) (at ...) (unit N) ...)
return [
int(n)
for n in re.findall(r"\(symbol \(lib_id [^)]+\) \(at [^)]+\) \(unit (\d+)\)", content)
]
# ---------------------------------------------------------------------------
# Unit tests create_component_instance
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCreateComponentInstanceUnit:
"""Tests for DynamicSymbolLoader.create_component_instance unit parameter."""
def setup_method(self) -> None:
from commands.dynamic_symbol_loader import DynamicSymbolLoader
self.DynamicSymbolLoader = DynamicSymbolLoader
def _loader(self) -> Any:
return self.DynamicSymbolLoader()
def test_default_unit_is_1(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
loader = self._loader()
loader.create_component_instance(
sch, "Device", "R", reference="R1", value="10k", x=10, y=10
)
units = _unit_values_in_file(sch)
assert 1 in units
def test_explicit_unit_1(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
loader = self._loader()
loader.create_component_instance(
sch, "Device", "R", reference="R1", value="10k", x=10, y=10, unit=1
)
units = _unit_values_in_file(sch)
assert units.count(1) >= 1
def test_unit_2_written_correctly(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
loader = self._loader()
loader.create_component_instance(
sch, "Device", "R", reference="U1", value="TLP291-4", x=10, y=10, unit=2
)
units = _unit_values_in_file(sch)
assert 2 in units
def test_unit_4_written_correctly(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
loader = self._loader()
loader.create_component_instance(
sch, "Device", "R", reference="U1", value="TLP291-4", x=10, y=10, unit=4
)
units = _unit_values_in_file(sch)
assert 4 in units
def test_instances_block_uses_same_unit(self, tmp_path: Any) -> None:
"""The (instances ...) path block must also record the correct unit number."""
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
loader = self._loader()
loader.create_component_instance(
sch, "Device", "R", reference="U1", value="val", x=5, y=5, unit=3
)
content = sch.read_text()
# The (unit 3) inside the (instances ...) block
assert "(unit 3)" in content
# Count occurrences — should appear at least twice (symbol header + instances)
assert content.count("(unit 3)") >= 2
def test_multiple_units_same_reference(self, tmp_path: Any) -> None:
"""Placing units A and B of the same reference produces two distinct unit entries."""
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
loader = self._loader()
loader.create_component_instance(
sch, "Device", "R", reference="U10", value="TLP291-4", x=10, y=10, unit=1
)
loader.create_component_instance(
sch, "Device", "R", reference="U10", value="TLP291-4", x=10, y=35, unit=2
)
units = _unit_values_in_file(sch)
assert 1 in units
assert 2 in units
# ---------------------------------------------------------------------------
# Handler-level tests _handle_add_schematic_component
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestHandlerAddSchematicComponent:
"""Tests for KiCADInterface._handle_add_schematic_component unit plumbing."""
def _call_handler(self, params: dict) -> dict:
from kicad_interface import KiCADInterface
iface = KiCADInterface()
return iface._handle_add_schematic_component(params)
def test_missing_schematic_path_returns_error(self) -> None:
result = self._call_handler({"component": {"type": "R", "library": "Device"}})
assert result["success"] is False
assert "path" in result["message"].lower() or "schematic" in result["message"].lower()
def test_missing_component_returns_error(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self._call_handler({"schematicPath": str(sch)})
assert result["success"] is False
def test_unit_defaults_to_1_in_handler(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self._call_handler(
{
"schematicPath": str(sch),
"component": {
"library": "Device",
"type": "R",
"reference": "R99",
"value": "1k",
"x": 10,
"y": 10,
# no "unit" key — should default to 1
},
}
)
assert result["success"] is True
units = _unit_values_in_file(sch)
assert 1 in units
def test_unit_2_passed_through_handler(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self._call_handler(
{
"schematicPath": str(sch),
"component": {
"library": "Device",
"type": "R",
"reference": "U10",
"value": "TLP291-4",
"x": 25,
"y": 35,
"unit": 2,
},
}
)
assert result["success"] is True
units = _unit_values_in_file(sch)
assert 2 in units
# ---------------------------------------------------------------------------
# Hierarchical sub-sheets — no (sheet_instances ...) block
# ---------------------------------------------------------------------------
# Minimal sub-sheet: same outer (kicad_sch ...) form as a root schematic but
# WITHOUT (sheet_instances ...). Hierarchical KiCad designs only carry that
# block in the root .kicad_sch — every child sheet ends after lib_symbols /
# any placed (symbol ...) blocks. The fix under test must insert new symbol
# instances before the closing paren of (kicad_sch ...) when the marker is
# missing.
SUB_SHEET_NO_SHEET_INSTANCES = """(kicad_sch
\t(version 20260306)
\t(generator "eeschema")
\t(generator_version "10.0")
\t(uuid "bbbb2222-2222-2222-2222-bbbbbbbbbbbb")
\t(paper "A4")
\t(lib_symbols)
)
"""
@pytest.mark.unit
class TestCreateComponentInstanceSubSheet:
"""Hierarchical sub-sheets don't have (sheet_instances ...).
Before the fix, create_component_instance raised
'Could not find insertion point in schematic' on any sub-sheet, blocking
every add_schematic_component call into a hierarchical design's child
sheet.
"""
def setup_method(self) -> None:
from commands.dynamic_symbol_loader import DynamicSymbolLoader
self.DynamicSymbolLoader = DynamicSymbolLoader
def _loader(self) -> Any:
return self.DynamicSymbolLoader()
def test_sub_sheet_insertion_succeeds(self, tmp_path: Any) -> None:
sch = tmp_path / "child.kicad_sch"
sch.write_text(SUB_SHEET_NO_SHEET_INSTANCES, encoding="utf-8")
ok = self._loader().create_component_instance(
sch, "Device", "R", reference="R_TEST", value="100k", x=50, y=50
)
assert ok is True
content = sch.read_text(encoding="utf-8")
assert '"R_TEST"' in content
assert "100k" in content
def test_sub_sheet_keeps_outer_form_balanced(self, tmp_path: Any) -> None:
"""The new symbol must land inside (kicad_sch ...), with parens balanced."""
sch = tmp_path / "child.kicad_sch"
sch.write_text(SUB_SHEET_NO_SHEET_INSTANCES, encoding="utf-8")
self._loader().create_component_instance(
sch, "Device", "R", reference="R_TEST", value="1k", x=10, y=10
)
content = sch.read_text(encoding="utf-8")
assert content.count("(") == content.count(
")"
), "Inserting into a sub-sheet must keep parens balanced"
# The outer form must still parse via sexpdata.
import sexpdata
parsed = sexpdata.loads(content)
assert isinstance(parsed, list)
assert parsed[0] == sexpdata.Symbol("kicad_sch")
def test_sub_sheet_round_trips_via_sexpdata(self, tmp_path: Any) -> None:
"""The injected symbol must survive a sexpdata load+dump round-trip."""
import sexpdata
sch = tmp_path / "child.kicad_sch"
sch.write_text(SUB_SHEET_NO_SHEET_INSTANCES, encoding="utf-8")
self._loader().create_component_instance(
sch, "Device", "R", reference="R_TEST", value="1k", x=10, y=10
)
parsed = sexpdata.loads(sch.read_text(encoding="utf-8"))
# The placed (symbol (lib_id ...) ...) block must be a top-level child of kicad_sch.
symbol_items = [
item
for item in parsed[1:]
if isinstance(item, list) and len(item) > 0 and item[0] == sexpdata.Symbol("symbol")
]
# Confirm at least one of those carries our reference.
assert any(
sexpdata.dumps(s).find('"R_TEST"') >= 0 for s in symbol_items
), "Reference 'R_TEST' should appear in a top-level (symbol ...) child"
# ---------------------------------------------------------------------------
# Mirror parameter — known gap
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAddComponentMirrorParam:
"""ComponentManager.add_component does NOT honor a 'mirror' kwarg today.
The MCP add_schematic_component tool schema also doesn't expose mirror.
A mirror is currently only applicable post-add via rotate_schematic_component.
These tests pin down the silent-drop behavior so a fixture that passes
'mirror': 'x' and then asserts something against the resulting schematic
cannot accidentally pass for the wrong reason (the symbol ends up
unmirrored). If/when add_component grows real mirror support, update both
tests together — the second test then becomes the positive assertion."""
def setup_method(self) -> None:
from commands.component_schematic import ComponentManager
from commands.schematic import SchematicManager
self.ComponentManager = ComponentManager
self.SchematicManager = SchematicManager
def _add(self, sch_path: Path, mirror_value: Any) -> None:
sch = self.SchematicManager.load_schematic(str(sch_path))
params = {
"type": "R",
"reference": "R1",
"value": "10k",
"x": 100.0,
"y": 100.0,
"rotation": 0,
}
if mirror_value is not None:
params["mirror"] = mirror_value
self.ComponentManager.add_component(sch, params, sch_path)
self.SchematicManager.save_schematic(sch, str(sch_path))
def test_mirror_x_arg_is_silently_dropped(self, tmp_path: Any) -> None:
sch = tmp_path / "mirror_x.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
self._add(sch, "x")
text = sch.read_text()
assert "(mirror x)" not in text, (
"ComponentManager.add_component now appears to honor mirror='x'. "
"Update _build_mirror_case in test_pin_world_xy_eeschema_truth.py "
"to drop the post-add mirror application and remove this test."
)
def test_mirror_y_arg_is_silently_dropped(self, tmp_path: Any) -> None:
sch = tmp_path / "mirror_y.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
self._add(sch, "y")
text = sch.read_text()
assert "(mirror y)" not in text, (
"ComponentManager.add_component now appears to honor mirror='y'. "
"See sibling test_mirror_x_arg_is_silently_dropped."
)