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.
669 lines
21 KiB
Python
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
|