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:
Eugene Mikhantyev
2026-04-05 23:48:35 +01:00
parent 5a2b481db3
commit 9b1024a8f3
35 changed files with 4665 additions and 4610 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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