"""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 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 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 freeroute # 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 freeroute.dsn import parse_dsn from freeroute.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)