kicad-mcp/tests/test_sexp_tree.py
Ryan Malloy 57872e59c1 Replace kicad-sch-api with internal SchDocument engine
Eliminate the external kicad-sch-api dependency by migrating all MCP
tools to the internal SchDocument class built on sexp_tree.py. This
fixes three serialization bugs (dropped global labels, TypeError on
local labels, mis-quoted property private keywords) and removes ~1,900
lines of workaround code.

New modules:
- sexp_tree.py: S-expression parser and round-trip serializer
- sch_document.py: schematic read/write/mutate API
- lib_resolver.py: symbol library search and resolution
- sch_helpers.py: shared load/validate/expand helpers

Migrated all 9 tool files, resources, and tests. Removed 5 workaround
functions from sexp_parser.py. 531 tests pass, ruff + mypy clean.
2026-07-11 16:47:22 -06:00

669 lines
21 KiB
Python

"""Tests for the KiCad s-expression tokenizer and tree builder."""
import os
import pytest
from mckicad.utils.sexp_tree import (
SexpNode,
TokenType,
deep_copy,
find,
find_named,
find_one,
find_recursive,
get_values,
make_atom,
make_node,
make_string,
parse,
parse_file,
serialize,
serialize_file,
text_at,
tokenize,
)
# ---------------------------------------------------------------------------
# Tokenizer
# ---------------------------------------------------------------------------
class TestTokenizeAtoms:
def test_bare_words(self):
tokens = tokenize("symbol pin at")
assert len(tokens) == 3
assert all(t.type == TokenType.ATOM for t in tokens)
assert [t.value for t in tokens] == ["symbol", "pin", "at"]
def test_numbers(self):
tokens = tokenize("1.27 2.54 0")
assert [t.value for t in tokens] == ["1.27", "2.54", "0"]
def test_negative_numbers(self):
tokens = tokenize("-3.81 -0.5e-3")
assert [t.value for t in tokens] == ["-3.81", "-0.5e-3"]
def test_offsets_sequential(self):
tokens = tokenize("abc def")
assert tokens[0].offset == 0
assert tokens[1].offset == 4
class TestTokenizeStrings:
def test_quoted_string(self):
tokens = tokenize('"hello world"')
assert len(tokens) == 1
assert tokens[0].type == TokenType.STRING
assert tokens[0].value == "hello world"
def test_escaped_quotes(self):
tokens = tokenize(r'"say \"hello\""')
assert tokens[0].value == 'say "hello"'
def test_escaped_backslash(self):
tokens = tokenize(r'"path\\to\\file"')
assert tokens[0].value == "path\\to\\file"
def test_empty_string(self):
tokens = tokenize('""')
assert len(tokens) == 1
assert tokens[0].type == TokenType.STRING
assert tokens[0].value == ""
class TestTokenizeNested:
def test_nested_parens(self):
tokens = tokenize("(at 1.27 2.54)")
types = [t.type for t in tokens]
assert types == [
TokenType.LPAREN,
TokenType.ATOM,
TokenType.ATOM,
TokenType.ATOM,
TokenType.RPAREN,
]
def test_deeply_nested(self):
tokens = tokenize('(pin passive line (at 0 0) (name "A"))')
lparen_count = sum(1 for t in tokens if t.type == TokenType.LPAREN)
rparen_count = sum(1 for t in tokens if t.type == TokenType.RPAREN)
assert lparen_count == 3
assert rparen_count == 3
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
class TestParseSimple:
def test_simple_node(self):
node = parse("(at 1.27 2.54)")
assert node.tag == "at"
assert node.values == ["1.27", "2.54"]
assert node.children == []
def test_string_values(self):
node = parse('(name "VCC")')
assert node.tag == "name"
assert node.values == ["VCC"]
def test_empty_input(self):
node = parse("")
assert node.tag == ""
class TestParseNested:
def test_pin_with_children(self):
sexp = '(pin passive line (at 0 0) (name "A"))'
node = parse(sexp)
assert node.tag == "pin"
assert node.values == ["passive", "line"]
assert len(node.children) == 2
assert node.children[0].tag == "at"
assert node.children[0].values == ["0", "0"]
assert node.children[1].tag == "name"
assert node.children[1].values == ["A"]
def test_mixed_values_and_children(self):
sexp = '(symbol "R" (pin_names (offset 1.016)))'
node = parse(sexp)
assert node.tag == "symbol"
assert node.values == ["R"]
assert len(node.children) == 1
assert node.children[0].tag == "pin_names"
class TestParseKicadLabel:
LABEL_SEXP = (
'(label "VCC"\n'
"\t(at 100.0 50.0 0)\n"
"\t(effects\n"
"\t\t(font\n"
"\t\t\t(size 1.27 1.27)\n"
"\t\t)\n"
"\t\t(justify left bottom)\n"
"\t)\n"
'\t(uuid "abc-123")\n'
")"
)
def test_label_structure(self):
node = parse(self.LABEL_SEXP)
assert node.tag == "label"
assert node.values == ["VCC"]
assert len(node.children) == 3 # at, effects, uuid
at_node = find_one(node, "at")
assert at_node is not None
assert at_node.values == ["100.0", "50.0", "0"]
uuid_node = find_one(node, "uuid")
assert uuid_node is not None
assert uuid_node.values == ["abc-123"]
class TestParseKicadSymbol:
SYMBOL_SEXP = """\
(symbol "R"
(pin_names (offset 0))
(symbol "R_0_1"
(polyline (pts (xy 0 -1.27) (xy 0 1.27)))
)
(symbol "R_1_1"
(pin passive line
(at 0 2.54 270)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line
(at 0 -2.54 90)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)"""
def test_symbol_structure(self):
node = parse(self.SYMBOL_SEXP)
assert node.tag == "symbol"
assert node.values == ["R"]
# Direct children with tag "symbol" are sub-symbols
sub_syms = find(node, "symbol")
assert len(sub_syms) == 2
assert sub_syms[0].values == ["R_0_1"]
assert sub_syms[1].values == ["R_1_1"]
def test_pins_in_subsymbols(self):
node = parse(self.SYMBOL_SEXP)
pins = find_recursive(node, "pin")
assert len(pins) == 2
# First pin
assert pins[0].values[0] == "passive" # type
at_vals = get_values(pins[0], "at")
assert at_vals == ["0", "2.54", "270"]
# ---------------------------------------------------------------------------
# Query functions
# ---------------------------------------------------------------------------
class TestFindDirectChildren:
def test_find_symbols(self):
sexp = '(lib (symbol "A" (pin)) (symbol "B" (pin)) (version 1))'
node = parse(sexp)
syms = find(node, "symbol")
assert len(syms) == 2
assert syms[0].values == ["A"]
assert syms[1].values == ["B"]
def test_find_no_match(self):
node = parse("(root (child))")
assert find(node, "nonexistent") == []
class TestFindRecursive:
def test_find_pins_in_subsymbols(self):
sexp = "(root (symbol (sub (pin 1)) (sub (pin 2))))"
node = parse(sexp)
pins = find_recursive(node, "pin")
assert len(pins) == 2
assert pins[0].values == ["1"]
assert pins[1].values == ["2"]
class TestFindNamed:
def test_exact_match(self):
sexp = '(lib (symbol "Device:R") (symbol "Device:R_0_1"))'
node = parse(sexp)
result = find_named(node, "symbol", "Device:R")
assert result is not None
assert result.values == ["Device:R"]
def test_no_partial_match(self):
sexp = '(lib (symbol "Device:R_0_1") (symbol "Device:R_1_1"))'
node = parse(sexp)
result = find_named(node, "symbol", "Device:R")
assert result is None
def test_no_match(self):
node = parse("(lib (symbol))")
assert find_named(node, "symbol", "anything") is None
class TestGetValues:
def test_get_at_values(self):
sexp = "(pin passive line (at 5.08 0 180) (length 2.54))"
node = parse(sexp)
at_vals = get_values(node, "at")
assert at_vals == ["5.08", "0", "180"]
def test_get_missing_tag(self):
node = parse("(pin passive)")
assert get_values(node, "at") is None
# ---------------------------------------------------------------------------
# Span tracking
# ---------------------------------------------------------------------------
class TestSpanTracking:
def test_simple_span(self):
source = "(at 1.27 2.54)"
node = parse(source)
assert node.span == (0, len(source))
def test_nested_span(self):
source = '(pin passive (at 0 0) (name "A"))'
node = parse(source)
# Root span covers everything
assert node.span == (0, len(source))
# Children have their own spans
at_node = node.children[0]
assert source[at_node.span[0] : at_node.span[1]] == "(at 0 0)"
def test_span_with_whitespace(self):
source = " (at 1 2) "
node = parse(source)
# Span should start at the opening paren, not at whitespace
assert node.span[0] == 2
assert source[node.span[0] : node.span[1]] == "(at 1 2)"
class TestTextAt:
def test_extract_source(self):
source = '(root (child "value") (other))'
node = parse(source)
child = node.children[0]
extracted = text_at(source, child.span)
assert extracted == '(child "value")'
def test_extract_nested(self):
source = "(root (parent (child 1) (child 2)))"
node = parse(source)
parent = node.children[0]
extracted = text_at(source, parent.span)
assert extracted == "(parent (child 1) (child 2))"
# ---------------------------------------------------------------------------
# Real file parsing (requires KiCad installation)
# ---------------------------------------------------------------------------
DEVICE_LIB = "/usr/share/kicad/symbols/Device.kicad_sym"
@pytest.mark.skipif(
not os.path.isfile(DEVICE_LIB),
reason="KiCad symbol library not installed",
)
class TestParseRealFile:
def test_parse_device_lib(self):
tree = parse_file(DEVICE_LIB)
assert tree.tag == "kicad_symbol_lib"
# Should contain many symbols
symbols = find(tree, "symbol")
assert len(symbols) > 50
def test_find_resistor(self):
tree = parse_file(DEVICE_LIB)
resistor = find_named(tree, "symbol", "R")
assert resistor is not None
assert resistor.values[0] == "R"
# R has two pins in its sub-symbols
pins = find_recursive(resistor, "pin")
assert len(pins) == 2
def test_span_matches_source(self):
with open(DEVICE_LIB, encoding="utf-8") as f:
source = f.read()
tree = parse(source)
resistor = find_named(tree, "symbol", "R")
assert resistor is not None
extracted = text_at(source, resistor.span)
assert extracted.startswith('(symbol "R"')
assert extracted.endswith(")")
def test_pin_details(self):
tree = parse_file(DEVICE_LIB)
resistor = find_named(tree, "symbol", "R")
assert resistor is not None
pins = find_recursive(resistor, "pin")
for pin in pins:
# Each pin should have at, length, name, number children
assert find_one(pin, "at") is not None
assert find_one(pin, "length") is not None
assert find_one(pin, "name") is not None
assert find_one(pin, "number") is not None
# ---------------------------------------------------------------------------
# Quoting tracking (_quoted field)
# ---------------------------------------------------------------------------
class TestQuotedTracking:
def test_atom_not_quoted(self):
node = parse("(at 1.27 2.54)")
assert node._quoted == [False, False]
def test_string_quoted(self):
node = parse('(name "VCC")')
assert node._quoted == [True]
def test_mixed_values(self):
node = parse('(pin passive line)')
assert node._quoted == [False, False]
def test_nested_quoted(self):
sexp = '(property "Reference" "R1" (at 0 0 0))'
node = parse(sexp)
assert node._quoted == [True, True]
at_node = find_one(node, "at")
assert at_node is not None
assert at_node._quoted == [False, False, False]
def test_empty_string_quoted(self):
node = parse('(value "")')
assert node._quoted == [True]
assert node.values == [""]
# ---------------------------------------------------------------------------
# Serializer
# ---------------------------------------------------------------------------
class TestSerializeSimple:
def test_values_only(self):
node = make_atom("at", "1.27", "2.54")
assert serialize(node) == "(at 1.27 2.54)"
def test_string_values(self):
node = make_string("name", "VCC")
assert serialize(node) == '(name "VCC")'
def test_no_values_no_children(self):
node = make_node("empty")
assert serialize(node) == "(empty)"
def test_escape_quotes_in_values(self):
node = make_string("desc", 'say "hello"')
result = serialize(node)
assert result == '(desc "say \\"hello\\"")'
def test_escape_backslash(self):
node = make_string("path", "C:\\Users\\file")
result = serialize(node)
assert result == '(path "C:\\\\Users\\\\file")'
class TestSerializeNested:
def test_single_child_inline(self):
child = make_atom("offset", "0")
node = make_node("pin_names", children=[child])
assert serialize(node) == "(pin_names (offset 0))"
def test_multiple_children_multiline(self):
c1 = make_atom("at", "0", "0")
c2 = make_string("name", "A")
node = make_node("pin", ["passive", "line"], [c1, c2])
result = serialize(node)
assert "(pin passive line\n" in result
assert " (at 0 0)\n" in result
assert ' (name "A")\n' in result
assert result.endswith(")")
def test_indentation_nesting(self):
inner = make_atom("size", "1.27", "1.27")
font = make_node("font", children=[inner])
effects = make_node("effects", children=[font])
# Single-child chain: effects -> font -> size → all inline
result = serialize(effects)
assert result == "(effects (font (size 1.27 1.27)))"
class TestSerializeRoundTrip:
def test_simple_roundtrip(self):
original = "(at 1.27 2.54)"
tree = parse(original)
assert serialize(tree) == original
def test_string_roundtrip(self):
original = '(name "VCC")'
tree = parse(original)
assert serialize(tree) == original
def test_nested_roundtrip(self):
original = "(pin_names (offset 0))"
tree = parse(original)
assert serialize(tree) == original
def test_property_roundtrip(self):
original = '(property "Reference" "R1" (at 0 0 0))'
tree = parse(original)
result = serialize(tree)
assert result == original
def test_kicad_label_roundtrip(self):
sexp = (
'(label "VCC"\n'
' (at 100.0 50.0 0)\n'
' (effects\n'
' (font (size 1.27 1.27))\n'
' (justify left bottom)\n'
' )\n'
' (uuid "abc-123")\n'
')'
)
tree = parse(sexp)
result = serialize(tree)
# Re-parse both to verify structural equivalence
re_parsed = parse(result)
assert re_parsed.tag == "label"
assert re_parsed.values == ["VCC"]
at_node = find_one(re_parsed, "at")
assert at_node is not None
assert at_node.values == ["100.0", "50.0", "0"]
def test_multi_child_structure_preserved(self):
sexp = '(symbol "R" (pin_names (offset 0)) (in_bom yes) (on_board yes))'
tree = parse(sexp)
result = serialize(tree)
re_tree = parse(result)
assert re_tree.tag == "symbol"
assert re_tree.values == ["R"]
assert len(re_tree.children) == 3
class TestSerializeFile:
def test_writes_valid_file(self, tmp_path):
node = make_node("kicad_sch", children=[
make_atom("version", "20231120"),
make_string("generator", "test"),
make_string("uuid", "abc-123"),
])
filepath = str(tmp_path / "test.kicad_sch")
serialize_file(node, filepath)
assert os.path.isfile(filepath)
with open(filepath, encoding="utf-8") as f:
content = f.read()
assert content.startswith("(kicad_sch")
assert content.endswith("\n")
def test_roundtrip_through_file(self, tmp_path):
node = make_node("kicad_sch", children=[
make_atom("version", "20231120"),
make_string("uuid", "file-test"),
])
filepath = str(tmp_path / "rt.kicad_sch")
serialize_file(node, filepath)
reloaded = parse_file(filepath)
assert reloaded.tag == "kicad_sch"
assert find_one(reloaded, "version") is not None
uuid_node = find_one(reloaded, "uuid")
assert uuid_node is not None
assert uuid_node.values == ["file-test"]
@pytest.mark.skipif(
not os.path.isfile(DEVICE_LIB),
reason="KiCad symbol library not installed",
)
class TestSerializeRealFile:
def test_device_lib_roundtrip_structural(self):
"""Parse real Device.kicad_sym, serialize, re-parse — same structure."""
tree = parse_file(DEVICE_LIB)
text = serialize(tree)
re_tree = parse(text)
# Same number of top-level symbols
orig_syms = find(tree, "symbol")
re_syms = find(re_tree, "symbol")
assert len(re_syms) == len(orig_syms)
# Spot-check: resistor still has 2 pins
r_orig = find_named(tree, "symbol", "R")
r_new = find_named(re_tree, "symbol", "R")
assert r_orig is not None
assert r_new is not None
assert len(find_recursive(r_orig, "pin")) == len(find_recursive(r_new, "pin"))
# ---------------------------------------------------------------------------
# Node builders
# ---------------------------------------------------------------------------
class TestMakeNode:
def test_auto_quote_numeric(self):
node = make_node("at", ["1.27", "2.54"])
assert node._quoted == [False, False]
assert serialize(node) == "(at 1.27 2.54)"
def test_auto_quote_negative(self):
node = make_node("at", ["-3.81", "0"])
assert node._quoted == [False, False]
def test_auto_quote_keyword(self):
node = make_node("in_bom", ["yes"])
assert node._quoted == [False]
assert serialize(node) == "(in_bom yes)"
def test_auto_quote_string(self):
node = make_node("lib_id", ["Device:R"])
assert node._quoted == [True]
assert serialize(node) == '(lib_id "Device:R")'
def test_auto_quote_mixed(self):
node = make_node("pin", ["passive", "line"])
assert node._quoted == [False, False]
def test_with_children(self):
child = make_atom("offset", "0")
node = make_node("pin_names", children=[child])
assert serialize(node) == "(pin_names (offset 0))"
def test_empty(self):
node = make_node("empty")
assert node.values == []
assert node.children == []
class TestMakeAtom:
def test_basic(self):
node = make_atom("at", "1.27", "2.54", "90")
assert node.values == ["1.27", "2.54", "90"]
assert all(q is False for q in node._quoted)
assert serialize(node) == "(at 1.27 2.54 90)"
def test_no_values(self):
node = make_atom("empty")
assert node.values == []
class TestMakeString:
def test_basic(self):
node = make_string("property", "Reference", "R1")
assert node.values == ["Reference", "R1"]
assert all(q is True for q in node._quoted)
assert serialize(node) == '(property "Reference" "R1")'
def test_single(self):
node = make_string("uuid", "abc-123")
assert serialize(node) == '(uuid "abc-123")'
# ---------------------------------------------------------------------------
# Deep copy
# ---------------------------------------------------------------------------
class TestDeepCopy:
def test_independent_copy(self):
original = make_node("symbol", ["Device:R"], [
make_atom("at", "1.27", "2.54"),
make_string("uuid", "orig-uuid"),
])
copied = deep_copy(original)
# Structural equality
assert copied.tag == original.tag
assert copied.values == original.values
assert copied._quoted == original._quoted
assert len(copied.children) == len(original.children)
# Independence: modifying copy doesn't affect original
copied.values[0] = "Modified:R"
assert original.values[0] == "Device:R"
copied.children[0].values[0] = "99.99"
assert original.children[0].values[0] == "1.27"
def test_preserves_quoted(self):
original = parse('(property "Reference" "R1")')
copied = deep_copy(original)
assert copied._quoted == [True, True]
assert serialize(copied) == '(property "Reference" "R1")'
def test_deep_nesting(self):
sexp = '(effects (font (size 1.27 1.27)) (justify left))'
original = parse(sexp)
copied = deep_copy(original)
result = serialize(copied)
re_parsed = parse(result)
assert re_parsed.tag == "effects"
font = find_one(re_parsed, "font")
assert font is not None