370 lines
13 KiB
Python
370 lines
13 KiB
Python
"""
|
||
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
|
||
"""
|
||
|
||
import re
|
||
import sys
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
# Ensure python/ directory is on path so kicad_interface can be imported
|
||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers shared across tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||
|
||
# Minimal placed-symbol block we can embed into a schematic for testing
|
||
PLACED_RESISTOR_BLOCK = """\
|
||
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
||
(in_bom yes) (on_board yes) (dnp no)
|
||
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||
(property "Reference" "R1" (at 51.27 47.46 0)
|
||
(effects (font (size 1.27 1.27)))
|
||
)
|
||
(property "Value" "10k" (at 51.27 52.54 0)
|
||
(effects (font (size 1.27 1.27)))
|
||
)
|
||
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
|
||
(effects (font (size 1.27 1.27)) hide)
|
||
)
|
||
(property "Datasheet" "~" (at 50 50 0)
|
||
(effects (font (size 1.27 1.27)) hide)
|
||
)
|
||
)
|
||
"""
|
||
|
||
|
||
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
|
||
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
|
||
dest = tmp_dir / "test.kicad_sch"
|
||
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
||
# Insert placed symbol block before the closing paren of the top-level form
|
||
if extra_block:
|
||
src_content = src_content.rstrip()
|
||
if src_content.endswith(")"):
|
||
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
|
||
dest.write_text(src_content, encoding="utf-8")
|
||
return dest
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestGetSchematicComponentParsing:
|
||
"""Unit tests for the regex logic used by _handle_get_schematic_component."""
|
||
|
||
def _parse_fields(self, block_text: str) -> dict:
|
||
"""Mirrors the regex used in _handle_get_schematic_component."""
|
||
prop_pattern = re.compile(
|
||
r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)'
|
||
)
|
||
fields = {}
|
||
for m in prop_pattern.finditer(block_text):
|
||
name, value, x, y, angle = (
|
||
m.group(1),
|
||
m.group(2),
|
||
m.group(3),
|
||
m.group(4),
|
||
m.group(5),
|
||
)
|
||
fields[name] = {
|
||
"value": value,
|
||
"x": float(x),
|
||
"y": float(y),
|
||
"angle": float(angle),
|
||
}
|
||
return fields
|
||
|
||
def _parse_comp_pos(self, block_text: str):
|
||
"""Mirrors the regex used to extract symbol position."""
|
||
m = re.search(
|
||
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
|
||
block_text,
|
||
)
|
||
if m:
|
||
return {
|
||
"x": float(m.group(1)),
|
||
"y": float(m.group(2)),
|
||
"angle": float(m.group(3)),
|
||
}
|
||
return None
|
||
|
||
def test_parses_reference_field(self):
|
||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||
assert "Reference" in fields
|
||
assert fields["Reference"]["value"] == "R1"
|
||
assert fields["Reference"]["x"] == pytest.approx(51.27)
|
||
assert fields["Reference"]["y"] == pytest.approx(47.46)
|
||
assert fields["Reference"]["angle"] == pytest.approx(0.0)
|
||
|
||
def test_parses_value_field(self):
|
||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||
assert "Value" in fields
|
||
assert fields["Value"]["value"] == "10k"
|
||
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||
assert fields["Value"]["y"] == pytest.approx(52.54)
|
||
|
||
def test_parses_all_four_standard_fields(self):
|
||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
|
||
|
||
def test_parses_component_position(self):
|
||
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
|
||
assert pos is not None
|
||
assert pos["x"] == pytest.approx(50.0)
|
||
assert pos["y"] == pytest.approx(50.0)
|
||
assert pos["angle"] == pytest.approx(0.0)
|
||
|
||
def test_field_position_regex_replaces_correctly(self):
|
||
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
|
||
field_name = "Reference"
|
||
new_x, new_y, new_angle = 99.0, 88.0, 0
|
||
block = PLACED_RESISTOR_BLOCK
|
||
block = re.sub(
|
||
r'(\(property\s+"'
|
||
+ re.escape(field_name)
|
||
+ r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||
rf"\1(at {new_x} {new_y} {new_angle})",
|
||
block,
|
||
)
|
||
fields = self._parse_fields(block)
|
||
assert fields["Reference"]["x"] == pytest.approx(99.0)
|
||
assert fields["Reference"]["y"] == pytest.approx(88.0)
|
||
# Value should be unchanged
|
||
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||
|
||
def test_field_position_regex_preserves_value(self):
|
||
"""Replacing position must not change the field value string."""
|
||
block = PLACED_RESISTOR_BLOCK
|
||
block = re.sub(
|
||
r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||
r"\1(at 0.0 0.0 0)",
|
||
block,
|
||
)
|
||
fields = self._parse_fields(block)
|
||
assert fields["Value"]["value"] == "10k"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – real file I/O using the empty.kicad_sch template
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestGetSchematicComponentIntegration:
|
||
"""Integration tests: write a real .kicad_sch and call the handler."""
|
||
|
||
@pytest.fixture
|
||
def sch_with_r1(self, tmp_path):
|
||
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||
|
||
def _get_interface(self):
|
||
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
|
||
from kicad_interface import KiCADInterface
|
||
|
||
return KiCADInterface()
|
||
|
||
def test_get_returns_success(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert result["success"] is True
|
||
|
||
def test_get_returns_correct_reference(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert result["reference"] == "R1"
|
||
|
||
def test_get_returns_component_position(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert result["position"] is not None
|
||
assert result["position"]["x"] == pytest.approx(50.0)
|
||
assert result["position"]["y"] == pytest.approx(50.0)
|
||
|
||
def test_get_returns_reference_field_position(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
ref_field = result["fields"]["Reference"]
|
||
assert ref_field["value"] == "R1"
|
||
assert ref_field["x"] == pytest.approx(51.27)
|
||
assert ref_field["y"] == pytest.approx(47.46)
|
||
|
||
def test_get_returns_value_field(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
val_field = result["fields"]["Value"]
|
||
assert val_field["value"] == "10k"
|
||
assert val_field["x"] == pytest.approx(51.27)
|
||
assert val_field["y"] == pytest.approx(52.54)
|
||
|
||
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R99",
|
||
},
|
||
)
|
||
assert result["success"] is False
|
||
assert "R99" in result["message"]
|
||
|
||
def test_get_missing_path_returns_failure(self):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert result["success"] is False
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestEditSchematicComponentFieldPositions:
|
||
"""Integration tests for the new fieldPositions parameter."""
|
||
|
||
@pytest.fixture
|
||
def sch_with_r1(self, tmp_path):
|
||
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||
|
||
def _get_interface(self):
|
||
from kicad_interface import KiCADInterface
|
||
|
||
return KiCADInterface()
|
||
|
||
def test_reposition_reference_label(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"edit_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
|
||
},
|
||
)
|
||
assert result["success"] is True
|
||
|
||
# Verify the position was actually written
|
||
get_result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
|
||
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
|
||
|
||
def test_reposition_does_not_change_value(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
iface.handle_command(
|
||
"edit_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
|
||
},
|
||
)
|
||
get_result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
# Value field position must be unchanged
|
||
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
|
||
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
|
||
|
||
def test_reposition_multiple_fields(self, sch_with_r1):
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"edit_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
"fieldPositions": {
|
||
"Reference": {"x": 10.0, "y": 20.0, "angle": 0},
|
||
"Value": {"x": 10.0, "y": 30.0, "angle": 0},
|
||
},
|
||
},
|
||
)
|
||
assert result["success"] is True
|
||
|
||
get_result = iface.handle_command(
|
||
"get_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
|
||
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
|
||
|
||
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
|
||
"""fieldPositions without value/footprint/newReference should succeed."""
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"edit_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
|
||
},
|
||
)
|
||
assert result["success"] is True
|
||
|
||
def test_no_params_still_fails(self, sch_with_r1):
|
||
"""Providing no update params should return an error."""
|
||
iface = self._get_interface()
|
||
result = iface.handle_command(
|
||
"edit_schematic_component",
|
||
{
|
||
"schematicPath": str(sch_with_r1),
|
||
"reference": "R1",
|
||
},
|
||
)
|
||
assert result["success"] is False
|