style: apply Black formatting to all Python files
Add [tool.black] config to pyproject.toml and Black hook to .pre-commit-config.yaml (rev 26.3.1), then auto-format all Python source and test files with line-length=100, target-version=py310. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ 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
|
||||
@@ -17,7 +18,7 @@ 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 = '''\
|
||||
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")
|
||||
@@ -34,11 +35,11 @@ PLACED_RESISTOR_INLINE = '''\
|
||||
(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 = '''\
|
||||
PLACED_RESISTOR_MULTILINE = """\
|
||||
\t(symbol
|
||||
\t\t(lib_id "Device:R")
|
||||
\t\t(at 50 50 0)
|
||||
@@ -64,10 +65,10 @@ PLACED_RESISTOR_MULTILINE = '''\
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
'''
|
||||
"""
|
||||
|
||||
# Multi-line power symbol – the exact scenario that was reported as broken.
|
||||
PLACED_POWER_SYMBOL_MULTILINE = '''\
|
||||
PLACED_POWER_SYMBOL_MULTILINE = """\
|
||||
\t(symbol
|
||||
\t\t(lib_id "power:VCC")
|
||||
\t\t(at 365.6 38.1 0)
|
||||
@@ -94,7 +95,7 @@ PLACED_POWER_SYMBOL_MULTILINE = '''\
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
||||
@@ -112,6 +113,7 @@ def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
||||
# 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."""
|
||||
@@ -154,10 +156,12 @@ class TestDeleteDetectionRegex:
|
||||
# Integration tests – real file I/O using the handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteSchematicComponentIntegration:
|
||||
def _get_handler(self):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_delete_schematic_component
|
||||
|
||||
|
||||
@@ -47,9 +47,7 @@ def reset_pcbnew_mock():
|
||||
@pytest.fixture
|
||||
def mock_board():
|
||||
board = MagicMock()
|
||||
board.GetFileName.return_value = (
|
||||
"/tmp/test_project/test.kicad_pcb"
|
||||
)
|
||||
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
|
||||
board.GetTracks.return_value = []
|
||||
return board
|
||||
|
||||
@@ -101,15 +99,14 @@ def _patch_no_runtime():
|
||||
|
||||
class TestCheckFreerouting:
|
||||
def test_no_java_no_docker(self, cmds):
|
||||
with patch(
|
||||
"commands.freerouting._find_java", return_value=None
|
||||
), patch(
|
||||
"commands.freerouting._docker_available",
|
||||
return_value=False,
|
||||
with (
|
||||
patch("commands.freerouting._find_java", return_value=None),
|
||||
patch(
|
||||
"commands.freerouting._docker_available",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
result = cmds.check_freerouting(
|
||||
{"freeroutingJar": "/nonexistent.jar"}
|
||||
)
|
||||
result = cmds.check_freerouting({"freeroutingJar": "/nonexistent.jar"})
|
||||
assert result["success"] is True
|
||||
assert result["java"]["found"] is False
|
||||
assert result["docker"]["available"] is False
|
||||
@@ -119,48 +116,46 @@ class TestCheckFreerouting:
|
||||
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
||||
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)}
|
||||
)
|
||||
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, tmp_path):
|
||||
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)}
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -198,9 +193,7 @@ class TestExportDsn:
|
||||
assert result["path"] == output
|
||||
|
||||
def test_export_failure(self, cmds):
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
||||
"DSN error"
|
||||
)
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
|
||||
result = cmds.export_dsn({})
|
||||
assert result["success"] is False
|
||||
assert "DSN error" in result["errorDetails"]
|
||||
@@ -213,9 +206,7 @@ class TestExportDsn:
|
||||
|
||||
class TestImportSes:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
result = cmds_no_board.import_ses(
|
||||
{"sesPath": "/tmp/test.ses"}
|
||||
)
|
||||
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
@@ -225,9 +216,7 @@ class TestImportSes:
|
||||
assert "Missing sesPath" in result["message"]
|
||||
|
||||
def test_ses_file_not_found(self, cmds):
|
||||
result = cmds.import_ses(
|
||||
{"sesPath": "/nonexistent/test.ses"}
|
||||
)
|
||||
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
@@ -245,9 +234,7 @@ class TestImportSes:
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
pcbnew_mock.ImportSpecctraSES.side_effect = Exception(
|
||||
"SES error"
|
||||
)
|
||||
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"]
|
||||
@@ -268,16 +255,12 @@ class TestAutoroute:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with _patch_no_runtime():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
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):
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": "/nonexistent/freerouting.jar"}
|
||||
)
|
||||
result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
|
||||
assert result["success"] is False
|
||||
assert "JAR not found" in result["message"]
|
||||
|
||||
@@ -286,21 +269,15 @@ class TestAutoroute:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
||||
"export fail"
|
||||
)
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("export fail")
|
||||
|
||||
with _patch_direct_java():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
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, cmds, tmp_path
|
||||
):
|
||||
def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
|
||||
import subprocess
|
||||
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
@@ -312,24 +289,19 @@ class TestAutoroute:
|
||||
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
|
||||
)
|
||||
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}
|
||||
)
|
||||
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, cmds, tmp_path
|
||||
):
|
||||
def test_full_success_direct(self, mock_run, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -341,12 +313,11 @@ class TestAutoroute:
|
||||
|
||||
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=""
|
||||
)
|
||||
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
|
||||
|
||||
@@ -357,9 +328,7 @@ class TestAutoroute:
|
||||
cmds.board.GetTracks.return_value = [track, track, via]
|
||||
|
||||
with _patch_direct_java():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "direct"
|
||||
@@ -368,9 +337,7 @@ class TestAutoroute:
|
||||
assert "elapsed_seconds" in result
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_docker(
|
||||
self, mock_run, cmds, tmp_path
|
||||
):
|
||||
def test_full_success_docker(self, mock_run, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -382,24 +349,24 @@ class TestAutoroute:
|
||||
|
||||
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=""
|
||||
)
|
||||
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",
|
||||
with (
|
||||
_patch_docker_mode(),
|
||||
patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value="/usr/bin/docker",
|
||||
),
|
||||
):
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "docker"
|
||||
@@ -409,9 +376,7 @@ class TestAutoroute:
|
||||
assert "--rm" in call_args
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_nonzero_exit(
|
||||
self, mock_run, cmds, tmp_path
|
||||
):
|
||||
def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -421,17 +386,14 @@ class TestAutoroute:
|
||||
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"
|
||||
)
|
||||
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)}
|
||||
)
|
||||
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||
|
||||
assert result["success"] is False
|
||||
assert "exited with code 1" in result["message"]
|
||||
@@ -451,10 +413,13 @@ class TestFindJava:
|
||||
assert _find_java() == "/usr/bin/java"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value=None,
|
||||
), patch("os.path.isfile", return_value=False):
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value=None,
|
||||
),
|
||||
patch("os.path.isfile", return_value=False),
|
||||
):
|
||||
assert _find_java() is None
|
||||
|
||||
|
||||
@@ -462,18 +427,14 @@ class TestFindDocker:
|
||||
def test_finds_docker(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/docker"
|
||||
if x == "docker"
|
||||
else None,
|
||||
side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/docker"
|
||||
|
||||
def test_finds_podman(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/podman"
|
||||
if x == "podman"
|
||||
else None,
|
||||
side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/podman"
|
||||
|
||||
@@ -487,12 +448,13 @@ class TestFindDocker:
|
||||
|
||||
class TestDockerAvailable:
|
||||
def test_docker_found(self):
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value="/usr/bin/docker",
|
||||
), patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
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
|
||||
|
||||
@@ -504,33 +466,26 @@ class TestDockerAvailable:
|
||||
assert _docker_available() is False
|
||||
|
||||
def test_docker_not_running(self):
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value="/usr/bin/docker",
|
||||
), patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
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):
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr='openjdk version "21.0.1"', stdout=""
|
||||
)
|
||||
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):
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr='openjdk version "17.0.18"', stdout=""
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -256,18 +256,14 @@ class TestFindOverlappingElements:
|
||||
|
||||
def test_overlapping_symbols_detected(self):
|
||||
# Two resistors at nearly the same position — bboxes fully overlap
|
||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
||||
"R2", 100.1, 100
|
||||
)
|
||||
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):
|
||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
||||
"R2", 200, 200
|
||||
)
|
||||
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
|
||||
@@ -294,9 +290,7 @@ class TestFindOverlappingElements:
|
||||
"""
|
||||
# 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
|
||||
)
|
||||
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, (
|
||||
@@ -310,9 +304,7 @@ class TestFindOverlappingElements:
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
@@ -419,9 +411,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
||||
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
|
||||
]
|
||||
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?'"
|
||||
@@ -458,9 +448,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
||||
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"
|
||||
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):
|
||||
"""Regression: a wire connecting pin1→pin2 of the same component
|
||||
@@ -825,9 +813,7 @@ class TestComputeSymbolBboxWithGraphics:
|
||||
}
|
||||
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
||||
|
||||
bbox = _compute_symbol_bbox_direct(
|
||||
sym, pin_defs, graphics_points=graphics_points
|
||||
)
|
||||
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
|
||||
@@ -902,9 +888,7 @@ class TestComputeSymbolBboxWithGraphics:
|
||||
# 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
|
||||
)
|
||||
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
|
||||
@@ -935,9 +919,7 @@ class TestIntegrationGraphicsBbox:
|
||||
|
||||
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
|
||||
|
||||
bbox = _compute_symbol_bbox_direct(
|
||||
r1, pin_defs, graphics_points=graphics_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
|
||||
|
||||
@@ -8,6 +8,7 @@ Covers:
|
||||
(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
|
||||
@@ -43,9 +44,7 @@ _WIRE_SCH = """\
|
||||
|
||||
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 = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
@@ -66,9 +65,7 @@ class TestDeleteWireUnit:
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_wire(
|
||||
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
|
||||
)
|
||||
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):
|
||||
@@ -100,9 +97,7 @@ class TestDeleteLabelUnit:
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_label(
|
||||
tmp_path / "nope.kicad_sch", "VCC"
|
||||
)
|
||||
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
|
||||
assert result is False
|
||||
|
||||
def test_missing_label_returns_false(self, tmp_path):
|
||||
@@ -114,9 +109,7 @@ class TestDeleteLabelUnit:
|
||||
def test_position_kwarg_accepted(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(
|
||||
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
|
||||
)
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
|
||||
assert result is False
|
||||
|
||||
|
||||
@@ -145,9 +138,7 @@ class TestDeleteWireIntegration:
|
||||
wire_items = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("wire")
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert wire_items == [], "Wire should have been removed from the file"
|
||||
|
||||
@@ -165,18 +156,14 @@ class TestDeleteWireIntegration:
|
||||
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
|
||||
)
|
||||
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
|
||||
assert result is True
|
||||
|
||||
def test_outside_tolerance_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(
|
||||
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
|
||||
)
|
||||
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"
|
||||
@@ -200,9 +187,7 @@ class TestDeleteWireIntegration:
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("label")
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert len(labels) == 1
|
||||
|
||||
@@ -230,9 +215,7 @@ class TestDeleteLabelIntegration:
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("label")
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert labels == [], "Label should have been removed"
|
||||
|
||||
@@ -247,9 +230,7 @@ class TestDeleteLabelIntegration:
|
||||
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
|
||||
)
|
||||
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):
|
||||
@@ -260,9 +241,7 @@ class TestDeleteLabelIntegration:
|
||||
wires = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("wire")
|
||||
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert len(wires) == 1
|
||||
|
||||
@@ -387,9 +366,7 @@ class TestHandlerParamValidation:
|
||||
params = {}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
{"success": False, "message": "schematicPath is required"} if not schematic_path else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@@ -265,18 +265,14 @@ class TestCoreLogic:
|
||||
def test_find_connected_wires_no_wire_at_point(self):
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(
|
||||
5.0, 0.0, wires, iu_to_wires, adjacency
|
||||
)
|
||||
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited is None
|
||||
assert net_points is None
|
||||
|
||||
def test_find_connected_wires_single_wire(self):
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(
|
||||
0.0, 0.0, wires, iu_to_wires, adjacency
|
||||
)
|
||||
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
|
||||
@@ -289,9 +285,7 @@ class TestCoreLogic:
|
||||
[(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
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -44,11 +44,7 @@ def _parse_sch(path: Path):
|
||||
|
||||
def _find_elements(sch_data, tag: str):
|
||||
"""Return all top-level S-expression elements with the given tag Symbol."""
|
||||
return [
|
||||
item
|
||||
for item in sch_data
|
||||
if isinstance(item, list) and item and item[0] == Symbol(tag)
|
||||
]
|
||||
return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -188,9 +184,7 @@ class TestHandleAddSchematicWireValidation:
|
||||
assert "Schematic path" in result["message"]
|
||||
|
||||
def test_missing_waypoints(self):
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{"schematicPath": "/tmp/x.kicad_sch"}
|
||||
)
|
||||
result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"})
|
||||
assert result["success"] is False
|
||||
assert "waypoint" in result["message"].lower()
|
||||
|
||||
@@ -334,9 +328,7 @@ class TestPinSnapping:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
skip_mod.Schematic = lambda path: type(
|
||||
"FakeSch", (), {"symbol": [FakeSymbol()]}
|
||||
)()
|
||||
skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})()
|
||||
|
||||
mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]}
|
||||
|
||||
@@ -350,9 +342,7 @@ class TestPinSnapping:
|
||||
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
|
||||
with patch(
|
||||
"commands.wire_manager.WireManager.add_wire", return_value=True
|
||||
) as mw:
|
||||
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
|
||||
result = iface._handle_add_schematic_wire(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -371,9 +361,10 @@ class TestPinSnapping:
|
||||
|
||||
def test_snap_disabled_passes_original_coords(self):
|
||||
"""With snapToPins=False the handler should not load PinLocator at all."""
|
||||
with patch(
|
||||
"commands.wire_manager.WireManager.add_wire", return_value=True
|
||||
) as mw, patch("commands.pin_locator.PinLocator") as mock_locator_cls:
|
||||
with (
|
||||
patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw,
|
||||
patch("commands.pin_locator.PinLocator") as mock_locator_cls,
|
||||
):
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -389,9 +380,7 @@ class TestPinSnapping:
|
||||
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
|
||||
def test_snap_miss_leaves_coords_unchanged(self, mock_wire):
|
||||
"""Point beyond tolerance should not be snapped."""
|
||||
with patch(
|
||||
"commands.wire_manager.WireManager.add_wire", return_value=True
|
||||
) as mw:
|
||||
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -409,9 +398,7 @@ class TestPinSnapping:
|
||||
def test_intermediate_waypoints_not_snapped(self, mock_poly):
|
||||
"""Middle waypoints must remain unchanged even with snapToPins=True."""
|
||||
mid = [50.0, 50.0]
|
||||
with patch(
|
||||
"commands.wire_manager.WireManager.add_polyline_wire", return_value=True
|
||||
) as mp:
|
||||
with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp:
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -451,9 +438,7 @@ class TestHandleAddSchematicJunction:
|
||||
assert "Schematic path" in result["message"]
|
||||
|
||||
def test_missing_position(self):
|
||||
result = self.iface._handle_add_schematic_junction(
|
||||
{"schematicPath": "/tmp/x.kicad_sch"}
|
||||
)
|
||||
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
|
||||
assert result["success"] is False
|
||||
assert "Position" in result["message"]
|
||||
|
||||
@@ -551,9 +536,7 @@ class TestIntegrationWireManager:
|
||||
assert ok is True
|
||||
data = _parse_sch(sch)
|
||||
wires = _find_elements(data, "wire")
|
||||
assert (
|
||||
len(wires) == 3
|
||||
), f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||
|
||||
def test_add_junction_writes_junction_element(self, sch):
|
||||
from commands.wire_manager import WireManager
|
||||
@@ -575,14 +558,10 @@ class TestIntegrationWireManager:
|
||||
data = _parse_sch(sch)
|
||||
wire = _find_elements(data, "wire")[0]
|
||||
pts = [
|
||||
item
|
||||
for item in wire
|
||||
if isinstance(item, list) and item and item[0] == Symbol("pts")
|
||||
item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts")
|
||||
][0]
|
||||
xy_entries = [
|
||||
item
|
||||
for item in pts
|
||||
if isinstance(item, list) and item and item[0] == Symbol("xy")
|
||||
item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy")
|
||||
]
|
||||
assert xy_entries[0][1] == 5.0
|
||||
assert xy_entries[0][2] == 7.5
|
||||
@@ -644,9 +623,7 @@ class TestIntegrationHandlerEndToEnd:
|
||||
assert result["success"] is True
|
||||
data = _parse_sch(self.sch)
|
||||
wires = _find_elements(data, "wire")
|
||||
assert (
|
||||
len(wires) == 3
|
||||
), f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -812,9 +789,7 @@ class TestMakeWireSexp:
|
||||
def test_custom_stroke(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
sexp = WireManager._make_wire_sexp(
|
||||
[0, 0], [5, 0], stroke_width=0.5, stroke_type="dash"
|
||||
)
|
||||
sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
|
||||
parsed = WireManager._parse_wire(sexp)
|
||||
assert parsed is not None
|
||||
_, _, width, stype = parsed
|
||||
@@ -922,9 +897,7 @@ class TestBreakWiresAtPoint:
|
||||
|
||||
data = [Symbol("kicad_sch")]
|
||||
data.append(
|
||||
WireManager._make_wire_sexp(
|
||||
[0, 0], [20, 0], stroke_width=0.5, stroke_type="dash"
|
||||
)
|
||||
WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash")
|
||||
)
|
||||
data.append([Symbol("sheet_instances")])
|
||||
splits = WireManager._break_wires_at_point(data, [10, 0])
|
||||
@@ -998,9 +971,7 @@ class TestIntegrationTJunction:
|
||||
WireManager.add_junction(sch, [15, 0])
|
||||
data = _parse_sch(sch)
|
||||
wires = _find_elements(data, "wire")
|
||||
assert (
|
||||
len(wires) == 2
|
||||
), f"Expected 2 wires after junction split, got {len(wires)}"
|
||||
assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}"
|
||||
junctions = _find_elements(data, "junction")
|
||||
assert len(junctions) == 1
|
||||
|
||||
@@ -1012,9 +983,7 @@ class TestIntegrationTJunction:
|
||||
WireManager.add_junction(sch, [20, 0])
|
||||
data = _parse_sch(sch)
|
||||
wires = _find_elements(data, "wire")
|
||||
assert (
|
||||
len(wires) == 1
|
||||
), f"Expected 1 wire (no split at endpoint), got {len(wires)}"
|
||||
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
|
||||
|
||||
def test_polyline_breaks_existing_wire(self, sch):
|
||||
"""Polyline whose start/end hits mid-wire should break it."""
|
||||
|
||||
Reference in New Issue
Block a user