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.
182 lines
5.5 KiB
Python
182 lines
5.5 KiB
Python
"""End-to-end tests for parse_dsn against fixture files and inline snippets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from rpmroute.dsn import parse_dsn
|
|
from rpmroute.dsn.reader import DsnParseError
|
|
from rpmroute.dsn.shapes import Circle, Rectangle
|
|
from rpmroute.dsn.shapes import Path as DsnPath
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
def load(name: str) -> str:
|
|
return (FIXTURES / name).read_text()
|
|
|
|
|
|
# --- empty_board.dsn ---------------------------------------------------------
|
|
|
|
|
|
def test_empty_board_header_and_resolution():
|
|
board = parse_dsn(load("empty_board.dsn"))
|
|
assert board.name == "freeroute-empty.dsn"
|
|
assert board.parser.string_quote == '"'
|
|
assert board.resolution.unit == "um"
|
|
assert board.resolution.value == 10
|
|
assert board.unit == "um"
|
|
|
|
|
|
def test_empty_board_layers():
|
|
board = parse_dsn(load("empty_board.dsn"))
|
|
assert board.layer_names() == ["F.Cu", "B.Cu"]
|
|
assert all(layer.is_signal for layer in board.layers)
|
|
assert [layer.index for layer in board.layers] == [0, 1]
|
|
|
|
|
|
def test_empty_board_boundary_is_pcb_path():
|
|
board = parse_dsn(load("empty_board.dsn"))
|
|
assert isinstance(board.boundary, DsnPath)
|
|
assert board.boundary.layer == "pcb"
|
|
# closed rectangle: 5 points (10 coords), width prefix stripped
|
|
assert len(board.boundary.coords) == 10
|
|
assert board.outlines == []
|
|
|
|
|
|
# --- smd_demo.dsn ------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def smd():
|
|
return parse_dsn(load("smd_demo.dsn"))
|
|
|
|
|
|
def test_smd_parser_scope(smd):
|
|
assert smd.name == "smd-demo.dsn"
|
|
assert smd.parser.host_cad == "freeroute-test"
|
|
assert smd.parser.host_version == "1.0"
|
|
|
|
|
|
def test_smd_structure_via_and_rules(smd):
|
|
assert smd.via_padstack_names == ["Via[0-1]_600:300_um"]
|
|
widths = [r.value for r in smd.structure_rules.width_rules]
|
|
assert widths == [200.0]
|
|
clearances = smd.structure_rules.clearance_rules
|
|
assert clearances[0].value == 200.0
|
|
assert clearances[0].class_pairs == []
|
|
assert clearances[1].value == 50.0
|
|
assert clearances[1].class_pairs == ["smd_smd"]
|
|
|
|
|
|
def test_smd_keepout(smd):
|
|
assert len(smd.keepouts) == 1
|
|
ko = smd.keepouts[0]
|
|
assert ko.kind == "keepout"
|
|
assert ko.area.name == "no_route_zone"
|
|
assert isinstance(ko.area.border, Rectangle)
|
|
|
|
|
|
def test_smd_padstacks(smd):
|
|
assert [p.name for p in smd.padstacks] == [
|
|
"Rect[T]Pad_800x200_um",
|
|
"Via[0-1]_600:300_um",
|
|
]
|
|
rect_pad = smd.padstack("Rect[T]Pad_800x200_um")
|
|
assert isinstance(rect_pad.shapes[0], Rectangle)
|
|
assert rect_pad.attach_allowed is False
|
|
|
|
via_pad = smd.padstack("Via[0-1]_600:300_um")
|
|
assert len(via_pad.shapes) == 2
|
|
assert all(isinstance(s, Circle) for s in via_pad.shapes)
|
|
assert [s.layer for s in via_pad.shapes] == ["Top", "Bottom"]
|
|
|
|
|
|
def test_smd_images_and_pins(smd):
|
|
assert [img.name for img in smd.images] == ["MiniQFN-6", "0603"]
|
|
qfn = smd.images[0]
|
|
assert qfn.is_front is True
|
|
assert len(qfn.pins) == 3
|
|
pin1 = qfn.pins[0]
|
|
assert pin1.name == "1"
|
|
assert pin1.padstack_name == "Rect[T]Pad_800x200_um"
|
|
assert (pin1.x, pin1.y) == (-3000.0, 400.0)
|
|
|
|
|
|
def test_smd_placement(smd):
|
|
comps = {p.lib_name: p for p in smd.placements}
|
|
assert set(comps) == {"MiniQFN-6", "0603"}
|
|
u1 = comps["MiniQFN-6"].places[0]
|
|
assert u1.name == "U1"
|
|
assert (u1.x, u1.y) == (75000.0, -45000.0)
|
|
assert u1.is_front is True
|
|
assert u1.rotation == 90.0
|
|
|
|
r2 = comps["0603"].places[1]
|
|
assert r2.name == "R2"
|
|
assert r2.is_front is False
|
|
assert r2.rotation == 180.0
|
|
|
|
|
|
def test_smd_nets_and_pin_refs(smd):
|
|
assert [n.name for n in smd.nets] == ["NET_A", "NET_B"]
|
|
net_a = smd.net("NET_A")
|
|
assert [(p.component, p.pin) for p in net_a.pins] == [("U1", "1"), ("R2", "2")]
|
|
|
|
|
|
def test_smd_net_class(smd):
|
|
assert len(smd.net_classes) == 1
|
|
cls = smd.net_classes[0]
|
|
assert cls.name == "default"
|
|
assert cls.use_via == ["Via[0-1]_600:300_um"]
|
|
assert [r.value for r in cls.width_rules] == [200.0]
|
|
|
|
|
|
# --- error handling ----------------------------------------------------------
|
|
|
|
|
|
def test_non_pcb_top_scope_raises():
|
|
with pytest.raises(DsnParseError):
|
|
parse_dsn("(session foo)")
|
|
|
|
|
|
def test_pin_ref_splits_on_first_hyphen():
|
|
dsn = "(pcb b (network (net N (pins A-B-C X-1))))"
|
|
board = parse_dsn(dsn)
|
|
pins = board.net("N").pins
|
|
assert (pins[0].component, pins[0].pin) == ("A", "B-C")
|
|
assert (pins[1].component, pins[1].pin) == ("X", "1")
|
|
|
|
|
|
def test_unplaced_component_has_no_coords():
|
|
dsn = '(pcb b (placement (component "LIB" (place U9))))'
|
|
board = parse_dsn(dsn)
|
|
place = board.placements[0].places[0]
|
|
assert place.name == "U9"
|
|
assert place.x is None and place.y is None
|
|
|
|
|
|
# --- string_quote ----------------------------------------------------------
|
|
|
|
_QUOTED = """(pcb q.dsn
|
|
(parser (string_quote {q}) (space_in_quoted_tokens on))
|
|
(resolution um 10) (unit um)
|
|
(structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0)))
|
|
(placement (component "SW1" (place SW1 100 100 front 0 (PN {pn}))))
|
|
(library)
|
|
(network)
|
|
)"""
|
|
|
|
|
|
def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes():
|
|
# KiCad declares " and writes a keycap's part number as a bare '
|
|
board = parse_dsn(_QUOTED.format(q='"', pn="'"))
|
|
assert board.parser.string_quote == '"'
|
|
|
|
|
|
def test_the_declared_quote_character_is_the_one_that_opens_strings():
|
|
board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'"))
|
|
assert board.parser.string_quote == "'"
|