Files
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

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)