freeroute/tests/dsn/test_reader.py
Ryan Malloy 3bf4f6bed2 Implement Specctra DSN parser with typed board model
Ports the read path of FreeRouting's io/specctra/parser package to a
Java-free Python implementation:

- tokenizer: S-expression lexer mirroring SpecctraFileDescription.flex
  (comments, quoted strings, the string_quote IGNORE_QUOTE directive,
  case-insensitive keywords, and hash-prefixed names)
- sexp: nested S-expression tree builder
- shapes: rect/circle/polygon/path plus area-with-holes scopes
- model: typed dataclasses for layers, padstacks, images, placements,
  nets, net classes, rules, keepouts
- reader: recursive-descent scope readers producing a DsnBoard, one
  _read_* function per FreeRouting read_scope method

46 pytest cases cover the tokenizer, tree, shapes, and end-to-end
parsing against hand-crafted fixtures modeled on FreeRouting's own
test DSN files. Parses 90 of 91 upstream fixtures (the one failure is
a binary OLE file, not text DSN).
2026-07-11 15:21:18 -06:00

159 lines
4.7 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 freeroute.dsn import parse_dsn
from freeroute.dsn.reader import DsnParseError
from freeroute.dsn.shapes import Circle, Rectangle
from freeroute.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