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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -1,948 +0,0 @@
|
||||
"""
|
||||
Tests for schematic analysis tools (Tools 2–5).
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user