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>
This commit is contained in:
Eugene Mikhantyev
2026-05-03 22:40:34 +01:00
parent 7e67cb91c4
commit 496be317e9
2 changed files with 119 additions and 38 deletions

View File

@@ -19,9 +19,7 @@ EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
def _write_temp_sch(content: str) -> Path: def _write_temp_sch(content: str) -> Path:
tmp = tempfile.NamedTemporaryFile( tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
)
tmp.write(content) tmp.write(content)
tmp.close() tmp.close()
return Path(tmp.name) return Path(tmp.name)
@@ -36,7 +34,10 @@ def _unit_values_in_file(path: Path) -> list[int]:
"""Return all (unit N) values written for symbol instances in the schematic.""" """Return all (unit N) values written for symbol instances in the schematic."""
content = path.read_text() content = path.read_text()
# Match top-level symbol instances: (symbol (lib_id ...) (at ...) (unit N) ...) # 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)] return [
int(n)
for n in re.findall(r"\(symbol \(lib_id [^)]+\) \(at [^)]+\) \(unit (\d+)\)", content)
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -155,18 +156,20 @@ class TestHandlerAddSchematicComponent:
def test_unit_defaults_to_1_in_handler(self, tmp_path: Any) -> None: def test_unit_defaults_to_1_in_handler(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
result = self._call_handler({ result = self._call_handler(
"schematicPath": str(sch), {
"component": { "schematicPath": str(sch),
"library": "Device", "component": {
"type": "R", "library": "Device",
"reference": "R99", "type": "R",
"value": "1k", "reference": "R99",
"x": 10, "value": "1k",
"y": 10, "x": 10,
# no "unit" key — should default to 1 "y": 10,
}, # no "unit" key — should default to 1
}) },
}
)
assert result["success"] is True assert result["success"] is True
units = _unit_values_in_file(sch) units = _unit_values_in_file(sch)
assert 1 in units assert 1 in units
@@ -174,18 +177,82 @@ class TestHandlerAddSchematicComponent:
def test_unit_2_passed_through_handler(self, tmp_path: Any) -> None: def test_unit_2_passed_through_handler(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
result = self._call_handler({ result = self._call_handler(
"schematicPath": str(sch), {
"component": { "schematicPath": str(sch),
"library": "Device", "component": {
"type": "R", "library": "Device",
"reference": "U10", "type": "R",
"value": "TLP291-4", "reference": "U10",
"x": 25, "value": "TLP291-4",
"y": 35, "x": 25,
"unit": 2, "y": 35,
}, "unit": 2,
}) },
}
)
assert result["success"] is True assert result["success"] is True
units = _unit_values_in_file(sch) units = _unit_values_in_file(sch)
assert 2 in units 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."
)

View File

@@ -106,6 +106,20 @@ def _build_diode_case(tmp: Path, rotation: int) -> tuple[Path, dict]:
return sch_path, {("D1", "1"): "D1_K", ("D1", "2"): "D1_A"} return sch_path, {("D1", "1"): "D1_K", ("D1", "2"): "D1_A"}
def _apply_mirror_to_file(sch_path: Path, reference: str, axis: str) -> None:
"""Apply (mirror x|y) to a placed symbol via direct sexpr mutation.
ComponentManager.add_component silently drops a 'mirror' kwarg, so this
fixture goes around it via the same low-level helper rotate_schematic_component
uses (WireDragger.update_symbol_rotation_mirror)."""
import sexpdata
sch_data = sexpdata.loads(sch_path.read_text())
if not WireDragger.update_symbol_rotation_mirror(sch_data, reference, 0, axis):
raise RuntimeError(f"Failed to apply mirror={axis} to {reference}")
sch_path.write_text(sexpdata.dumps(sch_data))
def _build_mirror_case(tmp: Path, axis: str) -> tuple[Path, dict]: def _build_mirror_case(tmp: Path, axis: str) -> tuple[Path, dict]:
sch_path = tmp / f"resistor_mirror_{axis}.kicad_sch" sch_path = tmp / f"resistor_mirror_{axis}.kicad_sch"
template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch" template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch"
@@ -114,23 +128,23 @@ def _build_mirror_case(tmp: Path, axis: str) -> tuple[Path, dict]:
sch = SchematicManager.load_schematic(str(sch_path)) sch = SchematicManager.load_schematic(str(sch_path))
ComponentManager.add_component( ComponentManager.add_component(
sch, sch,
{ {"type": "R", "reference": "R1", "value": "10k", "x": 100.0, "y": 100.0, "rotation": 0},
"type": "R",
"reference": "R1",
"value": "10k",
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror": axis,
},
sch_path, sch_path,
) )
SchematicManager.save_schematic(sch, str(sch_path)) SchematicManager.save_schematic(sch, str(sch_path))
_apply_mirror_to_file(sch_path, "R1", axis)
if f"(mirror {axis})" not in sch_path.read_text():
raise RuntimeError(
f"Fixture failed to write (mirror {axis}) — the kicad-cli oracle would "
f"silently match our pin coords for an unmirrored symbol."
)
locator = PinLocator() locator = PinLocator()
p1 = locator.get_pin_location(sch_path, "R1", "1") p1 = locator.get_pin_location(sch_path, "R1", "1")
p2 = locator.get_pin_location(sch_path, "R1", "2") p2 = locator.get_pin_location(sch_path, "R1", "2")
assert p1 is not None and p2 is not None if p1 is None or p2 is None:
raise RuntimeError(f"PinLocator returned None for R1 mirror={axis}")
_add_labels_to_file(sch_path, [("R1_PIN1", p1[0], p1[1]), ("R1_PIN2", p2[0], p2[1])]) _add_labels_to_file(sch_path, [("R1_PIN1", p1[0], p1[1]), ("R1_PIN2", p2[0], p2[1])])