chore: enable strict mypy checks and fix pre-commit mypy hook
Add type annotations to all previously untyped functions and remove 9 suppressed error codes (call-arg, assignment, return-value, operator, has-type, dict-item, misc, list-item, annotation-unchecked) by fixing the underlying type issues. Add [[tool.mypy.overrides]] with ignore_missing_imports for KiCAD-specific modules (pcbnew, sexpdata, skip, cairosvg, kipy, PIL) so the pre-commit mypy hook passes in its isolated venv. Add types-requests and pytest to additional_dependencies in .pre-commit-config.yaml. Also fixes several real bugs uncovered by stricter checks: incorrect static calls to instance methods in swig_backend, wrong return type on get_size, missing value param in BoardAPI.place_component, variable shadowing in kicad_process.py, unqualified LibraryManager reference in kicad_interface, and missing top-level Path import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,207 +1,208 @@
|
||||
"""
|
||||
Regression tests for delete_schematic_component.
|
||||
|
||||
Key regression: the handler previously used a line-by-line regex that required
|
||||
`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts
|
||||
them on *separate* lines, so every real-world delete returned "not found".
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||
|
||||
# Inline format (single line) – matches what tests previously used
|
||||
PLACED_RESISTOR_INLINE = """\
|
||||
(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)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
# Multi-line format – as KiCAD's own file writer produces it.
|
||||
# (symbol and (lib_id are on separate lines, which broke the old regex.
|
||||
PLACED_RESISTOR_MULTILINE = """\
|
||||
\t(symbol
|
||||
\t\t(lib_id "Device:R")
|
||||
\t\t(at 50 50 0)
|
||||
\t\t(unit 1)
|
||||
\t\t(in_bom yes)
|
||||
\t\t(on_board yes)
|
||||
\t\t(dnp no)
|
||||
\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
|
||||
\t\t(property "Reference" "R2"
|
||||
\t\t\t(at 51.27 47.46 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t\t(property "Value" "4.7k"
|
||||
\t\t\t(at 51.27 52.54 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
"""
|
||||
|
||||
# Multi-line power symbol – the exact scenario that was reported as broken.
|
||||
PLACED_POWER_SYMBOL_MULTILINE = """\
|
||||
\t(symbol
|
||||
\t\t(lib_id "power:VCC")
|
||||
\t\t(at 365.6 38.1 0)
|
||||
\t\t(unit 1)
|
||||
\t\t(in_bom yes)
|
||||
\t\t(on_board yes)
|
||||
\t\t(dnp no)
|
||||
\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030")
|
||||
\t\t(property "Reference" "#PWR030"
|
||||
\t\t\t(at 365.6 41.91 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t\t(hide yes)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t\t(property "Value" "VCC"
|
||||
\t\t\t(at 365.6 35.56 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
"""
|
||||
|
||||
|
||||
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
||||
dest = tmp_path / "test.kicad_sch"
|
||||
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
||||
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 – regression proof for the old regex vs the new approach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteDetectionRegex:
|
||||
"""Verify that the new content-string pattern finds blocks in both formats."""
|
||||
|
||||
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
|
||||
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
|
||||
|
||||
def test_old_regex_fails_on_multiline_format(self):
|
||||
"""Regression: old line-by-line regex must NOT match the multi-line format."""
|
||||
# The old code used re.match on individual lines; simulate that here.
|
||||
lines = PLACED_RESISTOR_MULTILINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert matches == [], "Old regex should not match multi-line KiCAD format"
|
||||
|
||||
def test_old_regex_matches_inline_format(self):
|
||||
"""Old regex did work on single-line (inline) format."""
|
||||
lines = PLACED_RESISTOR_INLINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert len(matches) == 1
|
||||
|
||||
def test_new_pattern_matches_multiline_format(self):
|
||||
"""New content-string pattern must find blocks in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
|
||||
|
||||
def test_new_pattern_matches_inline_format(self):
|
||||
"""New content-string pattern also works on inline format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
|
||||
|
||||
def test_new_pattern_matches_power_symbol_multiline(self):
|
||||
"""New pattern must find #PWR030 power symbol in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
def test_reference_extraction_from_multiline_block(self):
|
||||
"""Reference property can be found inside a multi-line block."""
|
||||
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
|
||||
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – real file I/O using the handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteSchematicComponentIntegration:
|
||||
def _get_handler(self):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_delete_schematic_component
|
||||
|
||||
def test_delete_inline_format_succeeds(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_multiline_format_succeeds(self, tmp_path):
|
||||
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_power_symbol_multiline_succeeds(self, tmp_path):
|
||||
"""Regression: #PWR030 multi-line power symbol must be deletable."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_component_absent_after_delete(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
remaining = sch.read_text(encoding="utf-8")
|
||||
assert '"#PWR030"' not in remaining
|
||||
|
||||
def test_unknown_reference_returns_failure(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_missing_schematic_path_returns_failure(self, tmp_path):
|
||||
result = self._get_handler()({"reference": "R1"})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_reference_returns_failure(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path)
|
||||
result = self._get_handler()({"schematicPath": str(sch)})
|
||||
assert result["success"] is False
|
||||
"""
|
||||
Regression tests for delete_schematic_component.
|
||||
|
||||
Key regression: the handler previously used a line-by-line regex that required
|
||||
`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts
|
||||
them on *separate* lines, so every real-world delete returned "not found".
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||
|
||||
# Inline format (single line) – matches what tests previously used
|
||||
PLACED_RESISTOR_INLINE = """\
|
||||
(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)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
# Multi-line format – as KiCAD's own file writer produces it.
|
||||
# (symbol and (lib_id are on separate lines, which broke the old regex.
|
||||
PLACED_RESISTOR_MULTILINE = """\
|
||||
\t(symbol
|
||||
\t\t(lib_id "Device:R")
|
||||
\t\t(at 50 50 0)
|
||||
\t\t(unit 1)
|
||||
\t\t(in_bom yes)
|
||||
\t\t(on_board yes)
|
||||
\t\t(dnp no)
|
||||
\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
|
||||
\t\t(property "Reference" "R2"
|
||||
\t\t\t(at 51.27 47.46 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t\t(property "Value" "4.7k"
|
||||
\t\t\t(at 51.27 52.54 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
"""
|
||||
|
||||
# Multi-line power symbol – the exact scenario that was reported as broken.
|
||||
PLACED_POWER_SYMBOL_MULTILINE = """\
|
||||
\t(symbol
|
||||
\t\t(lib_id "power:VCC")
|
||||
\t\t(at 365.6 38.1 0)
|
||||
\t\t(unit 1)
|
||||
\t\t(in_bom yes)
|
||||
\t\t(on_board yes)
|
||||
\t\t(dnp no)
|
||||
\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030")
|
||||
\t\t(property "Reference" "#PWR030"
|
||||
\t\t\t(at 365.6 41.91 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t\t(hide yes)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t\t(property "Value" "VCC"
|
||||
\t\t\t(at 365.6 35.56 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
"""
|
||||
|
||||
|
||||
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
||||
dest = tmp_path / "test.kicad_sch"
|
||||
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
||||
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 – regression proof for the old regex vs the new approach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteDetectionRegex:
|
||||
"""Verify that the new content-string pattern finds blocks in both formats."""
|
||||
|
||||
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
|
||||
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
|
||||
|
||||
def test_old_regex_fails_on_multiline_format(self) -> None:
|
||||
"""Regression: old line-by-line regex must NOT match the multi-line format."""
|
||||
# The old code used re.match on individual lines; simulate that here.
|
||||
lines = PLACED_RESISTOR_MULTILINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert matches == [], "Old regex should not match multi-line KiCAD format"
|
||||
|
||||
def test_old_regex_matches_inline_format(self) -> None:
|
||||
"""Old regex did work on single-line (inline) format."""
|
||||
lines = PLACED_RESISTOR_INLINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert len(matches) == 1
|
||||
|
||||
def test_new_pattern_matches_multiline_format(self) -> None:
|
||||
"""New content-string pattern must find blocks in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
|
||||
|
||||
def test_new_pattern_matches_inline_format(self) -> None:
|
||||
"""New content-string pattern also works on inline format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
|
||||
|
||||
def test_new_pattern_matches_power_symbol_multiline(self) -> None:
|
||||
"""New pattern must find #PWR030 power symbol in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
def test_reference_extraction_from_multiline_block(self) -> None:
|
||||
"""Reference property can be found inside a multi-line block."""
|
||||
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
|
||||
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – real file I/O using the handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteSchematicComponentIntegration:
|
||||
def _get_handler(self) -> Any:
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_delete_schematic_component
|
||||
|
||||
def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None:
|
||||
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None:
|
||||
"""Regression: #PWR030 multi-line power symbol must be deletable."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_component_absent_after_delete(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
remaining = sch.read_text(encoding="utf-8")
|
||||
assert '"#PWR030"' not in remaining
|
||||
|
||||
def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None:
|
||||
result = self._get_handler()({"reference": "R1"})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_reference_returns_failure(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path)
|
||||
result = self._get_handler()({"schematicPath": str(sch)})
|
||||
assert result["success"] is False
|
||||
|
||||
@@ -12,6 +12,7 @@ Covers:
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -33,7 +34,7 @@ pcbnew_mock = sys.modules["pcbnew"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_pcbnew_mock():
|
||||
def reset_pcbnew_mock() -> Any:
|
||||
"""Reset pcbnew mock before each test."""
|
||||
pcbnew_mock.reset_mock()
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = None
|
||||
@@ -44,7 +45,7 @@ def reset_pcbnew_mock():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_board():
|
||||
def mock_board() -> Any:
|
||||
board = MagicMock()
|
||||
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
|
||||
board.GetTracks.return_value = []
|
||||
@@ -52,16 +53,16 @@ def mock_board():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmds(mock_board):
|
||||
def cmds(mock_board: Any) -> Any:
|
||||
return FreeroutingCommands(board=mock_board)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmds_no_board():
|
||||
def cmds_no_board() -> Any:
|
||||
return FreeroutingCommands(board=None)
|
||||
|
||||
|
||||
def _patch_direct_java():
|
||||
def _patch_direct_java() -> Any:
|
||||
"""Patch to simulate Java 21+ available locally."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
@@ -70,7 +71,7 @@ def _patch_direct_java():
|
||||
)
|
||||
|
||||
|
||||
def _patch_docker_mode():
|
||||
def _patch_docker_mode() -> Any:
|
||||
"""Patch to simulate Docker execution mode."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
@@ -79,7 +80,7 @@ def _patch_docker_mode():
|
||||
)
|
||||
|
||||
|
||||
def _patch_no_runtime():
|
||||
def _patch_no_runtime() -> Any:
|
||||
"""Patch to simulate no Java and no Docker."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
@@ -97,7 +98,7 @@ def _patch_no_runtime():
|
||||
|
||||
|
||||
class TestCheckFreerouting:
|
||||
def test_no_java_no_docker(self, cmds):
|
||||
def test_no_java_no_docker(self, cmds: Any) -> None:
|
||||
with (
|
||||
patch("commands.freerouting._find_java", return_value=None),
|
||||
patch(
|
||||
@@ -112,7 +113,7 @@ class TestCheckFreerouting:
|
||||
assert result["ready"] is False
|
||||
assert result["execution_mode"] == "none"
|
||||
|
||||
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
||||
def test_java_too_old_docker_available(self, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with (
|
||||
@@ -135,7 +136,7 @@ class TestCheckFreerouting:
|
||||
assert result["ready"] is True
|
||||
assert result["execution_mode"] == "docker"
|
||||
|
||||
def test_java_21_direct(self, cmds, tmp_path):
|
||||
def test_java_21_direct(self, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with (
|
||||
@@ -165,12 +166,12 @@ class TestCheckFreerouting:
|
||||
|
||||
|
||||
class TestExportDsn:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
def test_no_board(self, cmds_no_board: Any) -> None:
|
||||
result = cmds_no_board.export_dsn({})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_export_success(self, cmds, tmp_path):
|
||||
def test_export_success(self, cmds: Any, tmp_path: Any) -> None:
|
||||
board_path = str(tmp_path / "test.kicad_pcb")
|
||||
dsn_path = str(tmp_path / "test.dsn")
|
||||
cmds.board.GetFileName.return_value = board_path
|
||||
@@ -182,7 +183,7 @@ class TestExportDsn:
|
||||
assert result["success"] is True
|
||||
assert result["path"] == dsn_path
|
||||
|
||||
def test_export_custom_path(self, cmds, tmp_path):
|
||||
def test_export_custom_path(self, cmds: Any, tmp_path: Any) -> None:
|
||||
output = str(tmp_path / "custom.dsn")
|
||||
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||
Path(output).write_text("(pcb test)")
|
||||
@@ -191,7 +192,7 @@ class TestExportDsn:
|
||||
assert result["success"] is True
|
||||
assert result["path"] == output
|
||||
|
||||
def test_export_failure(self, cmds):
|
||||
def test_export_failure(self, cmds: Any) -> None:
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
|
||||
result = cmds.export_dsn({})
|
||||
assert result["success"] is False
|
||||
@@ -204,22 +205,22 @@ class TestExportDsn:
|
||||
|
||||
|
||||
class TestImportSes:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
def test_no_board(self, cmds_no_board: Any) -> None:
|
||||
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_missing_ses_path(self, cmds):
|
||||
def test_missing_ses_path(self, cmds: Any) -> None:
|
||||
result = cmds.import_ses({})
|
||||
assert result["success"] is False
|
||||
assert "Missing sesPath" in result["message"]
|
||||
|
||||
def test_ses_file_not_found(self, cmds):
|
||||
def test_ses_file_not_found(self, cmds: Any) -> None:
|
||||
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_import_success(self, cmds, tmp_path):
|
||||
def test_import_success(self, cmds: Any, tmp_path: Any) -> None:
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
@@ -229,7 +230,7 @@ class TestImportSes:
|
||||
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||
assert result["success"] is True
|
||||
|
||||
def test_import_failure(self, cmds, tmp_path):
|
||||
def test_import_failure(self, cmds: Any, tmp_path: Any) -> None:
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
@@ -245,12 +246,12 @@ class TestImportSes:
|
||||
|
||||
|
||||
class TestAutoroute:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
def test_no_board(self, cmds_no_board: Any) -> None:
|
||||
result = cmds_no_board.autoroute({})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_no_runtime(self, cmds, tmp_path):
|
||||
def test_no_runtime(self, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with _patch_no_runtime():
|
||||
@@ -258,13 +259,13 @@ class TestAutoroute:
|
||||
assert result["success"] is False
|
||||
assert "No suitable Java runtime" in result["message"]
|
||||
|
||||
def test_no_jar(self, cmds):
|
||||
def test_no_jar(self, cmds: Any) -> None:
|
||||
result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
|
||||
assert result["success"] is False
|
||||
assert "JAR not found" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
|
||||
def test_dsn_export_fails(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
|
||||
@@ -276,7 +277,7 @@ class TestAutoroute:
|
||||
assert "DSN export failed" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
|
||||
def test_freerouting_timeout(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
import subprocess
|
||||
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
@@ -300,7 +301,7 @@ class TestAutoroute:
|
||||
assert "timed out" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_direct(self, mock_run, cmds, tmp_path):
|
||||
def test_full_success_direct(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -336,7 +337,7 @@ class TestAutoroute:
|
||||
assert "elapsed_seconds" in result
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_docker(self, mock_run, cmds, tmp_path):
|
||||
def test_full_success_docker(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -375,7 +376,7 @@ class TestAutoroute:
|
||||
assert "--rm" in call_args
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path):
|
||||
def test_freerouting_nonzero_exit(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -404,14 +405,14 @@ class TestAutoroute:
|
||||
|
||||
|
||||
class TestFindJava:
|
||||
def test_finds_via_which(self):
|
||||
def test_finds_via_which(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value="/usr/bin/java",
|
||||
):
|
||||
assert _find_java() == "/usr/bin/java"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
def test_none_when_not_found(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
@@ -423,21 +424,21 @@ class TestFindJava:
|
||||
|
||||
|
||||
class TestFindDocker:
|
||||
def test_finds_docker(self):
|
||||
def test_finds_docker(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/docker"
|
||||
|
||||
def test_finds_podman(self):
|
||||
def test_finds_podman(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/podman"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
def test_none_when_not_found(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value=None,
|
||||
@@ -446,7 +447,7 @@ class TestFindDocker:
|
||||
|
||||
|
||||
class TestDockerAvailable:
|
||||
def test_docker_found(self):
|
||||
def test_docker_found(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting._find_docker",
|
||||
@@ -457,14 +458,14 @@ class TestDockerAvailable:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert _docker_available() is True
|
||||
|
||||
def test_docker_not_installed(self):
|
||||
def test_docker_not_installed(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value=None,
|
||||
):
|
||||
assert _docker_available() is False
|
||||
|
||||
def test_docker_not_running(self):
|
||||
def test_docker_not_running(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting._find_docker",
|
||||
@@ -477,17 +478,17 @@ class TestDockerAvailable:
|
||||
|
||||
|
||||
class TestJavaVersionOk:
|
||||
def test_java_21(self):
|
||||
def test_java_21(self) -> None:
|
||||
with patch("commands.freerouting.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
|
||||
assert _java_version_ok("/usr/bin/java") is True
|
||||
|
||||
def test_java_17(self):
|
||||
def test_java_17(self) -> None:
|
||||
with patch("commands.freerouting.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="")
|
||||
assert _java_version_ok("/usr/bin/java") is False
|
||||
|
||||
def test_java_error(self):
|
||||
def test_java_error(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run",
|
||||
side_effect=Exception("not found"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,369 +1,370 @@
|
||||
"""
|
||||
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
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
|
||||
"""
|
||||
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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) -> Any:
|
||||
"""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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
|
||||
|
||||
def test_parses_component_position(self) -> None:
|
||||
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) -> None:
|
||||
"""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) -> None:
|
||||
"""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: Any) -> Any:
|
||||
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||||
|
||||
def _get_interface(self) -> Any:
|
||||
"""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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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) -> None:
|
||||
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: Any) -> Any:
|
||||
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||||
|
||||
def _get_interface(self) -> Any:
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface()
|
||||
|
||||
def test_reposition_reference_label(self, sch_with_r1: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
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: Any) -> None:
|
||||
"""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: Any) -> None:
|
||||
"""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
|
||||
|
||||
@@ -1,476 +1,477 @@
|
||||
"""
|
||||
Tests for schematic inspection and editing tools added in the schematic_tools branch.
|
||||
|
||||
Covers:
|
||||
- WireManager.delete_wire (unit + integration)
|
||||
- WireManager.delete_label (unit + integration)
|
||||
- Handler-level parameter validation for the 11 new KiCADInterface handlers
|
||||
(tested by calling _handle_* methods on a lightweight stub that avoids
|
||||
importing the full kicad_interface module).
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sexpdata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
|
||||
|
||||
# Minimal schematic content used by integration tests
|
||||
_WIRE_SCH = """\
|
||||
(kicad_sch (version 20250114) (generator "test")
|
||||
(uuid aaaaaaaa-0000-0000-0000-000000000000)
|
||||
(paper "A4")
|
||||
(wire (pts (xy 10 20) (xy 30 20))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid bbbbbbbb-0000-0000-0000-000000000001)
|
||||
)
|
||||
(label "VCC" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left bottom))
|
||||
(uuid cccccccc-0000-0000-0000-000000000002)
|
||||
)
|
||||
(sheet_instances (path "/" (page "1")))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _write_temp_sch(content: str) -> Path:
|
||||
"""Write *content* to a temp file and return its Path."""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteWireUnit:
|
||||
"""Unit-level tests for WireManager.delete_wire."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10])
|
||||
assert result is False
|
||||
|
||||
def test_no_matching_wire_returns_false(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
|
||||
assert result is False
|
||||
|
||||
def test_tolerance_argument_accepted(self, tmp_path):
|
||||
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1)
|
||||
assert result is False # no wire in empty sch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteLabelUnit:
|
||||
"""Unit-level tests for WireManager.delete_label."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
|
||||
assert result is False
|
||||
|
||||
def test_missing_label_returns_false(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "NONEXISTENT")
|
||||
assert result is False
|
||||
|
||||
def test_position_kwarg_accepted(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteWireIntegration:
|
||||
"""Integration tests that read/write real .kicad_sch files."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_exact_match_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wire_items = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert wire_items == [], "Wire should have been removed from the file"
|
||||
|
||||
def test_reverse_direction_match_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Pass end/start swapped – should still match
|
||||
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_within_tolerance_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
|
||||
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
|
||||
assert result is True
|
||||
|
||||
def test_outside_tolerance_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
|
||||
# tolerance=0.0 means exact float equality — may still match on most
|
||||
# platforms, but the key thing is that a *distant* miss is rejected
|
||||
sch2 = tmp_path / "test2.kicad_sch"
|
||||
sch2.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5)
|
||||
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance"
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
# Must parse without exception
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
def test_label_preserved_after_wire_deletion(self, tmp_path):
|
||||
"""Deleting a wire must not remove unrelated elements."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert len(labels) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteLabelIntegration:
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_deletes_label_by_name(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC")
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert labels == [], "Label should have been removed"
|
||||
|
||||
def test_deletes_label_with_matching_position(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
|
||||
assert result is True
|
||||
|
||||
def test_position_mismatch_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
|
||||
assert result is False
|
||||
|
||||
def test_wire_preserved_after_label_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wires = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert len(wires) == 1
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – handler parameter validation (via lightweight handler stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
# We test the validation logic of the new _handle_* methods without importing
|
||||
# the full kicad_interface module (which pulls in pcbnew and calls sys.exit).
|
||||
# Each handler is extracted as a standalone function for testing.
|
||||
|
||||
|
||||
def _make_handler_under_test(handler_name: str):
|
||||
"""
|
||||
Return the unbound handler method from kicad_interface by importing only
|
||||
that method's source via exec, bypassing module-level side effects.
|
||||
|
||||
This works because every _handle_* method starts with a params dict check
|
||||
before doing any file I/O or heavy imports.
|
||||
"""
|
||||
import importlib.util
|
||||
import types
|
||||
|
||||
# We monkey-patch sys.modules to avoid pcbnew/skip side effects
|
||||
stubs = {}
|
||||
for mod in ("pcbnew", "skip", "commands.schematic"):
|
||||
stubs[mod] = types.ModuleType(mod)
|
||||
|
||||
# Provide a minimal SchematicManager stub so attribute lookups don't fail
|
||||
schema_stub = types.ModuleType("commands.schematic")
|
||||
schema_stub.SchematicManager = MagicMock()
|
||||
stubs["commands.schematic"] = schema_stub
|
||||
|
||||
with patch.dict("sys.modules", stubs):
|
||||
# Import just the handlers module in isolation isn't feasible for
|
||||
# kicad_interface.py (module-level sys.exit). Instead, we directly
|
||||
# call the method on a MagicMock instance, binding the real function.
|
||||
pass
|
||||
|
||||
return None # Not used; see TestHandlerParamValidation below
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerParamValidation:
|
||||
"""
|
||||
Verify that each new handler returns success=False with an informative
|
||||
message when required parameters are missing, without needing real files.
|
||||
|
||||
We call the handler functions directly after building minimal stub objects
|
||||
that satisfy the dependency chain up to the first parameter-check branch.
|
||||
"""
|
||||
|
||||
def _make_iface_stub(self):
|
||||
"""Return a stub that exposes only the handler methods under test."""
|
||||
import importlib
|
||||
import types
|
||||
|
||||
# Build a minimal namespace that satisfies the imports inside each handler
|
||||
stub_mod = types.ModuleType("_handler_stubs")
|
||||
stub_mod.os = __import__("os")
|
||||
|
||||
class _Stub:
|
||||
pass
|
||||
|
||||
return _Stub()
|
||||
|
||||
# --- delete_schematic_wire ---
|
||||
|
||||
def test_delete_wire_missing_schematic_path(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
with patch.object(WireManager, "delete_wire", return_value=False):
|
||||
# Simulate the handler logic inline
|
||||
params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}}
|
||||
schematic_path = params.get("schematicPath")
|
||||
assert schematic_path is None
|
||||
# Handler should short-circuit before calling WireManager
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"]
|
||||
|
||||
# --- delete_schematic_net_label ---
|
||||
|
||||
def test_delete_label_missing_net_name(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
net_name = params.get("netName")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not net_name
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_delete_label_missing_schematic_path(self):
|
||||
params = {"netName": "VCC"}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_components ---
|
||||
|
||||
def test_list_components_missing_path(self):
|
||||
params = {}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"} if not schematic_path else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_nets ---
|
||||
|
||||
def test_list_nets_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_wires ---
|
||||
|
||||
def test_list_wires_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_labels ---
|
||||
|
||||
def test_list_labels_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- move_schematic_component ---
|
||||
|
||||
def test_move_component_missing_reference(self):
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"position": {"x": 10, "y": 20},
|
||||
}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_move_component_missing_position(self):
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"reference": "R1",
|
||||
"position": {},
|
||||
}
|
||||
new_x = params["position"].get("x")
|
||||
new_y = params["position"].get("y")
|
||||
result = (
|
||||
{"success": False, "message": "position with x and y is required"}
|
||||
if new_x is None or new_y is None
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- rotate_schematic_component ---
|
||||
|
||||
def test_rotate_component_missing_reference(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- annotate_schematic ---
|
||||
|
||||
def test_annotate_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- export_schematic_svg ---
|
||||
|
||||
def test_export_svg_missing_output_path(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and outputPath are required",
|
||||
}
|
||||
if not params.get("outputPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
"""
|
||||
Tests for schematic inspection and editing tools added in the schematic_tools branch.
|
||||
|
||||
Covers:
|
||||
- WireManager.delete_wire (unit + integration)
|
||||
- WireManager.delete_label (unit + integration)
|
||||
- Handler-level parameter validation for the 11 new KiCADInterface handlers
|
||||
(tested by calling _handle_* methods on a lightweight stub that avoids
|
||||
importing the full kicad_interface module).
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sexpdata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
|
||||
|
||||
# Minimal schematic content used by integration tests
|
||||
_WIRE_SCH = """\
|
||||
(kicad_sch (version 20250114) (generator "test")
|
||||
(uuid aaaaaaaa-0000-0000-0000-000000000000)
|
||||
(paper "A4")
|
||||
(wire (pts (xy 10 20) (xy 30 20))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid bbbbbbbb-0000-0000-0000-000000000001)
|
||||
)
|
||||
(label "VCC" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left bottom))
|
||||
(uuid cccccccc-0000-0000-0000-000000000002)
|
||||
)
|
||||
(sheet_instances (path "/" (page "1")))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _write_temp_sch(content: str) -> Path:
|
||||
"""Write *content* to a temp file and return its Path."""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteWireUnit:
|
||||
"""Unit-level tests for WireManager.delete_wire."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
|
||||
result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10])
|
||||
assert result is False
|
||||
|
||||
def test_no_matching_wire_returns_false(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
|
||||
assert result is False
|
||||
|
||||
def test_tolerance_argument_accepted(self, tmp_path: Any) -> None:
|
||||
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1)
|
||||
assert result is False # no wire in empty sch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteLabelUnit:
|
||||
"""Unit-level tests for WireManager.delete_label."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
|
||||
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
|
||||
assert result is False
|
||||
|
||||
def test_missing_label_returns_false(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "NONEXISTENT")
|
||||
assert result is False
|
||||
|
||||
def test_position_kwarg_accepted(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteWireIntegration:
|
||||
"""Integration tests that read/write real .kicad_sch files."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_exact_match_deletes_wire(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wire_items = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert wire_items == [], "Wire should have been removed from the file"
|
||||
|
||||
def test_reverse_direction_match_deletes_wire(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Pass end/start swapped – should still match
|
||||
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_within_tolerance_deletes_wire(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
|
||||
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
|
||||
assert result is True
|
||||
|
||||
def test_outside_tolerance_no_delete(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
|
||||
# tolerance=0.0 means exact float equality — may still match on most
|
||||
# platforms, but the key thing is that a *distant* miss is rejected
|
||||
sch2 = tmp_path / "test2.kicad_sch"
|
||||
sch2.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5)
|
||||
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance"
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
# Must parse without exception
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None:
|
||||
"""Deleting a wire must not remove unrelated elements."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert len(labels) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteLabelIntegration:
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_deletes_label_by_name(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC")
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert labels == [], "Label should have been removed"
|
||||
|
||||
def test_deletes_label_with_matching_position(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
|
||||
assert result is True
|
||||
|
||||
def test_position_mismatch_no_delete(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
|
||||
assert result is False
|
||||
|
||||
def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wires = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert len(wires) == 1
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – handler parameter validation (via lightweight handler stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
# We test the validation logic of the new _handle_* methods without importing
|
||||
# the full kicad_interface module (which pulls in pcbnew and calls sys.exit).
|
||||
# Each handler is extracted as a standalone function for testing.
|
||||
|
||||
|
||||
def _make_handler_under_test(handler_name: str) -> None:
|
||||
"""
|
||||
Return the unbound handler method from kicad_interface by importing only
|
||||
that method's source via exec, bypassing module-level side effects.
|
||||
|
||||
This works because every _handle_* method starts with a params dict check
|
||||
before doing any file I/O or heavy imports.
|
||||
"""
|
||||
import importlib.util
|
||||
import types
|
||||
|
||||
# We monkey-patch sys.modules to avoid pcbnew/skip side effects
|
||||
stubs = {}
|
||||
for mod in ("pcbnew", "skip", "commands.schematic"):
|
||||
stubs[mod] = types.ModuleType(mod)
|
||||
|
||||
# Provide a minimal SchematicManager stub so attribute lookups don't fail
|
||||
schema_stub = types.ModuleType("commands.schematic")
|
||||
schema_stub.SchematicManager = MagicMock()
|
||||
stubs["commands.schematic"] = schema_stub
|
||||
|
||||
with patch.dict("sys.modules", stubs):
|
||||
# Import just the handlers module in isolation isn't feasible for
|
||||
# kicad_interface.py (module-level sys.exit). Instead, we directly
|
||||
# call the method on a MagicMock instance, binding the real function.
|
||||
pass
|
||||
|
||||
return None # Not used; see TestHandlerParamValidation below
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerParamValidation:
|
||||
"""
|
||||
Verify that each new handler returns success=False with an informative
|
||||
message when required parameters are missing, without needing real files.
|
||||
|
||||
We call the handler functions directly after building minimal stub objects
|
||||
that satisfy the dependency chain up to the first parameter-check branch.
|
||||
"""
|
||||
|
||||
def _make_iface_stub(self) -> Any:
|
||||
"""Return a stub that exposes only the handler methods under test."""
|
||||
import importlib
|
||||
import types
|
||||
|
||||
# Build a minimal namespace that satisfies the imports inside each handler
|
||||
stub_mod = types.ModuleType("_handler_stubs")
|
||||
stub_mod.os = __import__("os")
|
||||
|
||||
class _Stub:
|
||||
pass
|
||||
|
||||
return _Stub()
|
||||
|
||||
# --- delete_schematic_wire ---
|
||||
|
||||
def test_delete_wire_missing_schematic_path(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
with patch.object(WireManager, "delete_wire", return_value=False):
|
||||
# Simulate the handler logic inline
|
||||
params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}}
|
||||
schematic_path = params.get("schematicPath")
|
||||
assert schematic_path is None
|
||||
# Handler should short-circuit before calling WireManager
|
||||
result: dict[str, Any] = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"]
|
||||
|
||||
# --- delete_schematic_net_label ---
|
||||
|
||||
def test_delete_label_missing_net_name(self) -> None:
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
net_name = params.get("netName")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not net_name
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_delete_label_missing_schematic_path(self) -> None:
|
||||
params = {"netName": "VCC"}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_components ---
|
||||
|
||||
def test_list_components_missing_path(self) -> None:
|
||||
params = {}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"} if not schematic_path else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_nets ---
|
||||
|
||||
def test_list_nets_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_wires ---
|
||||
|
||||
def test_list_wires_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_labels ---
|
||||
|
||||
def test_list_labels_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- move_schematic_component ---
|
||||
|
||||
def test_move_component_missing_reference(self) -> None:
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"position": {"x": 10, "y": 20},
|
||||
}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_move_component_missing_position(self) -> None:
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"reference": "R1",
|
||||
"position": {},
|
||||
}
|
||||
new_x = params["position"].get("x")
|
||||
new_y = params["position"].get("y")
|
||||
result = (
|
||||
{"success": False, "message": "position with x and y is required"}
|
||||
if new_x is None or new_y is None
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- rotate_schematic_component ---
|
||||
|
||||
def test_rotate_component_missing_reference(self) -> None:
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- annotate_schematic ---
|
||||
|
||||
def test_annotate_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- export_schematic_svg ---
|
||||
|
||||
def test_export_svg_missing_output_path(self) -> None:
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and outputPath are required",
|
||||
}
|
||||
if not params.get("outputPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
@@ -1,332 +1,332 @@
|
||||
"""
|
||||
Tests for the wire_connectivity module and the get_wire_connections handler.
|
||||
|
||||
Covers:
|
||||
- Schema shape (TestSchema)
|
||||
- Handler dispatch registration (TestHandlerDispatch)
|
||||
- Parameter validation in the handler (TestHandlerParamValidation)
|
||||
- Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires,
|
||||
get_wire_connections (TestCoreLogic)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the python package root is importable
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module under test
|
||||
# ---------------------------------------------------------------------------
|
||||
from commands.wire_connectivity import (
|
||||
_build_adjacency,
|
||||
_find_connected_wires,
|
||||
_parse_wires,
|
||||
_to_iu,
|
||||
get_wire_connections,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build minimal mock schematic objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_point(x: float, y: float) -> MagicMock:
|
||||
pt = MagicMock()
|
||||
pt.value = [x, y]
|
||||
return pt
|
||||
|
||||
|
||||
def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
|
||||
wire = MagicMock()
|
||||
wire.pts = MagicMock()
|
||||
wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)]
|
||||
return wire
|
||||
|
||||
|
||||
def _make_schematic(*wires) -> MagicMock:
|
||||
sch = MagicMock()
|
||||
sch.wire = list(wires)
|
||||
# No net labels, no symbols by default
|
||||
del sch.label # make hasattr(..., "label") return False
|
||||
del sch.symbol # make hasattr(..., "symbol") return False
|
||||
return sch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSchema:
|
||||
"""Verify the get_wire_connections tool schema is present and well-formed."""
|
||||
|
||||
def test_schema_registered(self):
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
assert "get_wire_connections" in TOOL_SCHEMAS
|
||||
|
||||
def test_schema_required_fields(self):
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
schema = TOOL_SCHEMAS["get_wire_connections"]
|
||||
required = schema["inputSchema"]["required"]
|
||||
assert "schematicPath" in required
|
||||
assert "x" in required
|
||||
assert "y" in required
|
||||
|
||||
def test_schema_has_title_and_description(self):
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
schema = TOOL_SCHEMAS["get_wire_connections"]
|
||||
assert schema.get("title")
|
||||
assert schema.get("description")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHandlerDispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerDispatch:
|
||||
"""Verify the handler is wired into KiCadInterface.command_routes."""
|
||||
|
||||
def test_get_wire_connections_in_routes(self):
|
||||
# Import lazily to avoid heavy side-effects at collection time
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
iface.board = None
|
||||
iface.project_filename = None
|
||||
iface.use_ipc = False
|
||||
iface.ipc_backend = MagicMock()
|
||||
iface.ipc_board_api = None
|
||||
iface.footprint_library = MagicMock()
|
||||
iface.project_commands = MagicMock()
|
||||
iface.board_commands = MagicMock()
|
||||
iface.component_commands = MagicMock()
|
||||
iface.routing_commands = MagicMock()
|
||||
|
||||
# Build routes only (avoid full __init__ side-effects)
|
||||
# The routes dict is built in __init__; we call it directly.
|
||||
iface.__init__()
|
||||
|
||||
assert "get_wire_connections" in iface.command_routes
|
||||
assert callable(iface.command_routes["get_wire_connections"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHandlerParamValidation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerParamValidation:
|
||||
"""Handler returns error responses for bad or missing parameters."""
|
||||
|
||||
def _make_handler(self):
|
||||
"""Return a bound _handle_get_wire_connections without full init."""
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_get_wire_connections
|
||||
|
||||
def test_missing_schematic_path(self):
|
||||
handler = self._make_handler()
|
||||
result = handler({"x": 1.0, "y": 2.0})
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"] or "Missing" in result["message"]
|
||||
|
||||
def test_missing_x(self):
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_y(self):
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_non_numeric_x(self):
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
|
||||
assert result["success"] is False
|
||||
assert "numeric" in result["message"].lower() or "x" in result["message"]
|
||||
|
||||
def test_non_numeric_y(self):
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCoreLogic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IU = 10_000 # IU per mm
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCoreLogic:
|
||||
"""Unit tests for the pure-logic functions in wire_connectivity."""
|
||||
|
||||
# --- _to_iu ---
|
||||
|
||||
def test_to_iu_integer_mm(self):
|
||||
assert _to_iu(1.0, 2.0) == (10_000, 20_000)
|
||||
|
||||
def test_to_iu_fractional_mm(self):
|
||||
assert _to_iu(0.5, 0.25) == (5_000, 2_500)
|
||||
|
||||
def test_to_iu_zero(self):
|
||||
assert _to_iu(0.0, 0.0) == (0, 0)
|
||||
|
||||
def test_to_iu_negative(self):
|
||||
assert _to_iu(-1.0, -2.0) == (-10_000, -20_000)
|
||||
|
||||
# --- _parse_wires ---
|
||||
|
||||
def test_parse_wires_single_wire(self):
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
result = _parse_wires(sch)
|
||||
assert len(result) == 1
|
||||
assert result[0] == [(0, 0), (10_000, 0)]
|
||||
|
||||
def test_parse_wires_empty_schematic(self):
|
||||
sch = MagicMock()
|
||||
sch.wire = []
|
||||
assert _parse_wires(sch) == []
|
||||
|
||||
def test_parse_wires_multiple_wires(self):
|
||||
sch = _make_schematic(
|
||||
_make_wire(0.0, 0.0, 1.0, 0.0),
|
||||
_make_wire(1.0, 0.0, 2.0, 0.0),
|
||||
)
|
||||
assert len(_parse_wires(sch)) == 2
|
||||
|
||||
def test_parse_wires_skips_wire_without_pts(self):
|
||||
bad_wire = MagicMock(spec=[]) # no `pts` attribute
|
||||
sch = MagicMock()
|
||||
sch.wire = [bad_wire]
|
||||
assert _parse_wires(sch) == []
|
||||
|
||||
# --- _build_adjacency ---
|
||||
|
||||
def test_build_adjacency_two_connected_wires(self):
|
||||
# wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0)
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
assert 1 in adjacency[0]
|
||||
assert 0 in adjacency[1]
|
||||
|
||||
def test_build_adjacency_two_disconnected_wires(self):
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
]
|
||||
adjacency, _ = _build_adjacency(wires)
|
||||
assert adjacency[0] == set()
|
||||
assert adjacency[1] == set()
|
||||
|
||||
def test_build_adjacency_iu_to_wires_maps_correctly(self):
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
]
|
||||
_, iu_to_wires = _build_adjacency(wires)
|
||||
assert iu_to_wires[(10_000, 0)] == {0, 1}
|
||||
assert iu_to_wires[(0, 0)] == {0}
|
||||
|
||||
def test_build_adjacency_three_wires_at_junction(self):
|
||||
# All three wires meet at (10,000, 0)
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
[(10_000, 0), (10_000, 10_000)],
|
||||
]
|
||||
adjacency, _ = _build_adjacency(wires)
|
||||
assert adjacency[0] == {1, 2}
|
||||
assert adjacency[1] == {0, 2}
|
||||
assert adjacency[2] == {0, 1}
|
||||
|
||||
# --- _find_connected_wires ---
|
||||
|
||||
def test_find_connected_wires_no_wire_at_point(self):
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited is None
|
||||
assert net_points is None
|
||||
|
||||
def test_find_connected_wires_single_wire(self):
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited == {0}
|
||||
assert (0, 0) in net_points
|
||||
assert (10_000, 0) in net_points
|
||||
|
||||
def test_find_connected_wires_flood_fills_chain(self):
|
||||
# Three wires in a chain: A-B-C-D
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited == {0, 1, 2}
|
||||
|
||||
def test_find_connected_wires_does_not_cross_gap(self):
|
||||
# Two disconnected segments; query on segment 0 should not reach segment 1
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited == {0}
|
||||
|
||||
# --- get_wire_connections (integration of internal functions) ---
|
||||
|
||||
def test_get_wire_connections_no_wires(self):
|
||||
sch = MagicMock()
|
||||
sch.wire = []
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result == {"pins": [], "wires": []}
|
||||
|
||||
def test_get_wire_connections_no_wire_at_point_returns_none(self):
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
|
||||
assert result is None
|
||||
|
||||
def test_get_wire_connections_returns_wire_data(self):
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
# Prevent _find_pins_on_net from iterating symbols
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result is not None
|
||||
assert result["pins"] == []
|
||||
assert len(result["wires"]) == 1
|
||||
wire = result["wires"][0]
|
||||
assert wire["start"] == {"x": 0.0, "y": 0.0}
|
||||
assert wire["end"] == {"x": 1.0, "y": 0.0}
|
||||
|
||||
def test_get_wire_connections_chain_returns_all_wires(self):
|
||||
sch = _make_schematic(
|
||||
_make_wire(0.0, 0.0, 1.0, 0.0),
|
||||
_make_wire(1.0, 0.0, 2.0, 0.0),
|
||||
)
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result is not None
|
||||
assert len(result["wires"]) == 2
|
||||
"""
|
||||
Tests for the wire_connectivity module and the get_wire_connections handler.
|
||||
|
||||
Covers:
|
||||
- Schema shape (TestSchema)
|
||||
- Handler dispatch registration (TestHandlerDispatch)
|
||||
- Parameter validation in the handler (TestHandlerParamValidation)
|
||||
- Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires,
|
||||
get_wire_connections (TestCoreLogic)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the python package root is importable
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module under test
|
||||
# ---------------------------------------------------------------------------
|
||||
from commands.wire_connectivity import (
|
||||
_build_adjacency,
|
||||
_find_connected_wires,
|
||||
_parse_wires,
|
||||
_to_iu,
|
||||
get_wire_connections,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build minimal mock schematic objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_point(x: float, y: float) -> MagicMock:
|
||||
pt = MagicMock()
|
||||
pt.value = [x, y]
|
||||
return pt
|
||||
|
||||
|
||||
def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
|
||||
wire = MagicMock()
|
||||
wire.pts = MagicMock()
|
||||
wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)]
|
||||
return wire
|
||||
|
||||
|
||||
def _make_schematic(*wires: Any) -> MagicMock:
|
||||
sch = MagicMock()
|
||||
sch.wire = list(wires)
|
||||
# No net labels, no symbols by default
|
||||
del sch.label # make hasattr(..., "label") return False
|
||||
del sch.symbol # make hasattr(..., "symbol") return False
|
||||
return sch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSchema:
|
||||
"""Verify the get_wire_connections tool schema is present and well-formed."""
|
||||
|
||||
def test_schema_registered(self) -> None:
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
assert "get_wire_connections" in TOOL_SCHEMAS
|
||||
|
||||
def test_schema_required_fields(self) -> None:
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
schema = TOOL_SCHEMAS["get_wire_connections"]
|
||||
required = schema["inputSchema"]["required"]
|
||||
assert "schematicPath" in required
|
||||
assert "x" in required
|
||||
assert "y" in required
|
||||
|
||||
def test_schema_has_title_and_description(self) -> None:
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
schema = TOOL_SCHEMAS["get_wire_connections"]
|
||||
assert schema.get("title")
|
||||
assert schema.get("description")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHandlerDispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerDispatch:
|
||||
"""Verify the handler is wired into KiCadInterface.command_routes."""
|
||||
|
||||
def test_get_wire_connections_in_routes(self) -> None:
|
||||
# Import lazily to avoid heavy side-effects at collection time
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
iface.board = None
|
||||
iface.project_filename = None
|
||||
iface.use_ipc = False
|
||||
iface.ipc_backend = MagicMock()
|
||||
iface.ipc_board_api = None
|
||||
iface.footprint_library = MagicMock()
|
||||
iface.project_commands = MagicMock()
|
||||
iface.board_commands = MagicMock()
|
||||
iface.component_commands = MagicMock()
|
||||
iface.routing_commands = MagicMock()
|
||||
|
||||
# Build routes only (avoid full __init__ side-effects)
|
||||
# The routes dict is built in __init__; we call it directly.
|
||||
KiCADInterface.__init__(iface)
|
||||
|
||||
assert "get_wire_connections" in iface.command_routes
|
||||
assert callable(iface.command_routes["get_wire_connections"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHandlerParamValidation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerParamValidation:
|
||||
"""Handler returns error responses for bad or missing parameters."""
|
||||
|
||||
def _make_handler(self) -> Any:
|
||||
"""Return a bound _handle_get_wire_connections without full init."""
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_get_wire_connections
|
||||
|
||||
def test_missing_schematic_path(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"x": 1.0, "y": 2.0})
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"] or "Missing" in result["message"]
|
||||
|
||||
def test_missing_x(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_y(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_non_numeric_x(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
|
||||
assert result["success"] is False
|
||||
assert "numeric" in result["message"].lower() or "x" in result["message"]
|
||||
|
||||
def test_non_numeric_y(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCoreLogic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IU = 10_000 # IU per mm
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCoreLogic:
|
||||
"""Unit tests for the pure-logic functions in wire_connectivity."""
|
||||
|
||||
# --- _to_iu ---
|
||||
|
||||
def test_to_iu_integer_mm(self) -> None:
|
||||
assert _to_iu(1.0, 2.0) == (10_000, 20_000)
|
||||
|
||||
def test_to_iu_fractional_mm(self) -> None:
|
||||
assert _to_iu(0.5, 0.25) == (5_000, 2_500)
|
||||
|
||||
def test_to_iu_zero(self) -> None:
|
||||
assert _to_iu(0.0, 0.0) == (0, 0)
|
||||
|
||||
def test_to_iu_negative(self) -> None:
|
||||
assert _to_iu(-1.0, -2.0) == (-10_000, -20_000)
|
||||
|
||||
# --- _parse_wires ---
|
||||
|
||||
def test_parse_wires_single_wire(self) -> None:
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
result = _parse_wires(sch)
|
||||
assert len(result) == 1
|
||||
assert result[0] == [(0, 0), (10_000, 0)]
|
||||
|
||||
def test_parse_wires_empty_schematic(self) -> None:
|
||||
sch = MagicMock()
|
||||
sch.wire = []
|
||||
assert _parse_wires(sch) == []
|
||||
|
||||
def test_parse_wires_multiple_wires(self) -> None:
|
||||
sch = _make_schematic(
|
||||
_make_wire(0.0, 0.0, 1.0, 0.0),
|
||||
_make_wire(1.0, 0.0, 2.0, 0.0),
|
||||
)
|
||||
assert len(_parse_wires(sch)) == 2
|
||||
|
||||
def test_parse_wires_skips_wire_without_pts(self) -> None:
|
||||
bad_wire = MagicMock(spec=[]) # no `pts` attribute
|
||||
sch = MagicMock()
|
||||
sch.wire = [bad_wire]
|
||||
assert _parse_wires(sch) == []
|
||||
|
||||
# --- _build_adjacency ---
|
||||
|
||||
def test_build_adjacency_two_connected_wires(self) -> None:
|
||||
# wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0)
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
assert 1 in adjacency[0]
|
||||
assert 0 in adjacency[1]
|
||||
|
||||
def test_build_adjacency_two_disconnected_wires(self) -> None:
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
]
|
||||
adjacency, _ = _build_adjacency(wires)
|
||||
assert adjacency[0] == set()
|
||||
assert adjacency[1] == set()
|
||||
|
||||
def test_build_adjacency_iu_to_wires_maps_correctly(self) -> None:
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
]
|
||||
_, iu_to_wires = _build_adjacency(wires)
|
||||
assert iu_to_wires[(10_000, 0)] == {0, 1}
|
||||
assert iu_to_wires[(0, 0)] == {0}
|
||||
|
||||
def test_build_adjacency_three_wires_at_junction(self) -> None:
|
||||
# All three wires meet at (10,000, 0)
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
[(10_000, 0), (10_000, 10_000)],
|
||||
]
|
||||
adjacency, _ = _build_adjacency(wires)
|
||||
assert adjacency[0] == {1, 2}
|
||||
assert adjacency[1] == {0, 2}
|
||||
assert adjacency[2] == {0, 1}
|
||||
|
||||
# --- _find_connected_wires ---
|
||||
|
||||
def test_find_connected_wires_no_wire_at_point(self) -> None:
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited is None
|
||||
assert net_points is None
|
||||
|
||||
def test_find_connected_wires_single_wire(self) -> None:
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited == {0}
|
||||
assert (0, 0) in net_points
|
||||
assert (10_000, 0) in net_points
|
||||
|
||||
def test_find_connected_wires_flood_fills_chain(self) -> None:
|
||||
# Three wires in a chain: A-B-C-D
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(10_000, 0), (20_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited == {0, 1, 2}
|
||||
|
||||
def test_find_connected_wires_does_not_cross_gap(self) -> None:
|
||||
# Two disconnected segments; query on segment 0 should not reach segment 1
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited == {0}
|
||||
|
||||
# --- get_wire_connections (integration of internal functions) ---
|
||||
|
||||
def test_get_wire_connections_no_wires(self) -> None:
|
||||
sch = MagicMock()
|
||||
sch.wire = []
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result == {"pins": [], "wires": []}
|
||||
|
||||
def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None:
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
|
||||
assert result is None
|
||||
|
||||
def test_get_wire_connections_returns_wire_data(self) -> None:
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
# Prevent _find_pins_on_net from iterating symbols
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result is not None
|
||||
assert result["pins"] == []
|
||||
assert len(result["wires"]) == 1
|
||||
wire = result["wires"][0]
|
||||
assert wire["start"] == {"x": 0.0, "y": 0.0}
|
||||
assert wire["end"] == {"x": 1.0, "y": 0.0}
|
||||
|
||||
def test_get_wire_connections_chain_returns_all_wires(self) -> None:
|
||||
sch = _make_schematic(
|
||||
_make_wire(0.0, 0.0, 1.0, 0.0),
|
||||
_make_wire(1.0, 0.0, 2.0, 0.0),
|
||||
)
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result is not None
|
||||
assert len(result["wires"]) == 2
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user