FreeRouting ships no unit tests for its geometry/router, so there is no value-level oracle to port against. This adds a dev/test-only harness that runs the reference JAR to route a DSN, letting freeroute's output be diffed against the reference implementation — the router phase will assert connectivity parity (same nets routed) via routed_net_set(). - tests/oracle.py: locate Java 21+ and a freerouting JAR (env overrides: FREEROUTE_ORACLE_JAVA, FREEROUTING_JAR), route a DSN, and extract routed connectivity from the SES. requires_oracle skips when no JVM/JAR is present, so the suite stays Java-free. - tests/test_oracle.py: routes a routable board end-to-end and checks the connectivity extraction. - tests/dsn/fixtures/kicad_routable.dsn: a real KiCad pcbnew-exported DSN (Arduino_Mega template, path sanitized) that actually routes — the smd_demo fixture leaves its nets unrouted. - pyproject: pythonpath=["tests"] so the harness imports as `oracle`.
161 lines
5.2 KiB
Python
161 lines
5.2 KiB
Python
"""FreeRouting JAR oracle for behaviour-level validation.
|
|
|
|
**Dev/test only — not part of the shipped package** (freeroute is Java-free by
|
|
design). This runs the reference FreeRouting JAR to route a Specctra DSN so that
|
|
freeroute's own output can be diffed against the reference implementation. There
|
|
are no unit-test oracles for FreeRouting's geometry/router, so the oracle is how
|
|
we validate at the behaviour level: same DSN in, compare routed connectivity.
|
|
|
|
Tests that use this skip automatically when a Java 21+ runtime or the JAR is not
|
|
available (e.g. CI without a JVM), so the normal suite stays Java-free.
|
|
|
|
Overrides:
|
|
- ``FREEROUTE_ORACLE_JAVA`` — explicit path to a ``java`` binary.
|
|
- ``FREEROUTING_JAR`` — explicit path to a freerouting JAR.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import glob
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
from freeroute.dsn.sexp import parse
|
|
|
|
_MIN_JAVA_MAJOR = 21
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
# JARs bundled in the (gitignored) reference clone, newest first.
|
|
_JAR_CANDIDATES = (
|
|
"reference/freerouting/scripts/benchmark/binaries/freerouting-current.jar",
|
|
"reference/freerouting/scripts/benchmark/binaries/freerouting-2.2.4.jar",
|
|
"reference/freerouting/integrations/KiCad/kicad-freerouting/plugins/jar/freerouting-2.2.4.jar",
|
|
"reference/freerouting/scripts/benchmark/binaries/freerouting-1.9.0.jar",
|
|
)
|
|
|
|
|
|
def _java_major(java: str) -> int:
|
|
"""Parse the major version from ``java -version`` (handles the old 1.8 form)."""
|
|
try:
|
|
out = subprocess.run(
|
|
[java, "-version"], capture_output=True, text=True, timeout=10
|
|
).stderr
|
|
except (OSError, subprocess.SubprocessError):
|
|
return 0
|
|
m = re.search(r'version "(\d+)(?:\.(\d+))?', out)
|
|
if not m:
|
|
return 0
|
|
major = int(m.group(1))
|
|
# "1.8.0" -> 8; "26.0.1" -> 26
|
|
return int(m.group(2)) if major == 1 and m.group(2) else major
|
|
|
|
|
|
def find_java() -> str | None:
|
|
"""Return a path to a Java >= 21 runtime, or None."""
|
|
candidates: list[str] = []
|
|
override = os.environ.get("FREEROUTE_ORACLE_JAVA")
|
|
if override:
|
|
candidates.append(override)
|
|
java_home = os.environ.get("JAVA_HOME")
|
|
if java_home:
|
|
candidates.append(os.path.join(java_home, "bin", "java"))
|
|
# Arch-style versioned JVMs, newest major first.
|
|
candidates += sorted(
|
|
glob.glob("/usr/lib/jvm/java-*-openjdk/bin/java"), reverse=True
|
|
)
|
|
on_path = shutil.which("java")
|
|
if on_path:
|
|
candidates.append(on_path)
|
|
|
|
for java in candidates:
|
|
if java and os.path.isfile(java) and _java_major(java) >= _MIN_JAVA_MAJOR:
|
|
return java
|
|
return None
|
|
|
|
|
|
def find_freerouting_jar() -> str | None:
|
|
"""Return a path to a runnable freerouting JAR, or None."""
|
|
override = os.environ.get("FREEROUTING_JAR")
|
|
if override and os.path.isfile(override):
|
|
return override
|
|
for rel in _JAR_CANDIDATES:
|
|
p = _REPO_ROOT / rel
|
|
if p.is_file():
|
|
return str(p)
|
|
return None
|
|
|
|
|
|
JAVA = find_java()
|
|
JAR = find_freerouting_jar()
|
|
HAS_ORACLE = JAVA is not None and JAR is not None
|
|
|
|
requires_oracle = pytest.mark.skipif(
|
|
not HAS_ORACLE, reason="FreeRouting oracle unavailable (needs Java 21+ and a freerouting JAR)"
|
|
)
|
|
|
|
|
|
def route_dsn(dsn_path: str | Path, *, max_passes: int = 6, timeout: int = 180) -> str:
|
|
"""Route ``dsn_path`` with the FreeRouting JAR and return the SES text.
|
|
|
|
Raises RuntimeError if the oracle is unavailable or produced no SES.
|
|
"""
|
|
if not HAS_ORACLE:
|
|
raise RuntimeError("FreeRouting oracle unavailable")
|
|
with tempfile.NamedTemporaryFile(suffix=".ses", delete=False) as tmp:
|
|
out_path = tmp.name
|
|
try:
|
|
subprocess.run(
|
|
[JAVA, "-jar", JAR, "-de", str(dsn_path), "-do", out_path, "-mp", str(max_passes)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
text = Path(out_path).read_text()
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(out_path)
|
|
if not text.strip():
|
|
raise RuntimeError("FreeRouting produced an empty SES")
|
|
return text
|
|
|
|
|
|
def routed_nets(ses_text: str) -> dict[str, dict[str, int]]:
|
|
"""Map each net in an SES ``network_out`` to its wire/via counts.
|
|
|
|
This is the diff primitive the router phase compares against: two SES files
|
|
route the "same" board when they cover the same set of nets with wires/vias.
|
|
"""
|
|
root = parse(ses_text)
|
|
routes = root.child("routes")
|
|
if routes is None:
|
|
return {}
|
|
network_out = routes.child("network_out")
|
|
if network_out is None:
|
|
return {}
|
|
result: dict[str, dict[str, int]] = {}
|
|
for net in network_out.children("net"):
|
|
vals = net.values()
|
|
name = vals[0].text if vals else "?"
|
|
result[name] = {
|
|
"wires": len(net.children("wire")),
|
|
"vias": len(net.children("via")),
|
|
}
|
|
return result
|
|
|
|
|
|
def routed_net_set(ses_text: str) -> set[str]:
|
|
"""Return the set of nets that actually carry routing (>=1 wire or via)."""
|
|
return {
|
|
name
|
|
for name, counts in routed_nets(ses_text).items()
|
|
if counts["wires"] or counts["vias"]
|
|
}
|