Files
kicad-mcp-server/tests/test_add_schematic_component.py
Eugene Mikhantyev 496be317e9 test: close mirror-fixture gap exposed by post-fix audit
The eeschema-ground-truth fixture (_build_mirror_case) passed
'mirror': 'x' to ComponentManager.add_component, which silently drops
the kwarg — so the resulting .kicad_sch had no (mirror x|y) token,
eeschema rendered an unmirrored symbol, and our pin coords (also
unmirrored) tautologically matched. The mirror tests were GREEN both
before and after the rotation/mirror fix in 7e67cb9, providing zero
regression coverage for the mirror semantics.

Fix:
  - _build_mirror_case now applies the mirror via the same low-level
    helper (WireDragger.update_symbol_rotation_mirror) that
    rotate_schematic_component uses, with a guard assertion that the
    written file actually contains (mirror x|y).
  - Two new pin-down unit tests in test_add_schematic_component.py
    document and lock down ComponentManager.add_component's silent-drop
    behavior for mirror, so the next person to touch that path knows to
    update the eeschema-truth fixture if they grow real mirror support.

Verified: with the production fix at 7e67cb9 reverted, the kicad-cli
mirror tests now go RED (previously they stayed GREEN regardless).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:40:34 +01:00

259 lines
9.4 KiB
Python
Raw 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
# ---------------------------------------------------------------------------
# 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."
)