add_label bypasses kicad-sch-api serializer entirely — generates s-expression strings and inserts them directly into the .kicad_sch file via atomic write. Fixes two upstream bugs: global labels silently dropped on save (serializer never iterates "global_label" key), and local labels raising TypeError (parameter signature mismatch in LabelCollection.add()). add_power_symbol now falls back to sexp pin parsing when the API returns None for custom library symbols (e.g. SMF5.0CA). Extracts shared resolve_pin_position() utility used by both add_power_symbol and batch operations. Batch labels also fixed — collected as sexp strings during the batch loop and inserted after sch.save() so the serializer can't overwrite them.
477 lines
14 KiB
Python
477 lines
14 KiB
Python
"""Tests for the S-expression parser utilities.
|
|
|
|
These tests do NOT require kicad-sch-api — they test raw file parsing.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
from mckicad.utils.sexp_parser import (
|
|
generate_global_label_sexp,
|
|
generate_label_sexp,
|
|
insert_sexp_before_close,
|
|
parse_global_labels,
|
|
parse_lib_symbol_pins,
|
|
transform_pin_to_schematic,
|
|
)
|
|
|
|
# Minimal .kicad_sch content with global labels and lib_symbols
|
|
SAMPLE_SCHEMATIC = """\
|
|
(kicad_sch
|
|
(version 20231120)
|
|
(generator "eeschema")
|
|
(uuid "abc123")
|
|
(paper "A4")
|
|
(lib_symbols
|
|
(symbol "Device:R"
|
|
(pin_numbers hide)
|
|
(pin_names
|
|
(offset 0)
|
|
)
|
|
(exclude_from_sim no)
|
|
(in_bom yes)
|
|
(on_board yes)
|
|
(property "Reference" "R"
|
|
(at 2.032 0 90)
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
(symbol "Device:R_0_1"
|
|
(polyline
|
|
(pts
|
|
(xy -1.016 -2.54)
|
|
(xy -1.016 2.54)
|
|
)
|
|
)
|
|
)
|
|
(symbol "Device:R_1_1"
|
|
(pin passive line
|
|
(at 0 3.81 270)
|
|
(length 2.54)
|
|
(name "~"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
(number "1"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
(pin passive line
|
|
(at 0 -3.81 90)
|
|
(length 2.54)
|
|
(name "~"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
(number "2"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
(symbol "Espressif:ESP32-P4"
|
|
(pin_names
|
|
(offset 1.016)
|
|
)
|
|
(exclude_from_sim no)
|
|
(in_bom yes)
|
|
(on_board yes)
|
|
(symbol "Espressif:ESP32-P4_0_1"
|
|
(pin input line
|
|
(at -25.4 22.86 0)
|
|
(length 2.54)
|
|
(name "GPIO0"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
(number "1"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
(pin power_in line
|
|
(at 0 30.48 270)
|
|
(length 2.54)
|
|
(name "VDD"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
(number "2"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
(pin output line
|
|
(at 25.4 22.86 180)
|
|
(length 2.54)
|
|
(name "TX"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
(number "3"
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
(global_label "ESP_3V3"
|
|
(shape input)
|
|
(at 127 95.25 180)
|
|
(uuid "def456")
|
|
(effects
|
|
(font
|
|
(size 1.27 1.27)
|
|
)
|
|
(justify right)
|
|
)
|
|
)
|
|
(global_label "GND"
|
|
(shape input)
|
|
(at 200.5 150.75 0)
|
|
(uuid "ghi789")
|
|
)
|
|
(global_label "SPI_CLK"
|
|
(shape output)
|
|
(at 300 200 90)
|
|
(uuid "jkl012")
|
|
)
|
|
(label "LOCAL_NET"
|
|
(at 100 100 0)
|
|
(uuid "mno345")
|
|
)
|
|
)
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_schematic_file():
|
|
"""Write the sample schematic to a temp file and return its path."""
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".kicad_sch", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write(SAMPLE_SCHEMATIC)
|
|
path = f.name
|
|
|
|
yield path
|
|
os.unlink(path)
|
|
|
|
|
|
class TestParseGlobalLabels:
|
|
def test_finds_all_global_labels(self, sample_schematic_file):
|
|
labels = parse_global_labels(sample_schematic_file)
|
|
assert len(labels) == 3
|
|
|
|
def test_extracts_text_and_position(self, sample_schematic_file):
|
|
labels = parse_global_labels(sample_schematic_file)
|
|
texts = {lbl["text"] for lbl in labels}
|
|
assert texts == {"ESP_3V3", "GND", "SPI_CLK"}
|
|
|
|
esp = next(lbl for lbl in labels if lbl["text"] == "ESP_3V3")
|
|
assert esp["x"] == pytest.approx(127.0)
|
|
assert esp["y"] == pytest.approx(95.25)
|
|
|
|
gnd = next(lbl for lbl in labels if lbl["text"] == "GND")
|
|
assert gnd["x"] == pytest.approx(200.5)
|
|
assert gnd["y"] == pytest.approx(150.75)
|
|
|
|
def test_does_not_include_local_labels(self, sample_schematic_file):
|
|
labels = parse_global_labels(sample_schematic_file)
|
|
texts = {lbl["text"] for lbl in labels}
|
|
assert "LOCAL_NET" not in texts
|
|
|
|
def test_nonexistent_file_returns_empty(self):
|
|
labels = parse_global_labels("/nonexistent/path.kicad_sch")
|
|
assert labels == []
|
|
|
|
|
|
class TestParseLibSymbolPins:
|
|
def test_finds_resistor_pins(self, sample_schematic_file):
|
|
pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R")
|
|
assert len(pins) == 2
|
|
|
|
nums = {p["number"] for p in pins}
|
|
assert nums == {"1", "2"}
|
|
|
|
pin1 = next(p for p in pins if p["number"] == "1")
|
|
assert pin1["name"] == "~"
|
|
assert pin1["type"] == "passive"
|
|
assert pin1["x"] == pytest.approx(0.0)
|
|
assert pin1["y"] == pytest.approx(3.81)
|
|
|
|
def test_finds_custom_ic_pins(self, sample_schematic_file):
|
|
pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4")
|
|
assert len(pins) == 3
|
|
|
|
names = {p["name"] for p in pins}
|
|
assert names == {"GPIO0", "VDD", "TX"}
|
|
|
|
gpio = next(p for p in pins if p["name"] == "GPIO0")
|
|
assert gpio["number"] == "1"
|
|
assert gpio["type"] == "input"
|
|
assert gpio["x"] == pytest.approx(-25.4)
|
|
assert gpio["y"] == pytest.approx(22.86)
|
|
assert gpio["rotation"] == pytest.approx(0.0)
|
|
|
|
vdd = next(p for p in pins if p["name"] == "VDD")
|
|
assert vdd["type"] == "power_in"
|
|
|
|
def test_does_not_match_subunit_prefix(self, sample_schematic_file):
|
|
# "Espressif:ESP32-P4_0_1" is a sub-unit, not the top-level symbol
|
|
pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4_0")
|
|
assert len(pins) == 0
|
|
|
|
def test_nonexistent_lib_id_returns_empty(self, sample_schematic_file):
|
|
pins = parse_lib_symbol_pins(sample_schematic_file, "NoSuchLib:Missing")
|
|
assert pins == []
|
|
|
|
def test_nonexistent_file_returns_empty(self):
|
|
pins = parse_lib_symbol_pins("/nonexistent/path.kicad_sch", "Device:R")
|
|
assert pins == []
|
|
|
|
|
|
class TestTransformPinToSchematic:
|
|
def test_zero_rotation(self):
|
|
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 0)
|
|
assert sx == pytest.approx(100.0)
|
|
assert sy == pytest.approx(103.81)
|
|
|
|
def test_90_degree_rotation(self):
|
|
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 90)
|
|
assert sx == pytest.approx(100 - 3.81, abs=0.01)
|
|
assert sy == pytest.approx(100.0, abs=0.01)
|
|
|
|
def test_180_degree_rotation(self):
|
|
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 180)
|
|
assert sx == pytest.approx(100.0, abs=0.01)
|
|
assert sy == pytest.approx(100 - 3.81, abs=0.01)
|
|
|
|
def test_270_degree_rotation(self):
|
|
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 270)
|
|
assert sx == pytest.approx(100 + 3.81, abs=0.01)
|
|
assert sy == pytest.approx(100.0, abs=0.01)
|
|
|
|
def test_mirror_x(self):
|
|
sx, sy = transform_pin_to_schematic(5, 0, 100, 100, 0, mirror_x=True)
|
|
assert sx == pytest.approx(95.0)
|
|
assert sy == pytest.approx(100.0)
|
|
|
|
|
|
class TestGenerateLabelSexp:
|
|
def test_basic_local_label(self):
|
|
sexp = generate_label_sexp("NET_A", 100.5, 200.25)
|
|
assert '(label "NET_A"' in sexp
|
|
assert "(at 100.5 200.25 0)" in sexp
|
|
assert "(uuid" in sexp
|
|
assert "(effects" in sexp
|
|
|
|
def test_custom_uuid(self):
|
|
sexp = generate_label_sexp("X", 0, 0, uuid_str="test-uuid-123")
|
|
assert '(uuid "test-uuid-123")' in sexp
|
|
|
|
def test_rotation(self):
|
|
sexp = generate_label_sexp("CLK", 50, 50, rotation=90)
|
|
assert "(at 50 50 90)" in sexp
|
|
|
|
def test_auto_uuid_is_unique(self):
|
|
sexp1 = generate_label_sexp("A", 0, 0)
|
|
sexp2 = generate_label_sexp("A", 0, 0)
|
|
# Extract UUIDs
|
|
import re
|
|
|
|
uuids = re.findall(r'\(uuid "([^"]+)"\)', sexp1 + sexp2)
|
|
assert len(uuids) == 2
|
|
assert uuids[0] != uuids[1]
|
|
|
|
def test_quote_escaping(self):
|
|
sexp = generate_label_sexp('NET"SPECIAL', 0, 0)
|
|
assert r'(label "NET\"SPECIAL"' in sexp
|
|
|
|
def test_backslash_escaping(self):
|
|
sexp = generate_label_sexp("NET\\PATH", 0, 0)
|
|
assert r'(label "NET\\PATH"' in sexp
|
|
|
|
|
|
class TestGenerateGlobalLabelSexp:
|
|
def test_basic_global_label(self):
|
|
sexp = generate_global_label_sexp("VBUS_OUT", 187.96, 114.3)
|
|
assert '(global_label "VBUS_OUT"' in sexp
|
|
assert "(shape bidirectional)" in sexp
|
|
assert "(at 187.96 114.3 0)" in sexp
|
|
assert "Intersheetrefs" in sexp
|
|
assert "${INTERSHEET_REFS}" in sexp
|
|
|
|
def test_custom_shape(self):
|
|
sexp = generate_global_label_sexp("CLK", 0, 0, shape="output")
|
|
assert "(shape output)" in sexp
|
|
|
|
def test_rotation(self):
|
|
sexp = generate_global_label_sexp("SIG", 10, 20, rotation=180)
|
|
assert "(at 10 20 180)" in sexp
|
|
|
|
def test_round_trip_parse(self, tmp_path):
|
|
"""Generated global label should be parseable by parse_global_labels."""
|
|
sexp = generate_global_label_sexp("TEST_NET", 123.45, 67.89)
|
|
# Wrap in a minimal schematic
|
|
content = f"(kicad_sch\n (version 20231120)\n{sexp})\n"
|
|
filepath = str(tmp_path / "test.kicad_sch")
|
|
with open(filepath, "w") as f:
|
|
f.write(content)
|
|
|
|
labels = parse_global_labels(filepath)
|
|
assert len(labels) == 1
|
|
assert labels[0]["text"] == "TEST_NET"
|
|
assert labels[0]["x"] == pytest.approx(123.45)
|
|
assert labels[0]["y"] == pytest.approx(67.89)
|
|
|
|
|
|
class TestInsertSexpBeforeClose:
|
|
def test_insert_into_minimal_schematic(self, tmp_path):
|
|
filepath = str(tmp_path / "test.kicad_sch")
|
|
with open(filepath, "w") as f:
|
|
f.write("(kicad_sch\n (version 20231120)\n)\n")
|
|
|
|
insert_sexp_before_close(filepath, ' (label "X"\n (at 0 0 0)\n )\n')
|
|
|
|
with open(filepath) as f:
|
|
content = f.read()
|
|
|
|
assert '(label "X"' in content
|
|
assert content.strip().endswith(")")
|
|
assert content.startswith("(kicad_sch")
|
|
|
|
def test_preserves_existing_content(self, tmp_path):
|
|
filepath = str(tmp_path / "test.kicad_sch")
|
|
original = '(kicad_sch\n (version 20231120)\n (uuid "abc")\n)\n'
|
|
with open(filepath, "w") as f:
|
|
f.write(original)
|
|
|
|
insert_sexp_before_close(filepath, ' (label "Y"\n (at 1 2 0)\n )\n')
|
|
|
|
with open(filepath) as f:
|
|
content = f.read()
|
|
|
|
assert '(uuid "abc")' in content
|
|
assert '(label "Y"' in content
|
|
|
|
def test_rejects_non_kicad_file(self, tmp_path):
|
|
filepath = str(tmp_path / "bad.kicad_sch")
|
|
with open(filepath, "w") as f:
|
|
f.write("not a kicad file")
|
|
|
|
with pytest.raises(ValueError, match="Not a KiCad schematic"):
|
|
insert_sexp_before_close(filepath, "(label)")
|
|
|
|
def test_multiple_insertions(self, tmp_path):
|
|
filepath = str(tmp_path / "multi.kicad_sch")
|
|
with open(filepath, "w") as f:
|
|
f.write("(kicad_sch\n (version 20231120)\n)\n")
|
|
|
|
insert_sexp_before_close(filepath, ' (label "A"\n (at 0 0 0)\n )\n')
|
|
insert_sexp_before_close(filepath, ' (label "B"\n (at 10 10 0)\n )\n')
|
|
|
|
with open(filepath) as f:
|
|
content = f.read()
|
|
|
|
assert '(label "A"' in content
|
|
assert '(label "B"' in content
|
|
|
|
def test_file_not_found(self, tmp_path):
|
|
with pytest.raises(FileNotFoundError):
|
|
insert_sexp_before_close(str(tmp_path / "missing.kicad_sch"), "(label)")
|
|
|
|
|
|
class TestResolvePinPosition:
|
|
"""Tests for resolve_pin_position (requires mocking sch object)."""
|
|
|
|
def test_returns_api_result_when_available(self):
|
|
"""When the API returns a position, use it directly."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from mckicad.utils.sexp_parser import resolve_pin_position
|
|
|
|
sch = MagicMock()
|
|
pos = MagicMock()
|
|
pos.x = 100.0
|
|
pos.y = 200.0
|
|
sch.get_component_pin_position.return_value = pos
|
|
|
|
result = resolve_pin_position(sch, "/fake/path.kicad_sch", "R1", "1")
|
|
assert result == (100.0, 200.0)
|
|
|
|
def test_returns_none_when_api_returns_none_and_no_component(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from mckicad.utils.sexp_parser import resolve_pin_position
|
|
|
|
sch = MagicMock()
|
|
sch.get_component_pin_position.return_value = None
|
|
sch.components.get.return_value = None
|
|
|
|
result = resolve_pin_position(sch, "/fake/path.kicad_sch", "U99", "1")
|
|
assert result is None
|
|
|
|
def test_falls_back_to_sexp(self, sample_schematic_file):
|
|
"""When API returns None, use sexp parsing for pin resolution."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from mckicad.utils.sexp_parser import resolve_pin_position
|
|
|
|
sch = MagicMock()
|
|
sch.get_component_pin_position.return_value = None
|
|
|
|
# Mock a component at position (100, 100) with lib_id "Device:R"
|
|
comp = MagicMock()
|
|
comp.lib_id = "Device:R"
|
|
comp.position = MagicMock()
|
|
comp.position.x = 100.0
|
|
comp.position.y = 100.0
|
|
comp.rotation = 0
|
|
comp.mirror = None
|
|
sch.components.get.return_value = comp
|
|
|
|
result = resolve_pin_position(sch, sample_schematic_file, "R1", "1")
|
|
assert result is not None
|
|
# Pin 1 of Device:R is at (0, 3.81) in local coords
|
|
# At component position (100, 100) with 0 rotation: (100, 103.81)
|
|
assert result[0] == pytest.approx(100.0)
|
|
assert result[1] == pytest.approx(103.81)
|