kicad-sch-api has two parsing gaps: get_symbol_definition() returns None for non-standard library prefixes (e.g. Espressif:ESP32-P4), and there is no sch.global_labels attribute for (global_label ...) nodes. This adds a focused parser that reads directly from the raw .kicad_sch file as a fallback, integrated into the connectivity engine, pin extraction, and label counting tools.
293 lines
7.5 KiB
Python
293 lines
7.5 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 (
|
|
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)
|