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.
263 lines
7.7 KiB
Python
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"
|