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

@@ -3,6 +3,10 @@ Freerouting autoroute integration for KiCAD MCP Server.
Exports the board to Specctra DSN format, runs Freerouting CLI,
and imports the routed SES file back into the board.
Supports two execution modes:
- Direct: java -jar freerouting.jar (requires Java 21+)
- Docker: docker run eclipse-temurin:21-jre (requires Docker)
"""
import os
@@ -11,23 +15,26 @@ import shutil
import time
import logging
from pathlib import Path
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, List
logger = logging.getLogger("kicad_interface")
# Default Freerouting JAR location
DEFAULT_FREEROUTING_JAR = os.environ.get(
"FREEROUTING_JAR",
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
os.path.join(
os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"
),
)
DOCKER_IMAGE = "eclipse-temurin:21-jre"
def _find_java() -> Optional[str]:
"""Find java executable on the system."""
java = shutil.which("java")
if java:
return java
# Check common locations
for candidate in [
"/usr/bin/java",
"/usr/local/bin/java",
@@ -38,12 +45,118 @@ def _find_java() -> Optional[str]:
return None
def _find_docker() -> Optional[str]:
"""Find docker executable on the system."""
return shutil.which("docker") or shutil.which("podman")
def _docker_available() -> bool:
"""Check if Docker/Podman is available and running."""
docker = _find_docker()
if not docker:
return False
try:
proc = subprocess.run(
[docker, "info"],
capture_output=True,
timeout=10,
)
return proc.returncode == 0
except Exception:
return False
def _java_version_ok(java_exe: str) -> bool:
"""Check if local Java is version 21+."""
try:
proc = subprocess.run(
[java_exe, "-version"],
capture_output=True,
text=True,
timeout=10,
)
output = proc.stderr or proc.stdout
# Parse version like: openjdk version "17.0.18"
for line in output.split("\n"):
if "version" in line:
ver = line.split('"')[1] if '"' in line else ""
major = int(ver.split(".")[0])
return major >= 21
except Exception:
pass
return False
def _build_freerouting_cmd(
jar_path: str,
dsn_path: str,
ses_path: str,
passes: int,
use_docker: bool,
) -> List[str]:
"""Build the command to run Freerouting."""
if use_docker:
docker_exe = _find_docker()
board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path)
jar_name = os.path.basename(jar_path)
return [
docker_exe, "run", "--rm",
"-v", f"{jar_path}:/app/{jar_name}:ro",
"-v", f"{board_dir}:/work",
DOCKER_IMAGE,
"java", "-jar", f"/app/{jar_name}",
"-de", f"/work/{dsn_name}",
"-do", f"/work/{ses_name}",
"-mp", str(passes),
]
else:
java_exe = _find_java()
return [
java_exe, "-jar", jar_path,
"-de", dsn_path, "-do", ses_path,
"-mp", str(passes),
]
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board=None):
self.board = board
def _resolve_execution_mode(
self, jar_path: str
) -> Dict[str, Any]:
"""Determine how to run Freerouting: direct or docker.
Returns dict with 'mode', 'use_docker', or 'error'.
"""
java_exe = _find_java()
if java_exe and _java_version_ok(java_exe):
return {"mode": "direct", "use_docker": False}
if _docker_available():
return {"mode": "docker", "use_docker": True}
if java_exe:
return {
"mode": "error",
"error": (
f"Java found at {java_exe} but version < 21. "
"Freerouting 2.x requires Java 21+. "
"Install Java 21+ or Docker."
),
}
return {
"mode": "error",
"error": (
"Neither Java 21+ nor Docker found. "
"Install one of them to use Freerouting."
),
}
def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Freerouting autorouter on the current board.
@@ -77,25 +190,17 @@ class FreeroutingCommands:
return {
"success": False,
"message": "No board file path available",
"errorDetails": "Provide boardPath or open a project first",
}
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
timeout = params.get("timeout", 300)
passes = params.get("maxPasses")
# Validate java
java_exe = _find_java()
if not java_exe:
return {
"success": False,
"message": "Java not found",
"errorDetails": (
"Freerouting requires Java. Install Java 11+ or set "
"JAVA_HOME environment variable."
"Provide boardPath or open a project first"
),
}
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
timeout = params.get("timeout", 300)
passes = params.get("maxPasses", 20)
# Validate Freerouting JAR
if not os.path.isfile(jar_path):
return {
@@ -103,11 +208,22 @@ class FreeroutingCommands:
"message": "Freerouting JAR not found",
"errorDetails": (
f"Expected at: {jar_path}. Download from "
"https://github.com/freerouting/freerouting/releases "
"or set FREEROUTING_JAR env var."
"https://github.com/freerouting/freerouting/"
"releases or set FREEROUTING_JAR env var."
),
}
# Determine execution mode
exec_mode = self._resolve_execution_mode(jar_path)
if exec_mode["mode"] == "error":
return {
"success": False,
"message": "No suitable Java runtime",
"errorDetails": exec_mode["error"],
}
use_docker = exec_mode["use_docker"]
# Set up file paths
board_dir = os.path.dirname(board_path)
board_stem = Path(board_path).stem
@@ -122,7 +238,9 @@ class FreeroutingCommands:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": f"ExportSpecctraDSN returned: {result}",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
}
except Exception as e:
return {
@@ -142,13 +260,14 @@ class FreeroutingCommands:
logger.info(f"DSN exported: {dsn_size} bytes")
# Step 2: Run Freerouting
cmd = [
java_exe, "-jar", jar_path,
"-de", dsn_path, "-do", ses_path,
"-mp", str(passes or 20),
]
cmd = _build_freerouting_cmd(
jar_path, dsn_path, ses_path, passes, use_docker
)
logger.info(f"Running Freerouting: {' '.join(cmd)}")
mode_label = "docker" if use_docker else "direct"
logger.info(
f"Running Freerouting ({mode_label}): {' '.join(cmd)}"
)
start_time = time.time()
try:
@@ -164,15 +283,23 @@ class FreeroutingCommands:
if proc.returncode != 0:
return {
"success": False,
"message": f"Freerouting exited with code {proc.returncode}",
"message": (
f"Freerouting exited with code "
f"{proc.returncode}"
),
"errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": elapsed,
"mode": mode_label,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"message": f"Freerouting timed out after {timeout}s",
"errorDetails": "Increase timeout or reduce board complexity",
"message": (
f"Freerouting timed out after {timeout}s"
),
"errorDetails": (
"Increase timeout or reduce board complexity"
),
}
except Exception as e:
return {
@@ -186,7 +313,10 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Freerouting did not produce SES output",
"errorDetails": f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}",
"errorDetails": (
f"Expected at: {ses_path}. "
f"Stdout: {proc.stdout[:500]}"
),
"elapsed_seconds": elapsed,
}
@@ -201,7 +331,9 @@ class FreeroutingCommands:
return {
"success": False,
"message": "SES import failed",
"errorDetails": f"ImportSpecctraSES returned: {result}",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
"elapsed_seconds": elapsed,
}
except Exception as e:
@@ -216,7 +348,9 @@ class FreeroutingCommands:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after autoroute failed: {e}")
logger.warning(
f"Board save after autoroute failed: {e}"
)
# Collect stats
tracks = self.board.GetTracks()
@@ -231,6 +365,7 @@ class FreeroutingCommands:
return {
"success": True,
"message": f"Autoroute completed in {elapsed}s",
"mode": mode_label,
"dsn_path": dsn_path,
"ses_path": ses_path,
"elapsed_seconds": elapsed,
@@ -238,11 +373,13 @@ class FreeroutingCommands:
"tracks": track_count,
"vias": via_count,
},
"freerouting_stdout": proc.stdout[:1000] if proc.stdout else "",
"freerouting_stdout": (
proc.stdout[:1000] if proc.stdout else ""
),
}
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export the board to Specctra DSN format only (no routing)."""
"""Export the board to Specctra DSN format only."""
try:
import pcbnew
except ImportError:
@@ -259,26 +396,36 @@ class FreeroutingCommands:
"errorDetails": "Load or create a board first",
}
board_path = params.get("boardPath") or self.board.GetFileName()
board_path = (
params.get("boardPath") or self.board.GetFileName()
)
output_path = params.get("outputPath")
if not output_path:
if board_path:
output_path = os.path.splitext(board_path)[0] + ".dsn"
output_path = (
os.path.splitext(board_path)[0] + ".dsn"
)
else:
return {
"success": False,
"message": "No output path",
"errorDetails": "Provide outputPath or have a board file open",
"errorDetails": (
"Provide outputPath or have a board open"
),
}
try:
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
result = pcbnew.ExportSpecctraDSN(
self.board, output_path
)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": f"ExportSpecctraDSN returned: {result}",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
}
except Exception as e:
return {
@@ -287,7 +434,11 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
file_size = (
os.path.getsize(output_path)
if os.path.isfile(output_path)
else 0
)
return {
"success": True,
"message": f"Exported DSN to {output_path}",
@@ -318,7 +469,9 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Missing sesPath parameter",
"errorDetails": "Provide the path to the .ses file",
"errorDetails": (
"Provide the path to the .ses file"
),
}
if not os.path.isfile(ses_path):
@@ -329,12 +482,16 @@ class FreeroutingCommands:
}
try:
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
result = pcbnew.ImportSpecctraSES(
self.board, ses_path
)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": f"ImportSpecctraSES returned: {result}",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
}
except Exception as e:
return {
@@ -343,17 +500,24 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
# Save after import
board_path = params.get("boardPath") or self.board.GetFileName()
board_path = (
params.get("boardPath") or self.board.GetFileName()
)
if board_path:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after SES import failed: {e}")
logger.warning(
f"Board save after SES import failed: {e}"
)
tracks = self.board.GetTracks()
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
track_count = sum(
1 for t in tracks if t.GetClass() != "PCB_VIA"
)
via_count = sum(
1 for t in tracks if t.GetClass() == "PCB_VIA"
)
return {
"success": True,
@@ -364,12 +528,18 @@ class FreeroutingCommands:
},
}
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Check if Freerouting and Java are available."""
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
def check_freerouting(
self, params: Dict[str, Any]
) -> Dict[str, Any]:
"""Check if Freerouting and Java/Docker are available."""
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
# Check local Java
java_exe = _find_java()
java_version = None
java_21_ok = False
if java_exe:
try:
proc = subprocess.run(
@@ -378,11 +548,27 @@ class FreeroutingCommands:
text=True,
timeout=10,
)
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
java_version = (
(proc.stderr or proc.stdout)
.strip()
.split("\n")[0]
)
java_21_ok = _java_version_ok(java_exe)
except Exception:
pass
# Check Docker/Podman
docker_exe = _find_docker()
has_docker = _docker_available()
jar_exists = os.path.isfile(jar_path)
ready = jar_exists and (java_21_ok or has_docker)
mode = "none"
if java_21_ok:
mode = "direct"
elif has_docker:
mode = "docker"
return {
"success": True,
@@ -391,10 +577,17 @@ class FreeroutingCommands:
"found": java_exe is not None,
"path": java_exe,
"version": java_version,
"java_21_ok": java_21_ok,
},
"docker": {
"available": has_docker,
"path": docker_exe,
"image": DOCKER_IMAGE,
},
"freerouting": {
"jar_found": jar_exists,
"jar_path": jar_path,
},
"ready": java_exe is not None and jar_exists,
"execution_mode": mode,
"ready": ready,
}

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