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:
18
README.md
18
README.md
@@ -603,7 +603,23 @@ Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap.
|
|||||||
|
|
||||||
Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter.
|
Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter.
|
||||||
|
|
||||||
**Prerequisites:** Java 11+ and `freerouting.jar` (place at `~/.kicad-mcp/freerouting.jar` or set `FREEROUTING_JAR` env var).
|
**Setup (one-time):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Download the Freerouting JAR
|
||||||
|
mkdir -p ~/.kicad-mcp
|
||||||
|
curl -L -o ~/.kicad-mcp/freerouting.jar \
|
||||||
|
https://github.com/freerouting/freerouting/releases/download/v2.0.1/freerouting-2.0.1-executable.jar
|
||||||
|
|
||||||
|
# 2. Runtime — pick ONE:
|
||||||
|
# Option A: Docker (recommended, no Java install needed)
|
||||||
|
docker pull eclipse-temurin:21-jre
|
||||||
|
|
||||||
|
# Option B: Install Java 21+ locally
|
||||||
|
# (Ubuntu/Debian) sudo apt install openjdk-21-jre
|
||||||
|
```
|
||||||
|
|
||||||
|
The autorouter auto-detects which runtime is available (Java 21+ direct, or Docker/Podman fallback).
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Check if Freerouting is ready on my system.
|
Check if Freerouting is ready on my system.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ Freerouting autoroute integration for KiCAD MCP Server.
|
|||||||
|
|
||||||
Exports the board to Specctra DSN format, runs Freerouting CLI,
|
Exports the board to Specctra DSN format, runs Freerouting CLI,
|
||||||
and imports the routed SES file back into the board.
|
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
|
import os
|
||||||
@@ -11,23 +15,26 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
logger = logging.getLogger("kicad_interface")
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
# Default Freerouting JAR location
|
# Default Freerouting JAR location
|
||||||
DEFAULT_FREEROUTING_JAR = os.environ.get(
|
DEFAULT_FREEROUTING_JAR = os.environ.get(
|
||||||
"FREEROUTING_JAR",
|
"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]:
|
def _find_java() -> Optional[str]:
|
||||||
"""Find java executable on the system."""
|
"""Find java executable on the system."""
|
||||||
java = shutil.which("java")
|
java = shutil.which("java")
|
||||||
if java:
|
if java:
|
||||||
return java
|
return java
|
||||||
# Check common locations
|
|
||||||
for candidate in [
|
for candidate in [
|
||||||
"/usr/bin/java",
|
"/usr/bin/java",
|
||||||
"/usr/local/bin/java",
|
"/usr/local/bin/java",
|
||||||
@@ -38,12 +45,118 @@ def _find_java() -> Optional[str]:
|
|||||||
return None
|
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:
|
class FreeroutingCommands:
|
||||||
"""Handles Freerouting autoroute operations."""
|
"""Handles Freerouting autoroute operations."""
|
||||||
|
|
||||||
def __init__(self, board=None):
|
def __init__(self, board=None):
|
||||||
self.board = board
|
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]:
|
def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Run Freerouting autorouter on the current board.
|
"""Run Freerouting autorouter on the current board.
|
||||||
|
|
||||||
@@ -77,25 +190,17 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board file path available",
|
"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": (
|
"errorDetails": (
|
||||||
"Freerouting requires Java. Install Java 11+ or set "
|
"Provide boardPath or open a project first"
|
||||||
"JAVA_HOME environment variable."
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jar_path = params.get(
|
||||||
|
"freeroutingJar", DEFAULT_FREEROUTING_JAR
|
||||||
|
)
|
||||||
|
timeout = params.get("timeout", 300)
|
||||||
|
passes = params.get("maxPasses", 20)
|
||||||
|
|
||||||
# Validate Freerouting JAR
|
# Validate Freerouting JAR
|
||||||
if not os.path.isfile(jar_path):
|
if not os.path.isfile(jar_path):
|
||||||
return {
|
return {
|
||||||
@@ -103,11 +208,22 @@ class FreeroutingCommands:
|
|||||||
"message": "Freerouting JAR not found",
|
"message": "Freerouting JAR not found",
|
||||||
"errorDetails": (
|
"errorDetails": (
|
||||||
f"Expected at: {jar_path}. Download from "
|
f"Expected at: {jar_path}. Download from "
|
||||||
"https://github.com/freerouting/freerouting/releases "
|
"https://github.com/freerouting/freerouting/"
|
||||||
"or set FREEROUTING_JAR env var."
|
"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
|
# Set up file paths
|
||||||
board_dir = os.path.dirname(board_path)
|
board_dir = os.path.dirname(board_path)
|
||||||
board_stem = Path(board_path).stem
|
board_stem = Path(board_path).stem
|
||||||
@@ -122,7 +238,9 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "DSN export failed",
|
"message": "DSN export failed",
|
||||||
"errorDetails": f"ExportSpecctraDSN returned: {result}",
|
"errorDetails": (
|
||||||
|
f"ExportSpecctraDSN returned: {result}"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -142,13 +260,14 @@ class FreeroutingCommands:
|
|||||||
logger.info(f"DSN exported: {dsn_size} bytes")
|
logger.info(f"DSN exported: {dsn_size} bytes")
|
||||||
|
|
||||||
# Step 2: Run Freerouting
|
# Step 2: Run Freerouting
|
||||||
cmd = [
|
cmd = _build_freerouting_cmd(
|
||||||
java_exe, "-jar", jar_path,
|
jar_path, dsn_path, ses_path, passes, use_docker
|
||||||
"-de", dsn_path, "-do", ses_path,
|
)
|
||||||
"-mp", str(passes or 20),
|
|
||||||
]
|
|
||||||
|
|
||||||
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()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -164,15 +283,23 @@ class FreeroutingCommands:
|
|||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"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,
|
"errorDetails": proc.stderr or proc.stdout,
|
||||||
"elapsed_seconds": elapsed,
|
"elapsed_seconds": elapsed,
|
||||||
|
"mode": mode_label,
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Freerouting timed out after {timeout}s",
|
"message": (
|
||||||
"errorDetails": "Increase timeout or reduce board complexity",
|
f"Freerouting timed out after {timeout}s"
|
||||||
|
),
|
||||||
|
"errorDetails": (
|
||||||
|
"Increase timeout or reduce board complexity"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -186,7 +313,10 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Freerouting did not produce SES output",
|
"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,
|
"elapsed_seconds": elapsed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +331,9 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "SES import failed",
|
"message": "SES import failed",
|
||||||
"errorDetails": f"ImportSpecctraSES returned: {result}",
|
"errorDetails": (
|
||||||
|
f"ImportSpecctraSES returned: {result}"
|
||||||
|
),
|
||||||
"elapsed_seconds": elapsed,
|
"elapsed_seconds": elapsed,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -216,7 +348,9 @@ class FreeroutingCommands:
|
|||||||
try:
|
try:
|
||||||
self.board.Save(board_path)
|
self.board.Save(board_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Board save after autoroute failed: {e}")
|
logger.warning(
|
||||||
|
f"Board save after autoroute failed: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Collect stats
|
# Collect stats
|
||||||
tracks = self.board.GetTracks()
|
tracks = self.board.GetTracks()
|
||||||
@@ -231,6 +365,7 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Autoroute completed in {elapsed}s",
|
"message": f"Autoroute completed in {elapsed}s",
|
||||||
|
"mode": mode_label,
|
||||||
"dsn_path": dsn_path,
|
"dsn_path": dsn_path,
|
||||||
"ses_path": ses_path,
|
"ses_path": ses_path,
|
||||||
"elapsed_seconds": elapsed,
|
"elapsed_seconds": elapsed,
|
||||||
@@ -238,11 +373,13 @@ class FreeroutingCommands:
|
|||||||
"tracks": track_count,
|
"tracks": track_count,
|
||||||
"vias": via_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]:
|
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:
|
try:
|
||||||
import pcbnew
|
import pcbnew
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -259,26 +396,36 @@ class FreeroutingCommands:
|
|||||||
"errorDetails": "Load or create a board first",
|
"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")
|
output_path = params.get("outputPath")
|
||||||
|
|
||||||
if not output_path:
|
if not output_path:
|
||||||
if board_path:
|
if board_path:
|
||||||
output_path = os.path.splitext(board_path)[0] + ".dsn"
|
output_path = (
|
||||||
|
os.path.splitext(board_path)[0] + ".dsn"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No output path",
|
"message": "No output path",
|
||||||
"errorDetails": "Provide outputPath or have a board file open",
|
"errorDetails": (
|
||||||
|
"Provide outputPath or have a board open"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
|
result = pcbnew.ExportSpecctraDSN(
|
||||||
|
self.board, output_path
|
||||||
|
)
|
||||||
if result is not True and result != 0:
|
if result is not True and result != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "DSN export failed",
|
"message": "DSN export failed",
|
||||||
"errorDetails": f"ExportSpecctraDSN returned: {result}",
|
"errorDetails": (
|
||||||
|
f"ExportSpecctraDSN returned: {result}"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -287,7 +434,11 @@ class FreeroutingCommands:
|
|||||||
"errorDetails": str(e),
|
"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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Exported DSN to {output_path}",
|
"message": f"Exported DSN to {output_path}",
|
||||||
@@ -318,7 +469,9 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing sesPath parameter",
|
"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):
|
if not os.path.isfile(ses_path):
|
||||||
@@ -329,12 +482,16 @@ class FreeroutingCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
|
result = pcbnew.ImportSpecctraSES(
|
||||||
|
self.board, ses_path
|
||||||
|
)
|
||||||
if result is not True and result != 0:
|
if result is not True and result != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "SES import failed",
|
"message": "SES import failed",
|
||||||
"errorDetails": f"ImportSpecctraSES returned: {result}",
|
"errorDetails": (
|
||||||
|
f"ImportSpecctraSES returned: {result}"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -343,17 +500,24 @@ class FreeroutingCommands:
|
|||||||
"errorDetails": str(e),
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save after import
|
board_path = (
|
||||||
board_path = params.get("boardPath") or self.board.GetFileName()
|
params.get("boardPath") or self.board.GetFileName()
|
||||||
|
)
|
||||||
if board_path:
|
if board_path:
|
||||||
try:
|
try:
|
||||||
self.board.Save(board_path)
|
self.board.Save(board_path)
|
||||||
except Exception as e:
|
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()
|
tracks = self.board.GetTracks()
|
||||||
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
|
track_count = sum(
|
||||||
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
|
1 for t in tracks if t.GetClass() != "PCB_VIA"
|
||||||
|
)
|
||||||
|
via_count = sum(
|
||||||
|
1 for t in tracks if t.GetClass() == "PCB_VIA"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -364,12 +528,18 @@ class FreeroutingCommands:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def check_freerouting(
|
||||||
"""Check if Freerouting and Java are available."""
|
self, params: Dict[str, Any]
|
||||||
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
|
) -> 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_exe = _find_java()
|
||||||
java_version = None
|
java_version = None
|
||||||
|
java_21_ok = False
|
||||||
if java_exe:
|
if java_exe:
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
@@ -378,11 +548,27 @@ class FreeroutingCommands:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Check Docker/Podman
|
||||||
|
docker_exe = _find_docker()
|
||||||
|
has_docker = _docker_available()
|
||||||
|
|
||||||
jar_exists = os.path.isfile(jar_path)
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -391,10 +577,17 @@ class FreeroutingCommands:
|
|||||||
"found": java_exe is not None,
|
"found": java_exe is not None,
|
||||||
"path": java_exe,
|
"path": java_exe,
|
||||||
"version": java_version,
|
"version": java_version,
|
||||||
|
"java_21_ok": java_21_ok,
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"available": has_docker,
|
||||||
|
"path": docker_exe,
|
||||||
|
"image": DOCKER_IMAGE,
|
||||||
},
|
},
|
||||||
"freerouting": {
|
"freerouting": {
|
||||||
"jar_found": jar_exists,
|
"jar_found": jar_exists,
|
||||||
"jar_path": jar_path,
|
"jar_path": jar_path,
|
||||||
},
|
},
|
||||||
"ready": java_exe is not None and jar_exists,
|
"execution_mode": mode,
|
||||||
|
"ready": ready,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ Covers:
|
|||||||
- FreeroutingCommands.check_freerouting (dependency detection)
|
- FreeroutingCommands.check_freerouting (dependency detection)
|
||||||
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
|
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
|
||||||
- FreeroutingCommands.import_ses (SES import via pcbnew)
|
- FreeroutingCommands.import_ses (SES import via pcbnew)
|
||||||
- FreeroutingCommands.autoroute (full pipeline)
|
- FreeroutingCommands.autoroute (full pipeline, direct + docker)
|
||||||
- Error handling: missing board, missing java, missing JAR, timeouts
|
- Error handling: missing board, no runtime, missing JAR, timeouts
|
||||||
|
- _find_java, _docker_available, _java_version_ok helpers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -15,10 +16,15 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
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
|
# pcbnew mock from conftest
|
||||||
# so methods that do `import pcbnew` get the mock.
|
|
||||||
pcbnew_mock = sys.modules["pcbnew"]
|
pcbnew_mock = sys.modules["pcbnew"]
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +37,6 @@ pcbnew_mock = sys.modules["pcbnew"]
|
|||||||
def reset_pcbnew_mock():
|
def reset_pcbnew_mock():
|
||||||
"""Reset pcbnew mock before each test."""
|
"""Reset pcbnew mock before each test."""
|
||||||
pcbnew_mock.reset_mock()
|
pcbnew_mock.reset_mock()
|
||||||
# Clear side_effects that persist through reset_mock
|
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = None
|
pcbnew_mock.ExportSpecctraDSN.side_effect = None
|
||||||
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
|
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
|
||||||
pcbnew_mock.ImportSpecctraSES.side_effect = None
|
pcbnew_mock.ImportSpecctraSES.side_effect = None
|
||||||
@@ -41,65 +46,88 @@ def reset_pcbnew_mock():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_board():
|
def mock_board():
|
||||||
"""Create a mock pcbnew.BOARD with minimal interface."""
|
|
||||||
board = MagicMock()
|
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 = []
|
board.GetTracks.return_value = []
|
||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cmds(mock_board):
|
def cmds(mock_board):
|
||||||
"""FreeroutingCommands with a mock board."""
|
|
||||||
return FreeroutingCommands(board=mock_board)
|
return FreeroutingCommands(board=mock_board)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cmds_no_board():
|
def cmds_no_board():
|
||||||
"""FreeroutingCommands without a board."""
|
|
||||||
return FreeroutingCommands(board=None)
|
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
|
# check_freerouting
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestCheckFreerouting:
|
class TestCheckFreerouting:
|
||||||
def test_java_not_found(self, cmds):
|
def test_no_java_no_docker(self, cmds):
|
||||||
with patch(
|
with patch(
|
||||||
"commands.freerouting._find_java", return_value=None
|
"commands.freerouting._find_java", return_value=None
|
||||||
|
), patch(
|
||||||
|
"commands.freerouting._docker_available",
|
||||||
|
return_value=False,
|
||||||
):
|
):
|
||||||
result = cmds.check_freerouting(
|
result = cmds.check_freerouting(
|
||||||
{"freeroutingJar": "/nonexistent.jar"}
|
{"freeroutingJar": "/nonexistent.jar"}
|
||||||
)
|
)
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["java"]["found"] is False
|
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["ready"] is False
|
||||||
|
assert result["execution_mode"] == "none"
|
||||||
|
|
||||||
def test_java_found_no_jar(self, cmds):
|
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
||||||
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):
|
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
with patch(
|
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(
|
), patch(
|
||||||
"commands.freerouting.subprocess.run"
|
"commands.freerouting.subprocess.run"
|
||||||
) as mock_run:
|
) as mock_run:
|
||||||
@@ -110,6 +138,31 @@ class TestCheckFreerouting:
|
|||||||
{"freeroutingJar": str(jar)}
|
{"freeroutingJar": str(jar)}
|
||||||
)
|
)
|
||||||
assert result["ready"] is True
|
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
|
cmds.board.GetFileName.return_value = board_path
|
||||||
|
|
||||||
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||||
# Simulate DSN file creation
|
|
||||||
Path(dsn_path).write_text("(pcb test)")
|
Path(dsn_path).write_text("(pcb test)")
|
||||||
|
|
||||||
result = cmds.export_dsn({})
|
result = cmds.export_dsn({})
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["path"] == dsn_path
|
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):
|
def test_export_custom_path(self, cmds, tmp_path):
|
||||||
output = str(tmp_path / "custom.dsn")
|
output = str(tmp_path / "custom.dsn")
|
||||||
@@ -149,7 +198,9 @@ class TestExportDsn:
|
|||||||
assert result["path"] == output
|
assert result["path"] == output
|
||||||
|
|
||||||
def test_export_failure(self, cmds):
|
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({})
|
result = cmds.export_dsn({})
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "DSN error" in result["errorDetails"]
|
assert "DSN error" in result["errorDetails"]
|
||||||
@@ -162,7 +213,9 @@ class TestExportDsn:
|
|||||||
|
|
||||||
class TestImportSes:
|
class TestImportSes:
|
||||||
def test_no_board(self, cmds_no_board):
|
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 result["success"] is False
|
||||||
assert "No board" in result["message"]
|
assert "No board" in result["message"]
|
||||||
|
|
||||||
@@ -172,7 +225,9 @@ class TestImportSes:
|
|||||||
assert "Missing sesPath" in result["message"]
|
assert "Missing sesPath" in result["message"]
|
||||||
|
|
||||||
def test_ses_file_not_found(self, cmds):
|
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 result["success"] is False
|
||||||
assert "not found" in result["message"]
|
assert "not found" in result["message"]
|
||||||
|
|
||||||
@@ -185,15 +240,14 @@ class TestImportSes:
|
|||||||
|
|
||||||
result = cmds.import_ses({"sesPath": str(ses_file)})
|
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||||
assert result["success"] is True
|
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):
|
def test_import_failure(self, cmds, tmp_path):
|
||||||
ses_file = tmp_path / "test.ses"
|
ses_file = tmp_path / "test.ses"
|
||||||
ses_file.write_text("(session test)")
|
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)})
|
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "SES error" in result["errorDetails"]
|
assert "SES error" in result["errorDetails"]
|
||||||
@@ -210,21 +264,20 @@ class TestAutoroute:
|
|||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "No board" in result["message"]
|
assert "No board" in result["message"]
|
||||||
|
|
||||||
def test_no_java(self, cmds):
|
def test_no_runtime(self, cmds, tmp_path):
|
||||||
with patch(
|
jar = tmp_path / "freerouting.jar"
|
||||||
"commands.freerouting._find_java", return_value=None
|
jar.touch()
|
||||||
):
|
with _patch_no_runtime():
|
||||||
result = cmds.autoroute({})
|
result = cmds.autoroute(
|
||||||
|
{"freeroutingJar": str(jar)}
|
||||||
|
)
|
||||||
assert result["success"] is False
|
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):
|
def test_no_jar(self, cmds):
|
||||||
with patch(
|
result = cmds.autoroute(
|
||||||
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
{"freeroutingJar": "/nonexistent/freerouting.jar"}
|
||||||
):
|
)
|
||||||
result = cmds.autoroute(
|
|
||||||
{"freeroutingJar": "/nonexistent/freerouting.jar"}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "JAR not found" in result["message"]
|
assert "JAR not found" in result["message"]
|
||||||
|
|
||||||
@@ -237,15 +290,17 @@ class TestAutoroute:
|
|||||||
"export fail"
|
"export fail"
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with _patch_direct_java():
|
||||||
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
result = cmds.autoroute(
|
||||||
):
|
{"freeroutingJar": str(jar)}
|
||||||
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
)
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "DSN export failed" in result["message"]
|
assert "DSN export failed" in result["message"]
|
||||||
|
|
||||||
@patch("commands.freerouting.subprocess.run")
|
@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
|
import subprocess
|
||||||
|
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
@@ -264,9 +319,7 @@ class TestAutoroute:
|
|||||||
cmd="", timeout=10
|
cmd="", timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with _patch_direct_java():
|
||||||
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
|
||||||
):
|
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute(
|
||||||
{"freeroutingJar": str(jar), "timeout": 10}
|
{"freeroutingJar": str(jar), "timeout": 10}
|
||||||
)
|
)
|
||||||
@@ -274,7 +327,9 @@ class TestAutoroute:
|
|||||||
assert "timed out" in result["message"]
|
assert "timed out" in result["message"]
|
||||||
|
|
||||||
@patch("commands.freerouting.subprocess.run")
|
@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 = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
board_dir = tmp_path / "project"
|
board_dir = tmp_path / "project"
|
||||||
@@ -286,37 +341,72 @@ class TestAutoroute:
|
|||||||
|
|
||||||
cmds.board.GetFileName.return_value = str(board_file)
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
|
|
||||||
# DSN export creates file
|
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||||
)
|
)
|
||||||
# Freerouting creates SES file
|
|
||||||
mock_run.return_value = MagicMock(
|
mock_run.return_value = MagicMock(
|
||||||
returncode=0, stdout="Routing completed", stderr=""
|
returncode=0, stdout="Routing completed", stderr=""
|
||||||
)
|
)
|
||||||
ses_file.write_text("(session)")
|
ses_file.write_text("(session)")
|
||||||
|
|
||||||
# SES import succeeds
|
|
||||||
pcbnew_mock.ImportSpecctraSES.return_value = True
|
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||||
|
|
||||||
# Board tracks after import
|
|
||||||
track = MagicMock()
|
track = MagicMock()
|
||||||
track.GetClass.return_value = "PCB_TRACK"
|
track.GetClass.return_value = "PCB_TRACK"
|
||||||
via = MagicMock()
|
via = MagicMock()
|
||||||
via.GetClass.return_value = "PCB_VIA"
|
via.GetClass.return_value = "PCB_VIA"
|
||||||
cmds.board.GetTracks.return_value = [track, track, via]
|
cmds.board.GetTracks.return_value = [track, track, via]
|
||||||
|
|
||||||
with patch(
|
with _patch_direct_java():
|
||||||
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
result = cmds.autoroute(
|
||||||
):
|
{"freeroutingJar": str(jar)}
|
||||||
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
)
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
assert result["mode"] == "direct"
|
||||||
assert result["board_stats"]["tracks"] == 2
|
assert result["board_stats"]["tracks"] == 2
|
||||||
assert result["board_stats"]["vias"] == 1
|
assert result["board_stats"]["vias"] == 1
|
||||||
assert "elapsed_seconds" in result
|
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")
|
@patch("commands.freerouting.subprocess.run")
|
||||||
def test_freerouting_nonzero_exit(
|
def test_freerouting_nonzero_exit(
|
||||||
@@ -338,17 +428,17 @@ class TestAutoroute:
|
|||||||
returncode=1, stdout="", stderr="OutOfMemoryError"
|
returncode=1, stdout="", stderr="OutOfMemoryError"
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with _patch_direct_java():
|
||||||
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
result = cmds.autoroute(
|
||||||
):
|
{"freeroutingJar": str(jar)}
|
||||||
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
)
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "exited with code 1" in result["message"]
|
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):
|
def test_none_when_not_found(self):
|
||||||
with patch(
|
with patch(
|
||||||
"commands.freerouting.shutil.which", return_value=None
|
"commands.freerouting.shutil.which",
|
||||||
|
return_value=None,
|
||||||
), patch("os.path.isfile", return_value=False):
|
), patch("os.path.isfile", return_value=False):
|
||||||
assert _find_java() is None
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user