freeroute/tests/ses/test_writer.py
Ryan Malloy 12b0a231f0 Implement Specctra SES session-file writer
Ports the write path of io/specctra/SesWriter.java to emit a valid
session from a parsed DsnBoard plus a RoutingResult. Each _write_*
function mirrors a write* method upstream:

- session scope with base_design, placement (resolution + components
  echoed from the DSN), an empty was_is, and routes
- routes carries resolution, a reduced parser scope, library_out with
  the via padstacks, and network_out
- network_out emits (net (wire (path layer width x1 y1 ...)) (via
  padstack x y)) for each routed net; integer coordinates via round-
  half-up to match Java Math.round

write_ses(board) with no result produces a valid no-op session,
proving the DSN-in / SES-out round trip. Only routes > network_out is
required by SesReader and kicad-cli; the rest is echoed for a
well-formed file.

18 tests: indent/quoting rules, no-op session structure, placement and
library_out echo, routed wire/via serialization, integer rounding, and
round-trips re-parsed through the project's own DSN tokenizer/sexp
(64 tests total across the suite, all green; ruff clean).
2026-07-11 16:34:19 -06:00

140 lines
4.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:`freeroute.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 freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import SExp, parse
from freeroute.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 rounded to int, side, rotation
u1 = comps[0].child("place")
vals = [v.text for v in u1.values()]
assert vals == ["U1", "75000", "-45000", "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)
assert pvals == ["Top", "200", "0", "0", "1000", "-500"]
via = child(net_a, "via")
vvals = [v.text for v in via.values()]
assert vvals == ["Via[0-1]_600:300_um", "1000", "-500"]
def test_coordinates_are_integers(board):
result = RoutingResult()
result.add_wire("NET_A", RoutedWire(layer="Top", width=199.6, coords=[0.4, -0.4, 2.5, 3.5]))
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()]
# 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") == []