Files
rpmroute/tests/oracle.py
T
rsp2k a6aa1991a1 Rename to rpmroute
The router began as a port of FreeRouting and has grown its own engine
(negotiated congestion, tree nets, exact checks, axis escapes), and
'freeroute' was one letter from the project it no longer follows. The
package, module and command are now rpmroute; the freeroute command stays
as an alias, and the RPMROUTE / RPMROUTE_ORACLE_JAVA variables fall back
to the old FREEROUTE names. Version 2026.10.07. Routing is unchanged: a
PCBench board routes to a byte-identical session either way.
2026-10-07 14:10:36 -06:00

166 lines
5.6 KiB
Python

"""FreeRouting JAR oracle for behaviour-level validation.
**Dev/test only — not part of the shipped package** (rpmroute is Java-free by
design). This runs the reference FreeRouting JAR to route a Specctra DSN so that
rpmroute'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:
- ``RPMROUTE_ORACLE_JAVA`` (or the older ``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 rpmroute.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("RPMROUTE_ORACLE_JAVA") or 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:
# Headless: without these the JAR opens its GUI on the developer's
# desktop for every oracle run.
env = {k: v for k, v in os.environ.items() if k not in ("DISPLAY", "WAYLAND_DISPLAY")}
subprocess.run(
[JAVA, "-Djava.awt.headless=true", "-jar", JAR, "-de", str(dsn_path),
"-do", out_path, "-mp", str(max_passes), "--gui.enabled=false"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
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"]
}