Add isort configuration (profile=black, line_length=100) to pyproject.toml, add isort pre-commit hook, and auto-sort imports across all Python source files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
16 KiB
Python
496 lines
16 KiB
Python
"""
|
|
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 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():
|
|
"""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():
|
|
board = MagicMock()
|
|
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
|
|
board.GetTracks.return_value = []
|
|
return board
|
|
|
|
|
|
@pytest.fixture
|
|
def cmds(mock_board):
|
|
return FreeroutingCommands(board=mock_board)
|
|
|
|
|
|
@pytest.fixture
|
|
def cmds_no_board():
|
|
return FreeroutingCommands(board=None)
|
|
|
|
|
|
def _patch_direct_java():
|
|
"""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():
|
|
"""Patch to simulate Docker execution mode."""
|
|
return patch.object(
|
|
FreeroutingCommands,
|
|
"_resolve_execution_mode",
|
|
return_value={"mode": "docker", "use_docker": True},
|
|
)
|
|
|
|
|
|
def _patch_no_runtime():
|
|
"""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):
|
|
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, 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)})
|
|
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)})
|
|
assert result["ready"] is True
|
|
assert result["execution_mode"] == "direct"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# export_dsn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExportDsn:
|
|
def test_no_board(self, cmds_no_board):
|
|
result = cmds_no_board.export_dsn({})
|
|
assert result["success"] is False
|
|
assert "No board" in result["message"]
|
|
|
|
def test_export_success(self, cmds, tmp_path):
|
|
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, tmp_path):
|
|
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):
|
|
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):
|
|
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):
|
|
result = cmds.import_ses({})
|
|
assert result["success"] is False
|
|
assert "Missing sesPath" in result["message"]
|
|
|
|
def test_ses_file_not_found(self, cmds):
|
|
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
|
|
assert result["success"] is False
|
|
assert "not found" in result["message"]
|
|
|
|
def test_import_success(self, cmds, tmp_path):
|
|
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, tmp_path):
|
|
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):
|
|
result = cmds_no_board.autoroute({})
|
|
assert result["success"] is False
|
|
assert "No board" in result["message"]
|
|
|
|
def test_no_runtime(self, cmds, tmp_path):
|
|
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):
|
|
result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
|
|
assert result["success"] is False
|
|
assert "JAR not found" in result["message"]
|
|
|
|
@patch("commands.freerouting.subprocess.run")
|
|
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
|
|
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, cmds, tmp_path):
|
|
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, cmds, tmp_path):
|
|
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, cmds, tmp_path):
|
|
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, cmds, tmp_path):
|
|
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):
|
|
with patch(
|
|
"commands.freerouting.shutil.which",
|
|
return_value="/usr/bin/java",
|
|
):
|
|
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),
|
|
):
|
|
assert _find_java() is None
|
|
|
|
|
|
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,
|
|
):
|
|
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,
|
|
):
|
|
assert _find_docker() == "/usr/bin/podman"
|
|
|
|
def test_none_when_not_found(self):
|
|
with patch(
|
|
"commands.freerouting.shutil.which",
|
|
return_value=None,
|
|
):
|
|
assert _find_docker() is None
|
|
|
|
|
|
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,
|
|
):
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
assert _docker_available() is True
|
|
|
|
def test_docker_not_installed(self):
|
|
with patch(
|
|
"commands.freerouting._find_docker",
|
|
return_value=None,
|
|
):
|
|
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,
|
|
):
|
|
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="")
|
|
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="")
|
|
assert _java_version_ok("/usr/bin/java") is False
|
|
|
|
def test_java_error(self):
|
|
with patch(
|
|
"commands.freerouting.subprocess.run",
|
|
side_effect=Exception("not found"),
|
|
):
|
|
assert _java_version_ok("/usr/bin/java") is False
|