Add FreeRouting JAR oracle harness for behaviour-level validation
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`.
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
"""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"]
|
||||
}
|
||||
Reference in New Issue
Block a user