freeroute/tests/oracle.py
Ryan Malloy 18f3afc8a5 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`.
2026-07-12 08:12:03 -06:00

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"]
}