0
mirror of https://github.com/bbenchoff/OrthoRoute.git synced 2026-08-21 06:00:57 +00:00
Files
OrthoRoute/tests/test_sexpr.py
Pratheek Balakrishna 45ae66707c feat: parse .kicad_pcb on a real s-expression parser (KiCad 5-10)
The regex loader returned 0 nets/0 pads on KiCad 8/9/10 files (no net
table; references in (property "Reference" ...)), so loading a board
from a file was broken. Replace it with a dependency-free s-expression
parser (sexpr.py) and a structural extractor (file_parser.py) handling
every dialect through KiCad 10 (format 20260206): name-only net
synthesis, footprint rotation and back-side pad transform (verified
pad-exact vs pcbnew 10.0.4), Edge.Cuts excluded from the copper count,
design rules merged from the sibling .kicad_pro. Tests use small
self-contained synthetic boards (both dialects). Also: pad_escape_planner
drill/drill_size fallback and rich_kicad_interface kipy import fix.

Re-include tests/*.py (the .gitignore blocked the suite).

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
2026-07-02 20:56:57 +02:00

125 lines
4.2 KiB
Python

"""Unit tests for the KiCad s-expression parser and node stripper."""
import pytest
from orthoroute.infrastructure.kicad.sexpr import (
SExprError,
atoms,
child,
children,
find_top_level_spans,
first_atom,
node_name,
parse,
strip_top_level_nodes,
)
class TestParse:
def test_simple_nested(self):
result = parse('(kicad_pcb (version 20260206) (net "GND"))')
assert result == [["kicad_pcb", ["version", "20260206"], ["net", "GND"]]]
def test_quoted_string_escapes(self):
result = parse(r'(property "Ref\"quoted\"" "line\nbreak")')
assert result == [["property", 'Ref"quoted"', "line\nbreak"]]
def test_atoms_with_special_chars(self):
result = parse("(at 12.0523 -9.6333 90)")
assert result == [["at", "12.0523", "-9.6333", "90"]]
def test_empty_string_atom(self):
assert parse('(net 0 "")') == [["net", "0", ""]]
def test_unbalanced_open_raises(self):
with pytest.raises(SExprError):
parse("(kicad_pcb (net")
def test_unbalanced_close_raises(self):
with pytest.raises(SExprError):
parse("(net))")
def test_unterminated_string_raises(self):
with pytest.raises(SExprError):
parse('(net "GND')
def test_multiline_whitespace(self):
result = parse('(segment\n\t(start 1 2)\n\t(end 3 4)\n)')
assert result == [["segment", ["start", "1", "2"], ["end", "3", "4"]]]
class TestHelpers:
NODE = ["footprint", "R_0402",
["property", "Reference", "R1"],
["pad", "1", "smd", ["net", "GND"]],
["pad", "2", "smd", ["net", "VCC"]]]
def test_node_name(self):
assert node_name(self.NODE) == "footprint"
assert node_name("atom") is None
assert node_name([]) is None
def test_children(self):
pads = children(self.NODE, "pad")
assert len(pads) == 2
assert pads[0][1] == "1"
def test_child_first_match(self):
assert child(self.NODE, "property") == ["property", "Reference", "R1"]
assert child(self.NODE, "missing") is None
def test_atoms_and_first_atom(self):
assert atoms(self.NODE) == ["R_0402"]
assert first_atom(child(self.NODE, "pad")) == "1"
assert first_atom(None) is None
class TestStripper:
BOARD = (
'(kicad_pcb\n'
'\t(version 20260206)\n'
'\t(segment\n\t\t(start 1 2)\n\t\t(net "GND")\n\t)\n'
'\t(zone\n\t\t(net "GND")\n\t)\n'
'\t(via\n\t\t(at 3 4)\n\t)\n'
')\n'
)
def test_strip_removes_only_named_nodes(self):
stripped, removed = strip_top_level_nodes(self.BOARD, ("segment", "via"))
assert removed == 2
assert "segment" not in stripped
assert "(via" not in stripped
assert "zone" in stripped
assert "(version 20260206)" in stripped
def test_strip_is_idempotent(self):
once, n1 = strip_top_level_nodes(self.BOARD, ("segment", "via"))
twice, n2 = strip_top_level_nodes(once, ("segment", "via"))
assert n1 == 2 and n2 == 0
assert once == twice
def test_strip_leaves_no_blank_lines(self):
stripped, _ = strip_top_level_nodes(self.BOARD, ("segment", "via"))
assert "\n\n" not in stripped
def test_stripped_output_still_parses(self):
stripped, _ = strip_top_level_nodes(self.BOARD, ("segment", "via"))
result = parse(stripped)
names = [node_name(c) for c in result[0][1:] if isinstance(c, list)]
assert names == ["version", "zone"]
def test_deep_nodes_not_stripped(self):
# A (via ...) nested inside a footprint (e.g. padstack) must survive.
text = '(kicad_pcb\n\t(footprint "X"\n\t\t(via (at 1 1))\n\t)\n)\n'
stripped, removed = strip_top_level_nodes(text, ("via",))
assert removed == 0
assert stripped == text
def test_paren_inside_string_ignored(self):
text = '(kicad_pcb\n\t(net ")(")\n\t(segment (net ")("))\n)\n'
spans = find_top_level_spans(text, ("segment",))
assert len(spans) == 1
stripped, removed = strip_top_level_nodes(text, ("segment",))
assert removed == 1
assert '(net ")(")' in stripped