refactor: consolidate python/tests/ into tests/ directory

Move all Python test files from python/tests/ to the top-level tests/
directory to match the project's CONTRIBUTING.md guidelines. Update
sys.path inserts and template path references to reflect the new
location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-12 11:22:39 +01:00
parent c92ca96bac
commit 5de932e2b3
11 changed files with 21 additions and 22 deletions

View File

@@ -1,42 +0,0 @@
"""
Test configuration for python/tests.
Sets up sys.modules stubs for heavy KiCAD modules (pcbnew, skip) before any
test module can trigger their import, preventing crashes on systems where the
real KiCAD environment is not fully initialised for testing.
"""
import sys
import types
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# pcbnew stub — kicad_interface.py accesses pcbnew.__file__ and
# pcbnew.GetBuildVersion() at module level. Use MagicMock so that any
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
# rather than raising AttributeError.
# ---------------------------------------------------------------------------
_pcbnew = MagicMock(name="pcbnew")
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
_pcbnew.__name__ = "pcbnew"
_pcbnew.__spec__ = None
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
sys.modules["pcbnew"] = _pcbnew
# ---------------------------------------------------------------------------
# Stub: skip (kicad-skip — use real module if available, stub otherwise)
# ---------------------------------------------------------------------------
try:
import skip as _skip_test # noqa: F401 — try importing real skip
except ImportError:
skip_mod = types.ModuleType("skip")
class _FakeSchematic:
"""Minimal stand-in for skip.Schematic used in PinLocator cache."""
def __init__(self, path: str):
self.path = path
self.symbol = []
skip_mod.Schematic = _FakeSchematic # type: ignore[attr-defined]
sys.modules["skip"] = skip_mod

View File

@@ -1,208 +0,0 @@
"""
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

@@ -1,496 +0,0 @@
"""
Tests for the Freerouting autoroute integration.
Covers:
- FreeroutingCommands.check_freerouting (dependency detection)
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
- FreeroutingCommands.import_ses (SES import via pcbnew)
- FreeroutingCommands.autoroute (full pipeline, direct + docker)
- Error handling: missing board, no runtime, missing JAR, timeouts
- _find_java, _docker_available, _java_version_ok helpers
"""
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from commands.freerouting import (
FreeroutingCommands,
_docker_available,
_find_docker,
_find_java,
_java_version_ok,
)
# pcbnew mock from conftest
pcbnew_mock = sys.modules["pcbnew"]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_pcbnew_mock() -> Any:
"""Reset pcbnew mock before each test."""
pcbnew_mock.reset_mock()
pcbnew_mock.ExportSpecctraDSN.side_effect = None
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
pcbnew_mock.ImportSpecctraSES.side_effect = None
pcbnew_mock.ImportSpecctraSES.return_value = MagicMock()
yield
@pytest.fixture
def mock_board() -> Any:
board = MagicMock()
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
board.GetTracks.return_value = []
return board
@pytest.fixture
def cmds(mock_board: Any) -> Any:
return FreeroutingCommands(board=mock_board)
@pytest.fixture
def cmds_no_board() -> Any:
return FreeroutingCommands(board=None)
def _patch_direct_java() -> Any:
"""Patch to simulate Java 21+ available locally."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={"mode": "direct", "use_docker": False},
)
def _patch_docker_mode() -> Any:
"""Patch to simulate Docker execution mode."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={"mode": "docker", "use_docker": True},
)
def _patch_no_runtime() -> Any:
"""Patch to simulate no Java and no Docker."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={
"mode": "error",
"error": "Neither Java 21+ nor Docker found.",
},
)
# ---------------------------------------------------------------------------
# check_freerouting
# ---------------------------------------------------------------------------
class TestCheckFreerouting:
def test_no_java_no_docker(self, cmds: Any) -> None:
with (
patch("commands.freerouting._find_java", return_value=None),
patch(
"commands.freerouting._docker_available",
return_value=False,
),
):
result = cmds.check_freerouting({"freeroutingJar": "/nonexistent.jar"})
assert result["success"] is True
assert result["java"]["found"] is False
assert result["docker"]["available"] is False
assert result["ready"] is False
assert result["execution_mode"] == "none"
def test_java_too_old_docker_available(self, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
with (
patch(
"commands.freerouting._find_java",
return_value="/usr/bin/java",
),
patch(
"commands.freerouting._java_version_ok",
return_value=False,
),
patch(
"commands.freerouting._docker_available",
return_value=True,
),
patch("commands.freerouting.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(stderr='openjdk version "17.0.1"', stdout="")
result = cmds.check_freerouting({"freeroutingJar": str(jar)})
assert result["ready"] is True
assert result["execution_mode"] == "docker"
def test_java_21_direct(self, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
with (
patch(
"commands.freerouting._find_java",
return_value="/usr/bin/java",
),
patch(
"commands.freerouting._java_version_ok",
return_value=True,
),
patch(
"commands.freerouting._docker_available",
return_value=False,
),
patch("commands.freerouting.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
result = cmds.check_freerouting({"freeroutingJar": str(jar)})
assert result["ready"] is True
assert result["execution_mode"] == "direct"
# ---------------------------------------------------------------------------
# export_dsn
# ---------------------------------------------------------------------------
class TestExportDsn:
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: 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
pcbnew_mock.ExportSpecctraDSN.return_value = True
Path(dsn_path).write_text("(pcb test)")
result = cmds.export_dsn({})
assert result["success"] is True
assert result["path"] == dsn_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)")
result = cmds.export_dsn({"outputPath": output})
assert result["success"] is True
assert result["path"] == output
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
assert "DSN error" in result["errorDetails"]
# ---------------------------------------------------------------------------
# import_ses
# ---------------------------------------------------------------------------
class TestImportSes:
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: 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: 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: Any, tmp_path: Any) -> None:
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = []
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is True
def test_import_failure(self, cmds: Any, tmp_path: Any) -> None:
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error")
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is False
assert "SES error" in result["errorDetails"]
# ---------------------------------------------------------------------------
# autoroute (full pipeline)
# ---------------------------------------------------------------------------
class TestAutoroute:
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: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
with _patch_no_runtime():
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is False
assert "No suitable Java runtime" in result["message"]
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: Any, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("export fail")
with _patch_direct_java():
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is False
assert "DSN export failed" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_freerouting_timeout(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
import subprocess
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
dsn_file.write_text("(pcb)"),
True,
)[1]
mock_run.side_effect = subprocess.TimeoutExpired(cmd="", timeout=10)
with _patch_direct_java():
result = cmds.autoroute({"freeroutingJar": str(jar), "timeout": 10})
assert result["success"] is False
assert "timed out" in result["message"]
@patch("commands.freerouting.subprocess.run")
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"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
ses_file = board_dir / "test.ses"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
dsn_file.write_text("(pcb)"),
True,
)[1]
mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="")
ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True
track = MagicMock()
track.GetClass.return_value = "PCB_TRACK"
via = MagicMock()
via.GetClass.return_value = "PCB_VIA"
cmds.board.GetTracks.return_value = [track, track, via]
with _patch_direct_java():
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is True
assert result["mode"] == "direct"
assert result["board_stats"]["tracks"] == 2
assert result["board_stats"]["vias"] == 1
assert "elapsed_seconds" in result
@patch("commands.freerouting.subprocess.run")
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"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
ses_file = board_dir / "test.ses"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
dsn_file.write_text("(pcb)"),
True,
)[1]
mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="")
ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = [MagicMock()]
with (
_patch_docker_mode(),
patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
),
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is True
assert result["mode"] == "docker"
# Verify a container runtime was invoked
call_args = mock_run.call_args[0][0]
assert "run" in call_args
assert "--rm" in call_args
@patch("commands.freerouting.subprocess.run")
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"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
dsn_file.write_text("(pcb)"),
True,
)[1]
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="OutOfMemoryError")
with _patch_direct_java():
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is False
assert "exited with code 1" in result["message"]
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
class TestFindJava:
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) -> None:
with (
patch(
"commands.freerouting.shutil.which",
return_value=None,
),
patch("os.path.isfile", return_value=False),
):
assert _find_java() is None
class TestFindDocker:
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) -> 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) -> None:
with patch(
"commands.freerouting.shutil.which",
return_value=None,
):
assert _find_docker() is None
class TestDockerAvailable:
def test_docker_found(self) -> None:
with (
patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
),
patch("commands.freerouting.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
assert _docker_available() is True
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) -> None:
with (
patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
),
patch("commands.freerouting.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=1)
assert _docker_available() is False
class TestJavaVersionOk:
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) -> 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) -> None:
with patch(
"commands.freerouting.subprocess.run",
side_effect=Exception("not found"),
):
assert _java_version_ok("/usr/bin/java") is False

File diff suppressed because it is too large Load Diff

View File

@@ -1,948 +0,0 @@
"""
Tests for schematic analysis tools (Tools 25).
Unit tests use mock data / synthetic S-expressions.
Integration tests parse real .kicad_sch files via sexpdata.
"""
import os
import shutil
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import sexpdata
from sexpdata import Symbol
# Ensure the python/ package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from commands.schematic_analysis import (
_aabb_overlap,
_check_wire_overlap,
_compute_symbol_bbox_direct,
_distance,
_extract_lib_symbols,
_line_segment_intersects_aabb,
_load_sexp,
_parse_labels,
_parse_lib_symbol_graphics,
_parse_symbols,
_parse_wires,
_point_in_rect,
_transform_local_point,
compute_symbol_bbox,
find_overlapping_elements,
find_wires_crossing_symbols,
get_elements_in_region,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch"
def _make_temp_schematic(extra_sexp: str = "") -> Path:
"""Copy empty.kicad_sch to a temp file and optionally append S-expression content."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
if extra_sexp:
content = tmp.read_text(encoding="utf-8")
# Insert before the final closing paren
idx = content.rfind(")")
content = content[:idx] + "\n" + extra_sexp + "\n)"
tmp.write_text(content, encoding="utf-8")
return tmp
import uuid as _uuid
def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
"""Generate a proper Device:R symbol S-expression that skip can parse."""
u = str(_uuid.uuid4())
return f"""
(symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "{u}")
(property "Reference" "{ref}" (at {x + 2.032} {y} 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at {x} {y} 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at {x - 1.778} {y} 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid "{_uuid.uuid4()}"))
(pin "2" (uuid "{_uuid.uuid4()}"))
(instances
(project "test"
(path "/" (reference "{ref}") (unit 1))
)
)
)
"""
def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
"""Generate a proper Device:LED symbol S-expression (horizontal pin spread)."""
u = str(_uuid.uuid4())
return f"""
(symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "{u}")
(property "Reference" "{ref}" (at {x} {y - 2.54} 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at {x} {y + 2.54} 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid "{_uuid.uuid4()}"))
(pin "2" (uuid "{_uuid.uuid4()}"))
(instances
(project "test"
(path "/" (reference "{ref}") (unit 1))
)
)
)
"""
# ===================================================================
# Unit tests — geometry helpers
# ===================================================================
class TestGeometryHelpers:
"""Test low-level geometry utilities."""
def test_point_in_rect_inside(self) -> None:
assert _point_in_rect(5, 5, 0, 0, 10, 10) is True
def test_point_in_rect_outside(self) -> None:
assert _point_in_rect(15, 5, 0, 0, 10, 10) is False
def test_point_in_rect_boundary(self) -> None:
assert _point_in_rect(0, 0, 0, 0, 10, 10) is True
def test_distance_zero(self) -> None:
assert _distance((0, 0), (0, 0)) == 0
def test_distance_unit(self) -> None:
assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9
def test_aabb_intersection_crossing(self) -> None:
# Line from (0,5) to (10,5) should intersect box (2,2)-(8,8)
assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True
def test_aabb_intersection_miss(self) -> None:
# Line from (0,0) to (10,0) should miss box (2,2)-(8,8)
assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False
def test_aabb_intersection_inside(self) -> None:
# Line entirely inside the box
assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True
def test_aabb_intersection_diagonal(self) -> None:
# Diagonal line crossing through box
assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True
def test_aabb_intersection_parallel_outside(self) -> None:
# Horizontal line above the box
assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False
def test_aabb_intersection_touching_edge(self) -> None:
# Line ending exactly at box edge
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
# ===================================================================
# Unit tests — S-expression parsers
# ===================================================================
class TestSexpParsers:
"""Test S-expression parsing functions with synthetic data."""
def test_parse_wires_basic(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 10 20) (xy 30 40))
(stroke (width 0) (type default))
(uuid "abc"))
)""")
wires = _parse_wires(sexp)
assert len(wires) == 1
assert wires[0]["start"] == (10.0, 20.0)
assert wires[0]["end"] == (30.0, 40.0)
def test_parse_wires_empty(self) -> None:
sexp = sexpdata.loads("(kicad_sch)")
assert _parse_wires(sexp) == []
def test_parse_labels_both_types(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(label "VCC" (at 10 20 0))
(global_label "GND" (at 30 40 0))
)""")
labels = _parse_labels(sexp)
assert len(labels) == 2
assert labels[0]["name"] == "VCC"
assert labels[0]["type"] == "label"
assert labels[1]["name"] == "GND"
assert labels[1]["type"] == "global_label"
def test_parse_symbols(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(symbol (lib_id "Device:R") (at 100 100 0)
(property "Reference" "R1" (at 0 0 0)))
(symbol (lib_id "power:VCC") (at 50 50 0)
(property "Reference" "#PWR01" (at 0 0 0)))
)""")
symbols = _parse_symbols(sexp)
assert len(symbols) == 2
assert symbols[0]["reference"] == "R1"
assert symbols[0]["is_power"] is False
assert symbols[1]["reference"] == "#PWR01"
assert symbols[1]["is_power"] is True
# ===================================================================
# Unit tests — analysis functions with mocked PinLocator
# ===================================================================
class TestAABBOverlap:
"""Test AABB overlap helper."""
def test_overlapping_boxes(self) -> None:
assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True
def test_non_overlapping_boxes(self) -> None:
assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False
def test_touching_boxes_no_overlap(self) -> None:
# Touching edges are not overlapping (strict inequality)
assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False
def test_contained_box(self) -> None:
assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True
def test_overlap_one_axis_only(self) -> None:
# Overlap in X but not Y
assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False
class TestFindOverlappingElements:
"""Test overlapping detection logic."""
def test_no_overlaps_in_empty_schematic(self) -> None:
tmp = _make_temp_schematic()
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_overlapping_symbols_detected(self) -> None:
# Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self) -> None:
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_collinear_wire_overlap(self) -> None:
extra = """
(wire (pts (xy 10 50) (xy 30 50))
(stroke (width 0) (type default))
(uuid "w1"))
(wire (pts (xy 20 50) (xy 40 50))
(stroke (width 0) (type default))
(uuid "w2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1
def test_overlapping_bodies_different_centers(self) -> None:
"""Two resistors whose bodies overlap even though centers are ~5mm apart.
Device:R pins are at y ±3.81 relative to center, so the body spans
~7.62mm vertically. Two resistors at the same X but 5mm apart in Y
have overlapping bodies — this is the bug the center-distance approach missed.
"""
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
# These overlap in Y from 101.19 to 103.81
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1, (
"Should detect overlap when component bodies intersect, "
"even if centers are far apart"
)
assert len(result["overlappingSymbols"]) >= 1
def test_adjacent_resistors_no_overlap(self) -> None:
"""Two vertical resistors side by side should not overlap.
R pins at y ±3.81, but different X positions far enough apart.
"""
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_resistor_and_led_overlapping_bodies(self) -> None:
"""A resistor and an LED placed close enough that bodies overlap.
LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position
as R — bodies clearly overlap.
"""
extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1
class TestGetElementsInRegion:
"""Test region query logic."""
def test_elements_inside_region_found(self) -> None:
extra = """
(symbol (lib_id "Device:R") (at 50 50 0)
(property "Reference" "R1" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
(wire (pts (xy 45 50) (xy 55 50))
(stroke (width 0) (type default))
(uuid "w1"))
(label "NET1" (at 50 50 0))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["symbols"] >= 1
assert result["counts"]["wires"] >= 1
assert result["counts"]["labels"] >= 1
def test_elements_outside_region_excluded(self) -> None:
extra = """
(symbol (lib_id "Device:R") (at 200 200 0)
(property "Reference" "R1" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 0, 0, 50, 50)
assert result["counts"]["symbols"] == 0
class TestComputeSymbolBbox:
"""Test bounding box computation."""
def test_returns_none_for_unknown_symbol(self) -> None:
tmp = _make_temp_schematic()
from commands.pin_locator import PinLocator
locator = PinLocator()
result = compute_symbol_bbox(tmp, "NONEXISTENT", locator)
assert result is None
# ===================================================================
# Integration tests — full schematic parsing
# ===================================================================
@pytest.mark.integration
class TestIntegrationFindWiresCrossingSymbols:
"""Integration test for wire crossing symbol detection."""
def test_wire_not_touching_pins_is_collision(self) -> None:
"""A wire passing through a component bbox without pin contact → collision."""
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
# Vertical wire from (100, 95) to (100, 105) crosses through the body
# without touching either horizontal pin
extra = _make_led_sexp("D1", 100, 100) + """
(wire (pts (xy 100 95) (xy 100 105))
(stroke (width 0) (type default))
(uuid "w1"))
"""
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_collisions) >= 1
def test_unannotated_duplicates_not_over_reported(self) -> None:
"""
Regression: two components with the same unannotated reference ("R?") at
different positions should each produce independent bounding boxes.
A wire crossing only one of them must produce exactly 1 collision, not 2.
Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to
the first match, so both symbols got identical bboxes and the same wire
was counted against both.
"""
# R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81).
# Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31].
# R? at (200, 100): identical type but far away → no intersection with wire.
r_at_100 = _make_resistor_sexp("R?", 100, 100)
r_at_200 = _make_resistor_sexp("R?", 200, 100)
# Horizontal wire crossing the body of the first R? only
wire = """
(wire (pts (xy 95 100) (xy 105 100))
(stroke (width 0) (type default))
(uuid "w-collision"))
"""
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
result = find_wires_crossing_symbols(tmp)
# The wire must not be reported against the far-away R? at (200, 100)
collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5]
assert len(collisions_at_200) == 0, (
"Wire at x≈100 must not be flagged against the R? at x=200; "
"likely caused by reference-lookup always returning the first 'R?'"
)
def test_wire_starting_at_pin_passing_through_body(self) -> None:
"""A wire that starts at a pin but continues through the component body
must be flagged — this is the core bug where the old suppression logic
treated any wire touching a pin as a valid connection."""
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
# Wire starts exactly at pin 1 and extends through the body to the right
extra = _make_led_sexp("D1", 100, 100) + """
(wire (pts (xy 96.19 100) (xy 110 100))
(stroke (width 0) (type default))
(uuid "w-through"))
"""
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert (
len(d1_crossings) >= 1
), "Wire starting at pin but passing through body must be detected"
def test_wire_terminating_at_pin_from_outside(self) -> None:
"""A wire that arrives at a pin from outside the component body
is a valid connection and must NOT be flagged."""
# LED D1 at (100,100) → pin 1 at (96.19, 100)
# Wire comes from the left and terminates at pin 1
extra = _make_led_sexp("D1", 100, 100) + """
(wire (pts (xy 80 100) (xy 96.19 100))
(stroke (width 0) (type default))
(uuid "w-valid"))
"""
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged"
def test_wire_shorts_component_pins_detected_as_collision(self) -> None:
"""Regression: a wire connecting pin1→pin2 of the same component
must be reported even though both endpoints land on pins."""
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
wire_sexp = (
"(wire (pts (xy 100 103.81) (xy 100 96.19))\n"
" (stroke (width 0) (type default))\n"
' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))'
)
sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp)
collisions = find_wires_crossing_symbols(sch)
assert len(collisions) == 1
w = collisions[0]["wire"]
assert w["start"]["x"] == pytest.approx(100.0)
assert w["start"]["y"] == pytest.approx(103.81)
assert collisions[0]["component"]["reference"] == "R_short"
@pytest.mark.integration
class TestIntegrationGetElementsInRegion:
"""Integration test for region query."""
def test_region_returns_pin_data(self) -> None:
"""Symbols in region should include pin position data."""
extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 90, 90, 110, 110)
assert result["counts"]["symbols"] == 1
sym = result["symbols"][0]
assert "pins" in sym
assert len(sym["pins"]) == 2 # Resistor has 2 pins
def test_wire_passing_through_region_included(self) -> None:
"""A wire that passes through a region (no endpoints inside) should be included."""
extra = """
(wire (pts (xy 0 50) (xy 100 50))
(stroke (width 0) (type default))
(uuid "w-through"))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["wires"] == 1
def test_wire_outside_region_excluded(self) -> None:
"""A wire entirely outside a region should not be included."""
extra = """
(wire (pts (xy 0 0) (xy 10 0))
(stroke (width 0) (type default))
(uuid "w-outside"))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["wires"] == 0
# ===================================================================
# Unit tests — _check_wire_overlap
# ===================================================================
class TestCheckWireOverlap:
"""Test wire overlap detection for horizontal, vertical, and diagonal cases."""
def test_horizontal_overlap(self) -> None:
w1 = {"start": (10, 50), "end": (30, 50)}
w2 = {"start": (20, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_vertical_overlap(self) -> None:
w1 = {"start": (50, 10), "end": (50, 30)}
w2 = {"start": (50, 20), "end": (50, 40)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_diagonal_overlap(self) -> None:
w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (10, 10), "end": (30, 30)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_horizontal_no_overlap(self) -> None:
w1 = {"start": (10, 50), "end": (20, 50)}
w2 = {"start": (30, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_parallel_offset_no_overlap(self) -> None:
"""Two parallel wires offset perpendicularly should not overlap."""
w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (0, 5), "end": (20, 25)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_non_parallel_no_overlap(self) -> None:
"""Two wires at different angles should not overlap."""
w1 = {"start": (0, 0), "end": (10, 10)}
w2 = {"start": (0, 0), "end": (10, 0)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_zero_length_segment(self) -> None:
w1 = {"start": (10, 10), "end": (10, 10)}
w2 = {"start": (10, 10), "end": (20, 20)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
@pytest.mark.integration
class TestIntegrationDiagonalWireOverlap:
"""Integration tests for diagonal collinear wire overlap detection."""
def test_diagonal_collinear_wire_overlap(self) -> None:
"""Two 45-degree wires that overlap should be detected."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w-diag1"))
(wire (pts (xy 10 10) (xy 30 30))
(stroke (width 0) (type default))
(uuid "w-diag2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1
def test_diagonal_parallel_no_overlap(self) -> None:
"""Two parallel 45-degree wires that are offset should not overlap."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w-diag1"))
(wire (pts (xy 0 5) (xy 20 25))
(stroke (width 0) (type default))
(uuid "w-diag2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) == 0
def test_diagonal_non_collinear_no_overlap(self) -> None:
"""Two wires at different angles crossing should not be flagged as collinear overlap."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w-diag1"))
(wire (pts (xy 0 20) (xy 20 0))
(stroke (width 0) (type default))
(uuid "w-diag2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) == 0
# ===================================================================
# Unit tests — _extract_lib_symbols
# ===================================================================
class TestExtractLibSymbols:
"""Test _extract_lib_symbols helper."""
def test_extracts_pins_from_lib_symbols(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(lib_symbols
(symbol "Device:R"
(symbol "Device:R_0_1"
(pin passive (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))
(pin passive (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27)))))))
)
)""")
result = _extract_lib_symbols(sexp)
assert "Device:R" in result
pins = result["Device:R"]["pins"]
assert "1" in pins
assert "2" in pins
assert pins["1"]["y"] == pytest.approx(3.81)
def test_empty_schematic_returns_empty(self) -> None:
sexp = sexpdata.loads("(kicad_sch)")
result = _extract_lib_symbols(sexp)
assert result == {}
def test_no_lib_symbols_section(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 0 0) (xy 10 10)))
)""")
result = _extract_lib_symbols(sexp)
assert result == {}
def test_extract_includes_graphics_points(self) -> None:
"""_extract_lib_symbols should return graphics_points from body shapes."""
sexp = sexpdata.loads("""(kicad_sch
(lib_symbols
(symbol "Device:R"
(symbol "Device:R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none))))
(symbol "Device:R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27)))))))
)
)""")
result = _extract_lib_symbols(sexp)
lib_data = result["Device:R"]
assert "graphics_points" in lib_data
gfx = lib_data["graphics_points"]
assert len(gfx) >= 2
# Rectangle corners should be present
xs = [p[0] for p in gfx]
ys = [p[1] for p in gfx]
assert pytest.approx(-1.016) in xs
assert pytest.approx(1.016) in xs
assert pytest.approx(-2.54) in ys
assert pytest.approx(2.54) in ys
# ===================================================================
# Unit tests — _parse_lib_symbol_graphics
# ===================================================================
class TestParseLibSymbolGraphics:
"""Test graphics extraction from lib_symbol definitions."""
def test_rectangle(self) -> None:
sexp = sexpdata.loads("""(symbol "Device:R"
(symbol "Device:R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert len(pts) == 2
assert (-1.016, -2.54) in pts
assert (1.016, 2.54) in pts
def test_polyline(self) -> None:
sexp = sexpdata.loads("""(symbol "Device:C"
(symbol "Device:C_0_1"
(polyline
(pts (xy -2.032 -0.762) (xy 2.032 -0.762))
(stroke (width 0.508) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert (-2.032, -0.762) in pts
assert (2.032, -0.762) in pts
def test_circle(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Circle"
(symbol "Test:Circle_0_1"
(circle (center 0 0) (radius 5)
(stroke (width 0.254) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert len(pts) == 2
assert (-5.0, -5.0) in pts
assert (5.0, 5.0) in pts
def test_arc(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Arc"
(symbol "Test:Arc_0_1"
(arc (start 1 0) (mid 0 1) (end -1 0)
(stroke (width 0.254) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert (1.0, 0.0) in pts
assert (0.0, 1.0) in pts
assert (-1.0, 0.0) in pts
def test_no_graphics(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Empty"
(symbol "Test:Empty_1_1"
(pin passive line (at 0 0 0) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert pts == []
# ===================================================================
# Unit tests — _transform_local_point
# ===================================================================
class TestTransformLocalPoint:
"""Test local→absolute coordinate transform."""
def test_no_transform(self) -> None:
# ly is negated (lib y-up → schematic y-down)
x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
assert x == pytest.approx(101.0)
assert y == pytest.approx(198.0)
def test_mirror_x(self) -> None:
# y-negate then mirror_x cancel out → net ly unchanged
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
assert x == pytest.approx(1.0)
assert y == pytest.approx(2.0)
def test_mirror_y(self) -> None:
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
assert x == pytest.approx(-1.0)
assert y == pytest.approx(-2.0)
def test_rotation_90(self) -> None:
# ly=0 negated is still 0, then rotate lx=1 by 90°
x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False)
assert x == pytest.approx(0.0, abs=1e-9)
assert y == pytest.approx(1.0, abs=1e-9)
# ===================================================================
# Unit tests — _compute_symbol_bbox_direct with graphics
# ===================================================================
class TestComputeSymbolBboxWithGraphics:
"""Test that bounding box computation uses graphics points when available."""
def test_resistor_bbox_from_graphics(self) -> None:
"""Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords.
Pins at (0, ±3.81). Placed at (100, 100) with no rotation.
Bbox should span from pin-to-pin in Y and use rectangle width in X."""
sym = {
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = {
"1": {
"x": 0,
"y": 3.81,
"angle": 270,
"length": 1.27,
"name": "~",
"type": "passive",
},
"2": {
"x": 0,
"y": -3.81,
"angle": 90,
"length": 1.27,
"name": "~",
"type": "passive",
},
}
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# X should come from rectangle: 100 ± 1.016
assert min_x == pytest.approx(100 - 1.016)
assert max_x == pytest.approx(100 + 1.016)
# Y should come from pins (extending beyond rectangle): 100 ± 3.81
assert min_y == pytest.approx(100 - 3.81)
assert max_y == pytest.approx(100 + 3.81)
def test_fallback_without_graphics(self) -> None:
"""Without graphics_points, should use the old degenerate expansion."""
sym = {
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = {
"1": {
"x": 0,
"y": 3.81,
"angle": 270,
"length": 1.27,
"name": "~",
"type": "passive",
},
"2": {
"x": 0,
"y": -3.81,
"angle": 90,
"length": 1.27,
"name": "~",
"type": "passive",
},
}
bbox = _compute_symbol_bbox_direct(sym, pin_defs)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# X should be expanded with min_body=1.5: 100 ± 1.5
assert min_x == pytest.approx(100 - 1.5)
assert max_x == pytest.approx(100 + 1.5)
def test_rotated_symbol_graphics(self) -> None:
"""Graphics points should be rotated along with the symbol."""
sym = {
"x": 100.0,
"y": 100.0,
"rotation": 90,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = {
"1": {
"x": 0,
"y": 3.81,
"angle": 270,
"length": 1.27,
"name": "~",
"type": "passive",
},
"2": {
"x": 0,
"y": -3.81,
"angle": 90,
"length": 1.27,
"name": "~",
"type": "passive",
},
}
# Rectangle corners in local coords
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# After 90° rotation, X and Y swap roles
# Pins now extend along X: 100 ± 3.81
# Rectangle now extends along Y: 100 ± 1.016
assert min_x == pytest.approx(100 - 3.81, abs=0.01)
assert max_x == pytest.approx(100 + 3.81, abs=0.01)
@pytest.mark.integration
class TestIntegrationGraphicsBbox:
"""Integration tests verifying graphics-based bbox from real template data."""
def test_resistor_bbox_uses_rectangle(self) -> None:
"""The template's Device:R has a rectangle body.
Verify that the bbox for a placed resistor uses the actual
rectangle width rather than the degenerate 1.5mm expansion."""
extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra)
sexp_data = _load_sexp(tmp)
symbols = _parse_symbols(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
r1 = [s for s in symbols if s["reference"] == "R1"][0]
lib_data = lib_defs.get(r1["lib_id"], {})
pin_defs = lib_data.get("pins", {})
graphics_points = lib_data.get("graphics_points", [])
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01)
def test_led_bbox_uses_polyline(self) -> None:
"""The template's Device:LED uses polylines for its body.
Verify that the bbox uses polyline extents."""
extra = _make_led_sexp("D1", 100, 100)
tmp = _make_temp_schematic(extra)
sexp_data = _load_sexp(tmp)
symbols = _parse_symbols(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
d1 = [s for s in symbols if s["reference"] == "D1"][0]
lib_data = lib_defs.get(d1["lib_id"], {})
graphics_points = lib_data.get("graphics_points", [])
assert len(graphics_points) >= 4, "Should have extracted polyline points"
# LED body polylines span from -1.27 to 1.27 in both X and Y
xs = [p[0] for p in graphics_points]
ys = [p[1] for p in graphics_points]
assert min(xs) == pytest.approx(-1.27)
assert max(xs) == pytest.approx(1.27)
assert min(ys) == pytest.approx(-1.27)
assert max(ys) == pytest.approx(1.27)

View File

@@ -1,370 +0,0 @@
"""
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,477 +0,0 @@
"""
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 +0,0 @@
"""
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