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.
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
"""End-to-end tests for the SES writer.
|
|
|
|
Where possible these re-parse the emitted SES with the project's own tokenizer /
|
|
S-expression parser (:mod:`rpmroute.dsn.sexp`) and assert structural
|
|
invariants, mirroring what FreeRouting's ``SesReader`` and ``kicad-cli pcb
|
|
import specctra-ses`` walk: ``session > routes > network_out > net >
|
|
wire(path)/via``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from rpmroute.dsn import parse_dsn
|
|
from rpmroute.dsn.sexp import SExp, parse
|
|
from rpmroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
|
|
|
|
FIXTURES = Path(__file__).parent.parent / "dsn" / "fixtures"
|
|
|
|
|
|
def load_board(name: str):
|
|
return parse_dsn((FIXTURES / name).read_text())
|
|
|
|
|
|
@pytest.fixture
|
|
def board():
|
|
return load_board("smd_demo.dsn")
|
|
|
|
|
|
def child(node: SExp, head: str) -> SExp:
|
|
found = node.child(head)
|
|
assert found is not None, f"missing ({head} ...) scope"
|
|
return found
|
|
|
|
|
|
# --- no-op session -----------------------------------------------------------
|
|
|
|
|
|
def test_noop_session_is_parseable_and_well_formed(board):
|
|
text = write_ses(board)
|
|
top = parse(text) # must be a single balanced S-expression
|
|
assert top.head == "session"
|
|
# session name argument: design name with .dsn -> .ses
|
|
assert top.values()[0].text == "smd-demo.ses"
|
|
|
|
|
|
def test_noop_session_has_required_scopes(board):
|
|
top = parse(write_ses(board))
|
|
assert child(top, "base_design").values()[0].text == "smd-demo.dsn"
|
|
assert top.child("placement") is not None
|
|
assert top.child("was_is") is not None
|
|
routes = child(top, "routes")
|
|
# routes must carry a resolution (KiCad scales session coords by it)
|
|
assert child(routes, "resolution").values()[0].text == "um"
|
|
assert routes.child("network_out") is not None
|
|
|
|
|
|
def test_noop_network_out_is_empty(board):
|
|
routes = child(parse(write_ses(board)), "routes")
|
|
network = child(routes, "network_out")
|
|
assert network.children("net") == []
|
|
|
|
|
|
def test_placement_echoes_components(board):
|
|
placement = child(parse(write_ses(board)), "placement")
|
|
comps = placement.children("component")
|
|
names = [c.values()[0].text for c in comps]
|
|
assert names == ["MiniQFN-6", "0603"]
|
|
# U1 place: coords in resolution units (um x10), side, rotation (unscaled)
|
|
u1 = comps[0].child("place")
|
|
vals = [v.text for v in u1.values()]
|
|
assert vals == ["U1", "750000", "-450000", "front", "90"]
|
|
|
|
|
|
def test_library_out_echoes_via_padstack(board):
|
|
routes = child(parse(write_ses(board)), "routes")
|
|
lib = child(routes, "library_out")
|
|
pads = lib.children("padstack")
|
|
assert [p.values()[0].text for p in pads] == ["Via[0-1]_600:300_um"]
|
|
|
|
|
|
# --- routed geometry ---------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def routed():
|
|
result = RoutingResult()
|
|
result.add_wire("NET_A", RoutedWire(layer="Top", width=200, coords=[0, 0, 1000, -500]))
|
|
result.add_via("NET_A", RoutedVia(padstack="Via[0-1]_600:300_um", x=1000, y=-500))
|
|
result.add_wire("NET_B", RoutedWire(layer="Bottom", width=250, coords=[10, 10, 20, 20]))
|
|
return result
|
|
|
|
|
|
def test_routed_network_out_has_nets(board, routed):
|
|
routes = child(parse(write_ses(board, routed)), "routes")
|
|
network = child(routes, "network_out")
|
|
nets = network.children("net")
|
|
assert [n.values()[0].text for n in nets] == ["NET_A", "NET_B"]
|
|
|
|
|
|
def test_routed_wire_path_and_via(board, routed):
|
|
routes = child(parse(write_ses(board, routed)), "routes")
|
|
net_a = child(routes, "network_out").children("net")[0]
|
|
|
|
wire = child(net_a, "wire")
|
|
path = child(wire, "path")
|
|
pvals = [v.text for v in path.values()]
|
|
# (path <layer> <width> x1 y1 x2 y2), DSN um scaled to resolution units (x10)
|
|
assert pvals == ["Top", "2000", "0", "0", "10000", "-5000"]
|
|
|
|
via = child(net_a, "via")
|
|
vvals = [v.text for v in via.values()]
|
|
assert vvals == ["Via[0-1]_600:300_um", "10000", "-5000"]
|
|
|
|
|
|
def test_coordinates_are_integers(board):
|
|
result = RoutingResult()
|
|
result.add_wire("NET_A", RoutedWire(layer="Top", width=19.96, coords=[0.04, -0.04, 0.25, 0.35]))
|
|
routes = child(parse(write_ses(board, result)), "routes")
|
|
path = child(child(child(routes, "network_out").children("net")[0], "wire"), "path")
|
|
pvals = [v.text for v in path.values()]
|
|
# scaled x10 first: width 199.6 -> 200; coords 0.4->0, -0.4->0, 2.5->3, 3.5->4
|
|
# (round half up)
|
|
assert pvals == ["Top", "200", "0", "0", "3", "4"]
|
|
assert all("." not in v for v in pvals)
|
|
|
|
|
|
def test_empty_result_matches_noop(board):
|
|
assert write_ses(board, RoutingResult()) == write_ses(board)
|
|
|
|
|
|
def test_round_trip_from_empty_board_fixture():
|
|
board = load_board("empty_board.dsn")
|
|
top = parse(write_ses(board))
|
|
assert top.head == "session"
|
|
assert top.values()[0].text == "freeroute-empty.ses"
|
|
# empty board has no placements -> placement scope has no components
|
|
assert child(top, "placement").children("component") == []
|
|
|
|
|
|
# --- SES coordinate units ---------------------------------------------------
|
|
# FreeRouting's SesWriter writes coordinates in resolution units, and KiCad's
|
|
# importer and mckicad's ses_apply read them that way. On 2026-09-26 rpmroute
|
|
# was found writing DSN units instead, so every routed board came back 10x small.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("resolution", "unit", "expected"),
|
|
[
|
|
("(resolution um 10)", "(unit um)", 10.0), # kicad-cli / pcbnew DSN
|
|
("(resolution mil 1000)", "(unit mil)", 1000.0),
|
|
("(resolution um 10)", "(unit mm)", 10000.0), # unit differs from resolution unit
|
|
],
|
|
)
|
|
def test_ses_scale(resolution, unit, expected):
|
|
from rpmroute.dsn import parse_dsn
|
|
from rpmroute.ses.writer import ses_scale
|
|
|
|
dsn = parse_dsn(f"(pcb t (parser) {resolution} {unit} (structure) (placement) (library) (network))")
|
|
assert ses_scale(dsn) == pytest.approx(expected)
|