diff --git a/pyproject.toml b/pyproject.toml index 2f0817d..90e53e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,4 +56,5 @@ force-sort-within-sections = true [tool.pytest.ini_options] minversion = "7.0" testpaths = ["tests"] +pythonpath = ["tests"] addopts = ["-ra", "--strict-markers"] diff --git a/tests/dsn/fixtures/kicad_routable.dsn b/tests/dsn/fixtures/kicad_routable.dsn new file mode 100644 index 0000000..a51517b --- /dev/null +++ b/tests/dsn/fixtures/kicad_routable.dsn @@ -0,0 +1,447 @@ +(pcb kicad_routable + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "KiCad's Pcbnew") + (host_version "10.0.4") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu + (type signal) + (property + (index 0) + ) + ) + (layer B.Cu + (type signal) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 199060 -49200 199060 -59360 201600 -61900 201600 -96190 + 199060 -98730 199060 -99238 199058 -99323.6 199020 -99490.5 + 198945 -99644.7 198839 -99778.5 198705 -99885.2 198550 -99959.5 + 198384 -99997.6 198298 -100000 100762 -100000 100676 -99997.6 + 100510 -99959.5 100355 -99885.2 100221 -99778.5 100115 -99644.7 + 100040 -99490.5 100002 -99323.6 100000 -99238 100000 -47422 + 100002 -47336.4 100040 -47169.5 100115 -47015.3 100221 -46881.5 + 100355 -46774.8 100510 -46700.5 100676 -46662.4 100762 -46660 + 196520 -46660 199060 -49200) + ) + (via "Via[0-1]_600:300_um") + (rule + (width 200) + (clearance 200) + (clearance 50 (type smd_smd)) + ) + ) + (placement + (component Connector_PinSocket_2.54mm:PinSocket_2x18_P2.54mm_Vertical + (place J7 193980 -92380 front 180 (PN Digital)) + ) + (component Connector_PinSocket_2.54mm:PinSocket_1x08_P2.54mm_Vertical + (place J1 127940 -97460 front 90 (PN Power)) + (place J3 150800 -97460 front 90 (PN Analog)) + (place J5 173660 -97460 front 90 (PN Analog)) + (place J4 145720 -49200 front 90 (PN PWM)) + (place J6 168580 -49200 front 90 (PN Communication)) + ) + (component Connector_PinSocket_2.54mm:PinSocket_1x10_P2.54mm_Vertical + (place J2 118796 -49200 front 90 (PN PWM)) + ) + (component Arduino_MountingHole:MountingHole_3.2mm + (place MH6 196520 -97460 front 0 (PN MountingHole_3.2mm)) + (place MH1 115240 -49200 front 0 (PN MountingHole_3.2mm)) + (place MH2 113970 -97460 front 0 (PN MountingHole_3.2mm)) + (place MH3 166040 -64440 front 0 (PN MountingHole_3.2mm)) + (place MH4 166040 -92380 front 0 (PN MountingHole_3.2mm)) + (place MH5 190170 -49200 front 0 (PN MountingHole_3.2mm)) + ) + ) + (library + (image Connector_PinSocket_2.54mm:PinSocket_2x18_P2.54mm_Vertical + (outline (polygon signal 0 -4335 1795 1755 1795 1755 -44945 -4335 -44945)) + (outline (path signal 120 1330 -1270 1330 -44510)) + (outline (path signal 120 1330 1330 1330 0)) + (outline (path signal 120 0 1330 1330 1330)) + (outline (path signal 120 -1270 -1270 1330 -1270)) + (outline (path signal 120 -1270 1330 -1270 -1270)) + (outline (path signal 120 -3870 -44510 1330 -44510)) + (outline (path signal 120 -3870 1330 -1270 1330)) + (outline (path signal 120 -3870 1330 -3870 -44510)) + (outline (path signal 100 1270 -44450 -3810 -44450)) + (outline (path signal 100 1270 270 1270 -44450)) + (outline (path signal 100 270 1270 1270 270)) + (outline (path signal 100 -3810 -44450 -3810 1270)) + (outline (path signal 100 -3810 1270 270 1270)) + (pin Rect[A]Pad_1700.000000x1700.000000_um 1 0 0) + (pin Oval[A]Pad_1700.000000x1700.000000_um 2 -2540 0) + (pin Oval[A]Pad_1700.000000x1700.000000_um 3 0 -2540) + (pin Oval[A]Pad_1700.000000x1700.000000_um 4 -2540 -2540) + (pin Oval[A]Pad_1700.000000x1700.000000_um 5 0 -5080) + (pin Oval[A]Pad_1700.000000x1700.000000_um 6 -2540 -5080) + (pin Oval[A]Pad_1700.000000x1700.000000_um 7 0 -7620) + (pin Oval[A]Pad_1700.000000x1700.000000_um 8 -2540 -7620) + (pin Oval[A]Pad_1700.000000x1700.000000_um 9 0 -10160) + (pin Oval[A]Pad_1700.000000x1700.000000_um 10 -2540 -10160) + (pin Oval[A]Pad_1700.000000x1700.000000_um 11 0 -12700) + (pin Oval[A]Pad_1700.000000x1700.000000_um 12 -2540 -12700) + (pin Oval[A]Pad_1700.000000x1700.000000_um 13 0 -15240) + (pin Oval[A]Pad_1700.000000x1700.000000_um 14 -2540 -15240) + (pin Oval[A]Pad_1700.000000x1700.000000_um 15 0 -17780) + (pin Oval[A]Pad_1700.000000x1700.000000_um 16 -2540 -17780) + (pin Oval[A]Pad_1700.000000x1700.000000_um 17 0 -20320) + (pin Oval[A]Pad_1700.000000x1700.000000_um 18 -2540 -20320) + (pin Oval[A]Pad_1700.000000x1700.000000_um 19 0 -22860) + (pin Oval[A]Pad_1700.000000x1700.000000_um 20 -2540 -22860) + (pin Oval[A]Pad_1700.000000x1700.000000_um 21 0 -25400) + (pin Oval[A]Pad_1700.000000x1700.000000_um 22 -2540 -25400) + (pin Oval[A]Pad_1700.000000x1700.000000_um 23 0 -27940) + (pin Oval[A]Pad_1700.000000x1700.000000_um 24 -2540 -27940) + (pin Oval[A]Pad_1700.000000x1700.000000_um 25 0 -30480) + (pin Oval[A]Pad_1700.000000x1700.000000_um 26 -2540 -30480) + (pin Oval[A]Pad_1700.000000x1700.000000_um 27 0 -33020) + (pin Oval[A]Pad_1700.000000x1700.000000_um 28 -2540 -33020) + (pin Oval[A]Pad_1700.000000x1700.000000_um 29 0 -35560) + (pin Oval[A]Pad_1700.000000x1700.000000_um 30 -2540 -35560) + (pin Oval[A]Pad_1700.000000x1700.000000_um 31 0 -38100) + (pin Oval[A]Pad_1700.000000x1700.000000_um 32 -2540 -38100) + (pin Oval[A]Pad_1700.000000x1700.000000_um 33 0 -40640) + (pin Oval[A]Pad_1700.000000x1700.000000_um 34 -2540 -40640) + (pin Oval[A]Pad_1700.000000x1700.000000_um 35 0 -43180) + (pin Oval[A]Pad_1700.000000x1700.000000_um 36 -2540 -43180) + ) + (image Connector_PinSocket_2.54mm:PinSocket_1x08_P2.54mm_Vertical + (outline (polygon signal 0 -1795 -19545 -1795 1795 1745 1795 1745 -19545)) + (outline (path signal 120 1330 1330 1330 0)) + (outline (path signal 120 0 1330 1330 1330)) + (outline (path signal 120 1330 -1270 1330 -19110)) + (outline (path signal 120 -1330 -1270 1330 -1270)) + (outline (path signal 120 -1330 -1270 -1330 -19110)) + (outline (path signal 120 -1330 -19110 1330 -19110)) + (outline (path signal 100 635 1270 1270 635)) + (outline (path signal 100 -1270 1270 635 1270)) + (outline (path signal 100 1270 635 1270 -19050)) + (outline (path signal 100 1270 -19050 -1270 -19050)) + (outline (path signal 100 -1270 -19050 -1270 1270)) + (pin Rect[A]Pad_1700.000000x1700.000000_um 1 0 0) + (pin Oval[A]Pad_1700.000000x1700.000000_um 2 0 -2540) + (pin Oval[A]Pad_1700.000000x1700.000000_um 3 0 -5080) + (pin Oval[A]Pad_1700.000000x1700.000000_um 4 0 -7620) + (pin Oval[A]Pad_1700.000000x1700.000000_um 5 0 -10160) + (pin Oval[A]Pad_1700.000000x1700.000000_um 6 0 -12700) + (pin Oval[A]Pad_1700.000000x1700.000000_um 7 0 -15240) + (pin Oval[A]Pad_1700.000000x1700.000000_um 8 0 -17780) + ) + (image Connector_PinSocket_2.54mm:PinSocket_1x10_P2.54mm_Vertical + (outline (polygon signal 0 -1795 -24595 -1795 1795 1745 1795 1745 -24595)) + (outline (path signal 120 1330 1330 1330 0)) + (outline (path signal 120 0 1330 1330 1330)) + (outline (path signal 120 1330 -1270 1330 -24190)) + (outline (path signal 120 -1330 -1270 1330 -1270)) + (outline (path signal 120 -1330 -1270 -1330 -24190)) + (outline (path signal 120 -1330 -24190 1330 -24190)) + (outline (path signal 100 635 1270 1270 635)) + (outline (path signal 100 -1270 1270 635 1270)) + (outline (path signal 100 1270 635 1270 -24130)) + (outline (path signal 100 1270 -24130 -1270 -24130)) + (outline (path signal 100 -1270 -24130 -1270 1270)) + (pin Rect[A]Pad_1700.000000x1700.000000_um 1 0 0) + (pin Oval[A]Pad_1700.000000x1700.000000_um 2 0 -2540) + (pin Oval[A]Pad_1700.000000x1700.000000_um 3 0 -5080) + (pin Oval[A]Pad_1700.000000x1700.000000_um 4 0 -7620) + (pin Oval[A]Pad_1700.000000x1700.000000_um 5 0 -10160) + (pin Oval[A]Pad_1700.000000x1700.000000_um 6 0 -12700) + (pin Oval[A]Pad_1700.000000x1700.000000_um 7 0 -15240) + (pin Oval[A]Pad_1700.000000x1700.000000_um 8 0 -17780) + (pin Oval[A]Pad_1700.000000x1700.000000_um 9 0 -20320) + (pin Oval[A]Pad_1700.000000x1700.000000_um 10 0 -22860) + ) + (image Arduino_MountingHole:MountingHole_3.2mm + (outline (polygon signal 0 194.668 1686.29 449.803 1636.81 694.386 1548.96 922.704 1424.81 + 1129.38 1267.27 1309.59 1080.02 1459.11 867.447 1574.42 634.549 + 1652.84 386.775 1692.51 129.897 1695 0 1692.51 -129.896 1652.84 -386.774 + 1574.42 -634.548 1459.11 -867.446 1309.59 -1080.01 1129.38 -1267.27 + 922.704 -1424.81 694.386 -1548.96 449.803 -1636.81 194.668 -1686.29 + -65.017 -1696.24 -323.188 -1666.44 -573.776 -1597.57 -810.924 -1491.26 + -1029.06 -1350 -1223.08 -1177.09 -1388.42 -976.599 -1521.23 -753.21 + -1618.38 -512.161 -1677.59 -259.121 -1697.49 0 -1677.59 259.122 + -1618.38 512.162 -1521.23 753.211 -1388.42 976.6 -1223.08 1177.09 + -1029.06 1350 -810.924 1491.26 -573.776 1597.57 -323.188 1666.44 + -65.017 1696.24)) + (keepout "" (circle F.Cu 3700)) + (keepout "" (circle B.Cu 3700)) + ) + (padstack Oval[A]Pad_1700.000000x1700.000000_um + (shape (path F.Cu 1700 0 0 0 0)) + (shape (path B.Cu 1700 0 0 0 0)) + (attach off) + ) + (padstack Rect[A]Pad_1700.000000x1700.000000_um + (shape (rect F.Cu -850 -850 850 850)) + (shape (rect B.Cu -850 -850 850 850)) + (attach off) + ) + (padstack "Via[0-1]_600:300_um" + (shape (circle F.Cu 600)) + (shape (circle B.Cu 600)) + (attach off) + ) + ) + (network + (net GND + (pins J7-1 J7-2 J1-6 J1-7 J2-4) + ) + (net /*52 + (pins J7-3) + ) + (net /53 + (pins J7-4) + ) + (net /50 + (pins J7-5) + ) + (net /51 + (pins J7-6) + ) + (net /48 + (pins J7-7) + ) + (net /49 + (pins J7-8) + ) + (net /*46 + (pins J7-9) + ) + (net /47 + (pins J7-10) + ) + (net /*44 + (pins J7-11) + ) + (net /*45 + (pins J7-12) + ) + (net /42 + (pins J7-13) + ) + (net /43 + (pins J7-14) + ) + (net /40 + (pins J7-15) + ) + (net /41 + (pins J7-16) + ) + (net /38 + (pins J7-17) + ) + (net /39 + (pins J7-18) + ) + (net /36 + (pins J7-19) + ) + (net /37 + (pins J7-20) + ) + (net /34 + (pins J7-21) + ) + (net /35 + (pins J7-22) + ) + (net /32 + (pins J7-23) + ) + (net /33 + (pins J7-24) + ) + (net /30 + (pins J7-25) + ) + (net /31 + (pins J7-26) + ) + (net /28 + (pins J7-27) + ) + (net /29 + (pins J7-28) + ) + (net /26 + (pins J7-29) + ) + (net /27 + (pins J7-30) + ) + (net /24 + (pins J7-31) + ) + (net /25 + (pins J7-32) + ) + (net /22 + (pins J7-33) + ) + (net /23 + (pins J7-34) + ) + (net +5V + (pins J7-35 J7-36 J1-5) + ) + (net /IOREF + (pins J1-2) + ) + (net /A0 + (pins J3-1) + ) + (net /A1 + (pins J3-2) + ) + (net /A2 + (pins J3-3) + ) + (net /A3 + (pins J3-4) + ) + (net /A4 + (pins J3-5) + ) + (net /A5 + (pins J3-6) + ) + (net /A6 + (pins J3-7) + ) + (net /A7 + (pins J3-8) + ) + (net /A8 + (pins J5-1) + ) + (net /A9 + (pins J5-2) + ) + (net /A10 + (pins J5-3) + ) + (net /A11 + (pins J5-4) + ) + (net /A12 + (pins J5-5) + ) + (net /A13 + (pins J5-6) + ) + (net /A14 + (pins J5-7) + ) + (net /A15 + (pins J5-8) + ) + (net /AREF + (pins J2-3) + ) + (net /*13 + (pins J2-5) + ) + (net /*12 + (pins J2-6) + ) + (net /*11 + (pins J2-7) + ) + (net /*10 + (pins J2-8) + ) + (net /*9 + (pins J2-9) + ) + (net /*8 + (pins J2-10) + ) + (net /*7 + (pins J4-1) + ) + (net /*6 + (pins J4-2) + ) + (net /*5 + (pins J4-3) + ) + (net /*4 + (pins J4-4) + ) + (net /*3 + (pins J4-5) + ) + (net /*2 + (pins J4-6) + ) + (net "/TX0{slash}1" + (pins J4-7) + ) + (net "/RX0{slash}0" + (pins J4-8) + ) + (net +3V3 + (pins J1-4) + ) + (net "/TX3{slash}14" + (pins J6-1) + ) + (net "/RX3{slash}15" + (pins J6-2) + ) + (net "/TX2{slash}16" + (pins J6-3) + ) + (net "/RX2{slash}17" + (pins J6-4) + ) + (net "/TX1{slash}18" + (pins J6-5) + ) + (net "/RX1{slash}19" + (pins J6-6) + ) + (net "/SDA{slash}20" + (pins J2-2 J6-7) + ) + (net "/SCL{slash}21" + (pins J2-1 J6-8) + ) + (net VCC + (pins J1-8) + ) + (net "/~{RESET}" + (pins J1-3) + ) + (net "unconnected-(J1-Pin_1-Pad1)" + (pins J1-1) + ) + (class kicad_default +3V3 +5V /*10 /*11 /*12 /*13 /*2 /*3 /*4 /*44 /*45 + /*46 /*5 /*52 /*6 /*7 /*8 /*9 /22 /23 /24 /25 /26 /27 /28 /29 /30 /31 + /32 /33 /34 /35 /36 /37 /38 /39 /40 /41 /42 /43 /47 /48 /49 /50 /51 + /53 /A0 /A1 /A10 /A11 /A12 /A13 /A14 /A15 /A2 /A3 /A4 /A5 /A6 /A7 /A8 + /A9 /AREF /IOREF "/RX0{slash}0" "/RX1{slash}19" "/RX2{slash}17" "/RX3{slash}15" + "/SCL{slash}21" "/SDA{slash}20" "/TX0{slash}1" "/TX1{slash}18" "/TX2{slash}16" + "/TX3{slash}14" "/~{RESET}" GND VCC "unconnected-(J1-Pin_1-Pad1)" + (circuit + (use_via "Via[0-1]_600:300_um") + ) + (rule + (width 200) + (clearance 200) + ) + ) + ) + (wiring + ) +) diff --git a/tests/oracle.py b/tests/oracle.py new file mode 100644 index 0000000..e526f0d --- /dev/null +++ b/tests/oracle.py @@ -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"] + } diff --git a/tests/test_oracle.py b/tests/test_oracle.py new file mode 100644 index 0000000..dc6f638 --- /dev/null +++ b/tests/test_oracle.py @@ -0,0 +1,41 @@ +"""Tests for the FreeRouting oracle harness (tests/oracle.py). + +The routing test is gated on a live JVM + JAR and skips otherwise, so the suite +stays green without Java. When the maze router lands, its output SES will be +diffed against ``routed_net_set`` of the oracle SES here. +""" + +from __future__ import annotations + +from pathlib import Path + +from oracle import HAS_ORACLE, requires_oracle, route_dsn, routed_net_set, routed_nets + +FIXTURES = Path(__file__).parent / "dsn" / "fixtures" +ROUTABLE = FIXTURES / "kicad_routable.dsn" + + +def test_oracle_harness_imports(): + # The harness must import and report availability even with no Java/JAR, + # so gated tests can skip cleanly instead of erroring at collection. + assert isinstance(HAS_ORACLE, bool) + + +@requires_oracle +def test_oracle_routes_and_reports_connectivity(): + """Reference FreeRouting routes a routable KiCad board and we can read it back. + + Exercises the whole oracle path — JAR execution, SES production, and the + ``routed_nets`` extraction that the router phase will diff against. + """ + ses = route_dsn(ROUTABLE, max_passes=5) + + nets = routed_nets(ses) + assert nets, "SES network_out had no nets" + for counts in nets.values(): + assert set(counts) == {"wires", "vias"} + + routed = routed_net_set(ses) + assert routed, "oracle produced no routed nets on a routable board" + # Every routed net is a subset of all nets in the SES. + assert routed <= set(nets)