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