kicad-mcp/tests/test_ses_apply.py
Ryan Malloy 00fb40fbf4 Add native headless SES to .kicad_pcb applier
KiCad 10's kicad-cli has no specctra subcommands, kipy has no Specctra
support, and pcbnew.ImportSpecctraSES needs a GUI display. This adds a
pure-Python applier that injects FreeRouting's routed wires and vias
directly into a .kicad_pcb, reusing the existing sexp_tree engine.

Reads the (resolution unit value) scope for scaling, applies the
Specctra Y-up to KiCad Y-down flip, maps SES net names to board net
numbers, and splices (segment)/(via) nodes in before the board's
closing paren so the rest of the file is preserved byte-for-byte.

Fixtures are a real Arduino_Mega template board and a FreeRouting-routed
SES; the integration test applies 23 segments across GND and +5V.
2026-07-12 11:10:25 -06:00

263 lines
7.7 KiB
Python

"""Tests for the native SES -> .kicad_pcb applier."""
from pathlib import Path
import pytest
from mckicad.utils.ses_apply import (
SesApplyError,
_fmt,
_padstack_dims,
_transform,
apply_ses_to_board,
build_net_map,
resolution_scale,
)
from mckicad.utils.sexp_tree import find, parse, parse_file
FIXTURES = Path(__file__).parent / "fixtures"
BOARD = FIXTURES / "arduino_mega.kicad_pcb"
ROUTED_SES = FIXTURES / "arduino_mega_routed.ses"
# A hand-built SES with a single-segment wire and one via, in um/10 units.
SMALL_SES = """\
(session small
(routes
(resolution um 10)
(library_out
(padstack "Via[0-1]_600:300_um"
(shape (circle F.Cu 6000 0 0))
(shape (circle B.Cu 6000 0 0))
(attach off)
)
)
(network_out
(net GND
(wire
(path F.Cu 2000
1965200 -923800
1939800 -923800
)
)
(via "Via[0-1]_600:300_um" 1928283 -923800)
)
(net NO_SUCH_NET
(wire
(path B.Cu 2000
100000 -100000
200000 -100000
)
)
)
)
)
)
"""
# --------------------------------------------------------------------------
# Resolution parsing / scaling
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_resolution_scale_um_10():
# (resolution um 10): 1 SES unit = 0.1 um = 1/10000 mm
assert resolution_scale("um", 10) == pytest.approx(1e-4)
@pytest.mark.unit
def test_resolution_scale_units():
assert resolution_scale("mm", 1) == pytest.approx(1.0)
assert resolution_scale("um", 1) == pytest.approx(0.001)
assert resolution_scale("mil", 1) == pytest.approx(0.0254)
assert resolution_scale("inch", 1) == pytest.approx(25.4)
@pytest.mark.unit
def test_resolution_scale_rejects_bad_unit():
with pytest.raises(SesApplyError):
resolution_scale("furlong", 10)
@pytest.mark.unit
def test_resolution_scale_rejects_zero():
with pytest.raises(SesApplyError):
resolution_scale("um", 0)
# --------------------------------------------------------------------------
# Coordinate transform (the de-risked J7 anchor)
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_transform_j7_anchor():
# SES (place J7 1939800 -923800) <-> board (at 193.98 92.38)
scale = resolution_scale("um", 10)
x, y = _transform(1939800, -923800, scale)
assert x == "193.98"
assert y == "92.38"
@pytest.mark.unit
def test_transform_y_axis_flip():
scale = resolution_scale("um", 10)
# Positive SES y flips to negative KiCad y.
_, y = _transform(0, 100000, scale)
assert y == "-10"
# Origin stays clean (no "-0").
assert _transform(0, 0, scale) == ("0", "0")
@pytest.mark.unit
def test_fmt_trims_trailing_zeros():
assert _fmt(0.2) == "0.2"
assert _fmt(196.52) == "196.52"
assert _fmt(-0.0) == "0"
# --------------------------------------------------------------------------
# Net-name -> number mapping
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_build_net_map():
root = parse_file(str(BOARD))
net_map = build_net_map(root)
assert net_map["GND"] == 1
assert net_map["+5V"] == 34
assert net_map["VCC"] == 76
# The empty net 0 is still present.
assert net_map[""] == 0
# --------------------------------------------------------------------------
# Padstack -> via geometry
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_padstack_dims_from_name():
# "600:300_um" -> 0.6 mm pad, 0.3 mm drill
size, drill = _padstack_dims("Via[0-1]_600:300_um", shape_diameter_mm=0.6)
assert size == pytest.approx(0.6)
assert drill == pytest.approx(0.3)
@pytest.mark.unit
def test_padstack_dims_fallback_to_shape_and_default():
size, drill = _padstack_dims("MysteryPad", shape_diameter_mm=0.8)
assert size == pytest.approx(0.8)
assert drill == pytest.approx(0.3) # default drill
size, drill = _padstack_dims("MysteryPad", shape_diameter_mm=None)
assert size == pytest.approx(0.6) # default size
assert drill == pytest.approx(0.3)
# --------------------------------------------------------------------------
# Small hand-built SES -> expected segment/via s-expr
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_small_ses_emits_expected_segment_and_via(tmp_path):
ses = tmp_path / "small.ses"
ses.write_text(SMALL_SES)
out = tmp_path / "out.kicad_pcb"
result = apply_ses_to_board(str(BOARD), str(ses), str(out))
# One GND wire (1 segment) + one via; NO_SUCH_NET skipped as unknown.
assert result["segments_added"] == 1
assert result["vias_added"] == 1
assert result["nets_routed"] == 1
assert result["unknown_nets"] == ["NO_SUCH_NET"]
root = parse_file(str(out))
segments = find(root, "segment")
vias = find(root, "via")
assert len(segments) == 1
assert len(vias) == 1
seg = segments[0]
def child_vals(node, tag):
for c in node.children:
if c.tag == tag:
return c.values
return None
assert child_vals(seg, "start") == ["196.52", "92.38"]
assert child_vals(seg, "end") == ["193.98", "92.38"]
assert child_vals(seg, "width") == ["0.2"]
assert child_vals(seg, "layer") == ["F.Cu"]
assert child_vals(seg, "net") == ["1"] # GND
via = vias[0]
assert child_vals(via, "at") == ["192.8283", "92.38"]
assert child_vals(via, "size") == ["0.6"]
assert child_vals(via, "drill") == ["0.3"]
assert child_vals(via, "layers") == ["F.Cu", "B.Cu"]
assert child_vals(via, "net") == ["1"]
@pytest.mark.unit
def test_apply_preserves_original_board_bytes(tmp_path):
ses = tmp_path / "small.ses"
ses.write_text(SMALL_SES)
out = tmp_path / "out.kicad_pcb"
apply_ses_to_board(str(BOARD), str(ses), str(out))
original = BOARD.read_text()
produced = out.read_text()
# Everything up to the injected block is untouched.
insert = produced.index("\t(segment")
assert produced[:insert] == original[: len(produced[:insert])]
# The board still closes cleanly.
assert produced.rstrip().endswith(")")
# --------------------------------------------------------------------------
# Integration: real routed SES applied to real board
# --------------------------------------------------------------------------
@pytest.mark.integration
def test_apply_real_routed_ses(tmp_path):
out = tmp_path / "routed.kicad_pcb"
result = apply_ses_to_board(str(BOARD), str(ROUTED_SES), str(out))
# The oracle SES routes GND (11 segments) and +5V (12 segments), no vias.
assert result["segments_added"] == 23
assert result["vias_added"] == 0
assert result["nets_routed"] == 2
assert result["unknown_nets"] == []
assert out.exists()
@pytest.mark.integration
def test_applied_segments_reference_valid_nets(tmp_path):
out = tmp_path / "routed.kicad_pcb"
apply_ses_to_board(str(BOARD), str(ROUTED_SES), str(out))
root = parse_file(str(out))
net_numbers = {int(n.values[0]) for n in find(root, "net") if n.values}
segments = find(root, "segment")
assert len(segments) > 0
for seg in segments:
net_child = next((c for c in seg.children if c.tag == "net"), None)
assert net_child is not None
assert int(net_child.values[0]) in net_numbers
@pytest.mark.integration
def test_applied_board_reparses(tmp_path):
out = tmp_path / "routed.kicad_pcb"
apply_ses_to_board(str(BOARD), str(ROUTED_SES), str(out))
# The produced file must still be a single well-formed s-expression.
root = parse(out.read_text())
assert root.tag == "kicad_pcb"