Add Docker/Podman support for Freerouting autorouter

Auto-detects runtime: Java 21+ direct or Docker/Podman fallback.
Discovers docker/podman via PATH instead of hardcoding.
README includes one-time setup with JAR download + Docker pull.
31 tests covering both execution modes.
This commit is contained in:
Jeff Laflamme
2026-03-20 12:01:19 +07:00
parent 53a8b3ff3e
commit 7f1b072d00
3 changed files with 518 additions and 135 deletions

View File

@@ -5,8 +5,9 @@ Covers:
- FreeroutingCommands.check_freerouting (dependency detection)
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
- FreeroutingCommands.import_ses (SES import via pcbnew)
- FreeroutingCommands.autoroute (full pipeline)
- Error handling: missing board, missing java, missing JAR, timeouts
- 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
@@ -15,10 +16,15 @@ from unittest.mock import MagicMock, patch
import pytest
from commands.freerouting import FreeroutingCommands, _find_java
from commands.freerouting import (
FreeroutingCommands,
_find_java,
_find_docker,
_docker_available,
_java_version_ok,
)
# Ensure the pcbnew mock from conftest is available at module level
# so methods that do `import pcbnew` get the mock.
# pcbnew mock from conftest
pcbnew_mock = sys.modules["pcbnew"]
@@ -31,7 +37,6 @@ pcbnew_mock = sys.modules["pcbnew"]
def reset_pcbnew_mock():
"""Reset pcbnew mock before each test."""
pcbnew_mock.reset_mock()
# Clear side_effects that persist through reset_mock
pcbnew_mock.ExportSpecctraDSN.side_effect = None
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
pcbnew_mock.ImportSpecctraSES.side_effect = None
@@ -41,65 +46,88 @@ def reset_pcbnew_mock():
@pytest.fixture
def mock_board():
"""Create a mock pcbnew.BOARD with minimal interface."""
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
@pytest.fixture
def cmds(mock_board):
"""FreeroutingCommands with a mock board."""
return FreeroutingCommands(board=mock_board)
@pytest.fixture
def cmds_no_board():
"""FreeroutingCommands without a 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_java_not_found(self, cmds):
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["freerouting"]["jar_found"] is False
assert result["docker"]["available"] is False
assert result["ready"] is False
assert result["execution_mode"] == "none"
def test_java_found_no_jar(self, cmds):
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
), 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": "/nonexistent.jar"}
)
assert result["java"]["found"] is True
assert result["java"]["path"] == "/usr/bin/java"
assert result["freerouting"]["jar_found"] is False
assert result["ready"] is False
def test_all_ready(self, cmds, tmp_path):
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"
"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:
@@ -110,6 +138,31 @@ class TestCheckFreerouting:
{"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"
# ---------------------------------------------------------------------------
@@ -129,15 +182,11 @@ class TestExportDsn:
cmds.board.GetFileName.return_value = board_path
pcbnew_mock.ExportSpecctraDSN.return_value = True
# Simulate DSN file creation
Path(dsn_path).write_text("(pcb test)")
result = cmds.export_dsn({})
assert result["success"] is True
assert result["path"] == dsn_path
pcbnew_mock.ExportSpecctraDSN.assert_called_once_with(
cmds.board, dsn_path
)
def test_export_custom_path(self, cmds, tmp_path):
output = str(tmp_path / "custom.dsn")
@@ -149,7 +198,9 @@ 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"]
@@ -162,7 +213,9 @@ 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"]
@@ -172,7 +225,9 @@ 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"]
@@ -185,15 +240,14 @@ class TestImportSes:
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is True
pcbnew_mock.ImportSpecctraSES.assert_called_once_with(
cmds.board, str(ses_file)
)
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")
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"]
@@ -210,21 +264,20 @@ class TestAutoroute:
assert result["success"] is False
assert "No board" in result["message"]
def test_no_java(self, cmds):
with patch(
"commands.freerouting._find_java", return_value=None
):
result = cmds.autoroute({})
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 "Java not found" in result["message"]
assert "No suitable Java runtime" in result["message"]
def test_no_jar(self, cmds):
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
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"]
@@ -237,15 +290,17 @@ class TestAutoroute:
"export fail"
)
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
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):
def test_freerouting_timeout(
self, mock_run, cmds, tmp_path
):
import subprocess
jar = tmp_path / "freerouting.jar"
@@ -264,9 +319,7 @@ class TestAutoroute:
cmd="", timeout=10
)
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
with _patch_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar), "timeout": 10}
)
@@ -274,7 +327,9 @@ class TestAutoroute:
assert "timed out" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_full_success(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"
@@ -286,37 +341,72 @@ class TestAutoroute:
cmds.board.GetFileName.return_value = str(board_file)
# DSN export creates file
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
# Freerouting creates SES file
mock_run.return_value = MagicMock(
returncode=0, stdout="Routing completed", stderr=""
)
ses_file.write_text("(session)")
# SES import succeeds
pcbnew_mock.ImportSpecctraSES.return_value = True
# Board tracks after import
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(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
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
pcbnew_mock.ExportSpecctraDSN.assert_called_once()
pcbnew_mock.ImportSpecctraSES.assert_called_once()
@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(
@@ -338,17 +428,17 @@ class TestAutoroute:
returncode=1, stdout="", stderr="OutOfMemoryError"
)
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
with _patch_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is False
assert "exited with code 1" in result["message"]
# ---------------------------------------------------------------------------
# _find_java helper
# Helper functions
# ---------------------------------------------------------------------------
@@ -362,6 +452,90 @@ class TestFindJava:
def test_none_when_not_found(self):
with patch(
"commands.freerouting.shutil.which", return_value=None
"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