kicad-mcp/tests/test_sexp_parser.py
Ryan Malloy 4ecbc598d6
Some checks are pending
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions
Add collision-aware stub length clamping to prevent net bridges
7.62mm default stubs caused shorts on small passives and stacked
labels where pin-to-pin distance was less than the stub length.
clamp_stub_length() now auto-shortens stubs when obstacles (adjacent
pins, placed wire endpoints) are detected in the stub's path, with
a 1.27mm clearance margin and 2.54mm minimum floor.
2026-03-09 00:25:11 -06:00

1667 lines
56 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 (
clamp_stub_length,
compute_label_placement,
fix_property_private_keywords,
generate_global_label_sexp,
generate_label_sexp,
generate_wire_sexp,
insert_sexp_before_close,
parse_global_labels,
parse_lib_file_symbol_pins,
parse_lib_symbol_pin_units,
parse_lib_symbol_pins,
parse_wire_segments,
remove_sexp_blocks_by_uuid,
resolve_label_collision,
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(96.19)
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 TestResolveLabelCollision:
def test_no_collision_empty_occupied(self):
occupied: dict[tuple[float, float], str] = {}
x, y = resolve_label_collision(100.0, 50.0, 0, "NET_A", occupied)
assert (x, y) == (100.0, 50.0)
def test_no_collision_different_position(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(200.0, 50.0, 0, "NET_B", occupied)
assert (x, y) == (200.0, 50.0)
def test_no_collision_same_net(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 0, "NET_A", occupied)
assert (x, y) == (100.0, 50.0)
def test_collision_different_net_angle_270(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 270, "NET_B", occupied)
assert x == pytest.approx(100.0)
assert y == pytest.approx(50.0 - 1.27)
def test_collision_different_net_angle_90(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 90, "NET_B", occupied)
assert x == pytest.approx(100.0)
assert y == pytest.approx(50.0 + 1.27)
def test_collision_different_net_angle_180(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 180, "NET_B", occupied)
assert x == pytest.approx(100.0 + 1.27)
assert y == pytest.approx(50.0)
def test_collision_different_net_angle_0(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 0, "NET_B", occupied)
assert x == pytest.approx(100.0 - 1.27)
assert y == pytest.approx(50.0)
def test_occupied_dict_updated_after_resolution(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 270, "NET_B", occupied)
assert (round(x, 2), round(y, 2)) in occupied
assert occupied[(round(x, 2), round(y, 2))] == "NET_B"
def test_custom_offset(self):
occupied: dict[tuple[float, float], str] = {(100.0, 50.0): "NET_A"}
x, y = resolve_label_collision(100.0, 50.0, 0, "NET_B", occupied, offset=2.54)
assert x == pytest.approx(100.0 - 2.54)
assert y == pytest.approx(50.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 "\t\t(at 100.5 200.25 0)" in sexp
assert "\t\t(uuid" in sexp
assert "\t\t(effects\n" in sexp
assert "\t\t\t(font\n" in sexp
assert "\t\t\t\t(size 1.27 1.27)" in sexp
assert "\t\t\t(justify left bottom)" 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 "\t\t(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 "\t\t(shape bidirectional)" in sexp
assert "\t\t(at 187.96 114.3 0)" in sexp
assert "Intersheetrefs" in sexp
assert "${INTERSHEET_REFS}" in sexp
assert "\t\t\t(at 0 0 0)" in sexp
def test_custom_shape(self):
sexp = generate_global_label_sexp("CLK", 0, 0, shape="output")
assert "\t\t(shape output)" in sexp
def test_rotation(self):
sexp = generate_global_label_sexp("SIG", 10, 20, rotation=180)
assert "\t\t(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 TestFixPropertyPrivateKeywords:
"""Tests for repairing mis-serialized (property private ...) keywords."""
MALFORMED_CONTENT = """\
(kicad_sch
(version 20231120)
(lib_symbols
(symbol "Device:Crystal_GND24"
(property "private" "KLC_S3.3" The rectangle is not a symbol body but a graphical element
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(property "private" "KLC_S4.6" Pin placement follows symbol drawing
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
)
)
)
"""
CORRECT_CONTENT = """\
(kicad_sch
(version 20231120)
(lib_symbols
(symbol "Device:Crystal_GND24"
(property private "KLC_S3.3" "The rectangle is not a symbol body but a graphical element"
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(property private "KLC_S4.6" "Pin placement follows symbol drawing"
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
)
)
)
"""
def test_fixes_malformed_properties(self, tmp_path):
filepath = str(tmp_path / "malformed.kicad_sch")
with open(filepath, "w") as f:
f.write(self.MALFORMED_CONTENT)
count = fix_property_private_keywords(filepath)
assert count == 2
with open(filepath) as f:
content = f.read()
assert '(property private "KLC_S3.3" "The rectangle' in content
assert '(property private "KLC_S4.6" "Pin placement' in content
assert '(property "private"' not in content
def test_no_changes_when_correct(self, tmp_path):
filepath = str(tmp_path / "correct.kicad_sch")
with open(filepath, "w") as f:
f.write(self.CORRECT_CONTENT)
count = fix_property_private_keywords(filepath)
assert count == 0
def test_no_changes_when_no_private(self, tmp_path):
filepath = str(tmp_path / "noprivate.kicad_sch")
with open(filepath, "w") as f:
f.write("(kicad_sch\n (version 20231120)\n)\n")
count = fix_property_private_keywords(filepath)
assert count == 0
def test_nonexistent_file_returns_zero(self):
count = fix_property_private_keywords("/nonexistent/path.kicad_sch")
assert count == 0
def test_preserves_surrounding_content(self, tmp_path):
filepath = str(tmp_path / "preserve.kicad_sch")
with open(filepath, "w") as f:
f.write(self.MALFORMED_CONTENT)
fix_property_private_keywords(filepath)
with open(filepath) as f:
content = f.read()
assert '(symbol "Device:Crystal_GND24"' in content
assert "(version 20231120)" in content
assert content.strip().endswith(")")
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 (Y-up)
# At component position (100, 100) with 0 rotation: (100, 96.19) in Y-down
assert result[0] == pytest.approx(100.0)
assert result[1] == pytest.approx(96.19)
# ---------------------------------------------------------------------------
# External library file tests
# ---------------------------------------------------------------------------
# Minimal .kicad_sym library file with a custom TVS diode symbol
SAMPLE_LIBRARY_FILE = """\
(kicad_symbol_lib
(version 20231120)
(generator "kicad_symbol_editor")
(symbol "SMF5.0CA"
(pin_names
(offset 1.016)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(symbol "SMF5.0CA_0_1"
(pin passive line
(at -10.16 2.54 0)
(length 2.54)
(name "A"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin passive line
(at 10.16 2.54 180)
(length 2.54)
(name "K"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
)
)
"""
# Schematic that references the library symbol but doesn't embed it
SAMPLE_SCHEMATIC_NO_EMBED = """\
(kicad_sch
(version 20231120)
(generator "eeschema")
(uuid "abc123")
(paper "A4")
(lib_symbols
)
(symbol
(lib_id "MyProject:SMF5.0CA")
(at 100 100 0)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(uuid "d1-uuid")
(property "Reference" "D1"
(at 100 90 0)
)
)
)
"""
@pytest.fixture
def external_lib_project(tmp_path):
"""Create a project structure with an external library file.
tmp_path/
test_project.kicad_pro
libs/
MyProject.kicad_sym
sheets/
power.kicad_sch
"""
# Project file
pro_file = tmp_path / "test_project.kicad_pro"
pro_file.write_text('{"meta": {"filename": "test_project.kicad_pro"}}')
# Library directory
libs_dir = tmp_path / "libs"
libs_dir.mkdir()
lib_file = libs_dir / "MyProject.kicad_sym"
lib_file.write_text(SAMPLE_LIBRARY_FILE)
# Schematic in a subdirectory (common for multi-sheet projects)
sheets_dir = tmp_path / "sheets"
sheets_dir.mkdir()
sch_file = sheets_dir / "power.kicad_sch"
sch_file.write_text(SAMPLE_SCHEMATIC_NO_EMBED)
return {
"project_root": str(tmp_path),
"lib_file": str(lib_file),
"schematic": str(sch_file),
}
class TestParseLibFileSymbolPins:
"""Tests for parsing pins from standalone .kicad_sym library files."""
def test_finds_pins_in_library_file(self, external_lib_project):
pins = parse_lib_file_symbol_pins(
external_lib_project["lib_file"], "SMF5.0CA",
)
assert len(pins) == 2
names = {p["name"] for p in pins}
assert names == {"A", "K"}
pin_a = next(p for p in pins if p["name"] == "A")
assert pin_a["number"] == "1"
assert pin_a["x"] == pytest.approx(-10.16)
assert pin_a["y"] == pytest.approx(2.54)
pin_k = next(p for p in pins if p["name"] == "K")
assert pin_k["number"] == "2"
assert pin_k["x"] == pytest.approx(10.16)
def test_nonexistent_symbol_returns_empty(self, external_lib_project):
pins = parse_lib_file_symbol_pins(
external_lib_project["lib_file"], "DOES_NOT_EXIST",
)
assert pins == []
def test_nonexistent_file_returns_empty(self):
pins = parse_lib_file_symbol_pins("/nonexistent/lib.kicad_sym", "X")
assert pins == []
class TestExternalLibraryFallback:
"""Tests for parse_lib_symbol_pins falling back to external .kicad_sym files."""
def test_embedded_symbol_found_first(self, sample_schematic_file):
"""When symbol is embedded, don't search external files."""
pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R")
assert len(pins) == 2 # Found in embedded lib_symbols
def test_external_library_fallback(self, external_lib_project):
"""When symbol not embedded, search external .kicad_sym files."""
pins = parse_lib_symbol_pins(
external_lib_project["schematic"], "MyProject:SMF5.0CA",
)
assert len(pins) == 2
names = {p["name"] for p in pins}
assert names == {"A", "K"}
def test_external_lib_not_found_returns_empty(self, tmp_path):
"""When no external library exists, return empty list."""
sch_file = tmp_path / "orphan.kicad_sch"
sch_file.write_text(
"(kicad_sch\n (version 20231120)\n (lib_symbols\n )\n)\n"
)
pins = parse_lib_symbol_pins(str(sch_file), "NoSuchLib:Missing")
assert pins == []
def test_resolve_pin_via_external_lib(self, external_lib_project):
"""resolve_pin_position should find pins via external library."""
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position
sch = MagicMock()
sch.get_component_pin_position.return_value = None
comp = MagicMock()
comp.lib_id = "MyProject:SMF5.0CA"
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, external_lib_project["schematic"], "D1", "1",
)
assert result is not None
# Pin 1 (A) at (-10.16, 2.54) local (Y-up), component at (100, 100), 0 rotation
assert result[0] == pytest.approx(100 - 10.16, abs=0.01)
assert result[1] == pytest.approx(100 - 2.54, abs=0.01)
class TestSymLibTableParsing:
"""Tests for sym-lib-table resolution."""
def test_sym_lib_table_resolution(self, tmp_path):
"""Library paths from sym-lib-table should be found."""
from mckicad.utils.sexp_parser import _find_library_file
# Create project structure
pro_file = tmp_path / "project.kicad_pro"
pro_file.write_text('{}')
custom_libs = tmp_path / "custom" / "symbols"
custom_libs.mkdir(parents=True)
lib_file = custom_libs / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
sym_lib_table = tmp_path / "sym-lib-table"
sym_lib_table.write_text(
'(sym_lib_table\n'
' (version 7)\n'
' (lib (name "CustomLib")(type "KiCad")'
'(uri "${KIPRJMOD}/custom/symbols/CustomLib.kicad_sym")'
'(options "")(descr ""))\n'
')\n'
)
sch_path = str(tmp_path / "test.kicad_sch")
result = _find_library_file(sch_path, "CustomLib")
assert result is not None
assert result == os.path.normpath(str(lib_file))
def test_libs_dir_search(self, external_lib_project):
"""Libraries in ../libs/ relative to schematic should be found."""
from mckicad.utils.sexp_parser import _find_library_file
result = _find_library_file(
external_lib_project["schematic"], "MyProject",
)
assert result is not None
assert result.endswith("MyProject.kicad_sym")
# ---------------------------------------------------------------------------
# Wire segment parsing and removal tests
# ---------------------------------------------------------------------------
SAMPLE_SCHEMATIC_WITH_WIRES = """\
(kicad_sch
(version 20231120)
(generator "eeschema")
(uuid "root-uuid")
(paper "A4")
(lib_symbols
)
(wire (pts (xy 148.59 194.31) (xy 156.21 194.31))
(stroke (width 0) (type default))
(uuid "wire-1")
)
(wire (pts (xy 156.21 194.31) (xy 163.83 194.31))
(stroke (width 0) (type default))
(uuid "wire-2")
)
(wire (pts (xy 100.0 100.0) (xy 100.0 120.0))
(stroke (width 0) (type default))
(uuid "wire-3")
)
(wire (pts (xy 200.5 50.25) (xy 210.75 50.25))
(stroke (width 0) (type default))
(uuid "wire-4")
)
)
"""
@pytest.fixture
def schematic_with_wires(tmp_path):
"""Write a schematic with wire segments to a temp file."""
filepath = tmp_path / "wired.kicad_sch"
filepath.write_text(SAMPLE_SCHEMATIC_WITH_WIRES)
return str(filepath)
class TestParseWireSegments:
def test_finds_all_wires(self, schematic_with_wires):
wires = parse_wire_segments(schematic_with_wires)
assert len(wires) == 4
def test_extracts_coordinates(self, schematic_with_wires):
wires = parse_wire_segments(schematic_with_wires)
w1 = next(w for w in wires if w["uuid"] == "wire-1")
assert w1["start"]["x"] == pytest.approx(148.59)
assert w1["start"]["y"] == pytest.approx(194.31)
assert w1["end"]["x"] == pytest.approx(156.21)
assert w1["end"]["y"] == pytest.approx(194.31)
def test_extracts_uuids(self, schematic_with_wires):
wires = parse_wire_segments(schematic_with_wires)
uuids = {w["uuid"] for w in wires}
assert uuids == {"wire-1", "wire-2", "wire-3", "wire-4"}
def test_vertical_wire(self, schematic_with_wires):
wires = parse_wire_segments(schematic_with_wires)
w3 = next(w for w in wires if w["uuid"] == "wire-3")
assert w3["start"]["x"] == pytest.approx(100.0)
assert w3["start"]["y"] == pytest.approx(100.0)
assert w3["end"]["x"] == pytest.approx(100.0)
assert w3["end"]["y"] == pytest.approx(120.0)
def test_nonexistent_file_returns_empty(self):
wires = parse_wire_segments("/nonexistent/path.kicad_sch")
assert wires == []
def test_schematic_without_wires(self, tmp_path):
filepath = tmp_path / "empty.kicad_sch"
filepath.write_text("(kicad_sch\n (version 20231120)\n)\n")
wires = parse_wire_segments(str(filepath))
assert wires == []
class TestRemoveSexpBlocksByUuid:
def test_remove_single_wire(self, schematic_with_wires):
removed = remove_sexp_blocks_by_uuid(schematic_with_wires, {"wire-1"})
assert removed == 1
# Verify wire is gone
remaining = parse_wire_segments(schematic_with_wires)
remaining_uuids = {w["uuid"] for w in remaining}
assert "wire-1" not in remaining_uuids
assert len(remaining) == 3
def test_remove_multiple_wires(self, schematic_with_wires):
removed = remove_sexp_blocks_by_uuid(
schematic_with_wires, {"wire-1", "wire-2"},
)
assert removed == 2
remaining = parse_wire_segments(schematic_with_wires)
remaining_uuids = {w["uuid"] for w in remaining}
assert remaining_uuids == {"wire-3", "wire-4"}
def test_remove_all_wires(self, schematic_with_wires):
removed = remove_sexp_blocks_by_uuid(
schematic_with_wires, {"wire-1", "wire-2", "wire-3", "wire-4"},
)
assert removed == 4
remaining = parse_wire_segments(schematic_with_wires)
assert remaining == []
# File should still be valid
with open(schematic_with_wires) as f:
content = f.read()
assert content.strip().startswith("(kicad_sch")
assert content.strip().endswith(")")
def test_remove_nonexistent_uuid(self, schematic_with_wires):
removed = remove_sexp_blocks_by_uuid(
schematic_with_wires, {"nonexistent-uuid"},
)
assert removed == 0
remaining = parse_wire_segments(schematic_with_wires)
assert len(remaining) == 4
def test_empty_uuid_set(self, schematic_with_wires):
removed = remove_sexp_blocks_by_uuid(schematic_with_wires, set())
assert removed == 0
def test_preserves_other_content(self, schematic_with_wires):
remove_sexp_blocks_by_uuid(schematic_with_wires, {"wire-1"})
with open(schematic_with_wires) as f:
content = f.read()
assert "(version 20231120)" in content
assert '(uuid "root-uuid")' in content
assert "(lib_symbols" in content
# ---------------------------------------------------------------------------
# compute_label_placement tests
# ---------------------------------------------------------------------------
class TestComputeLabelPlacement:
"""Test label offset + rotation for all 4 pin body directions."""
def test_pin_body_right_label_goes_left(self):
"""Pin body direction 0° (right) -> label placed to the left."""
result = compute_label_placement(100.0, 50.0, 0, stub_length=2.54)
assert result["label_x"] == pytest.approx(100 - 2.54)
assert result["label_y"] == pytest.approx(50.0)
assert result["label_rotation"] == 180
assert result["stub_start_x"] == 100.0
assert result["stub_start_y"] == 50.0
assert result["stub_end_x"] == result["label_x"]
assert result["stub_end_y"] == result["label_y"]
def test_pin_body_down_label_goes_up(self):
"""Pin body direction 90° (down) -> label placed upward."""
result = compute_label_placement(100.0, 50.0, 90, stub_length=2.54)
assert result["label_x"] == pytest.approx(100.0)
assert result["label_y"] == pytest.approx(50 - 2.54)
assert result["label_rotation"] == 90
def test_pin_body_left_label_goes_right(self):
"""Pin body direction 180° (left) -> label placed to the right."""
result = compute_label_placement(100.0, 50.0, 180, stub_length=2.54)
assert result["label_x"] == pytest.approx(100 + 2.54)
assert result["label_y"] == pytest.approx(50.0)
assert result["label_rotation"] == 0
def test_pin_body_up_label_goes_down(self):
"""Pin body direction 270° (up) -> label placed downward."""
result = compute_label_placement(100.0, 50.0, 270, stub_length=2.54)
assert result["label_x"] == pytest.approx(100.0)
assert result["label_y"] == pytest.approx(50 + 2.54)
assert result["label_rotation"] == 270
def test_custom_stub_length(self):
"""Non-default stub length should scale the offset."""
result = compute_label_placement(0, 0, 180, stub_length=5.08)
assert result["label_x"] == pytest.approx(5.08)
assert result["label_y"] == pytest.approx(0)
def test_stub_endpoints_match_pin_and_label(self):
"""Wire stub starts at pin tip and ends at label position."""
result = compute_label_placement(200, 100, 0, stub_length=2.54)
assert result["stub_start_x"] == 200
assert result["stub_start_y"] == 100
assert result["stub_end_x"] == result["label_x"]
assert result["stub_end_y"] == result["label_y"]
# ---------------------------------------------------------------------------
# clamp_stub_length tests
# ---------------------------------------------------------------------------
class TestClampStubLength:
"""Test collision-aware stub length clamping."""
def test_no_obstacles_returns_proposed(self):
"""No obstacles -> full proposed length."""
result = clamp_stub_length(100, 100, 180, 7.62, [])
assert result == 7.62
def test_obstacle_in_path_shortens_stub_right(self):
"""Obstacle 5mm to the right of pin with rot=180 (stub goes right)."""
result = clamp_stub_length(100, 100, 180, 7.62, [(105, 100)])
# Should shorten: 5.0 - 1.27 = 3.73
assert result == pytest.approx(3.73)
def test_obstacle_in_path_shortens_stub_left(self):
"""Obstacle 5mm to the left of pin with rot=0 (stub goes left)."""
result = clamp_stub_length(100, 100, 0, 7.62, [(95, 100)])
# Should shorten: 5.0 - 1.27 = 3.73
assert result == pytest.approx(3.73)
def test_obstacle_in_path_shortens_stub_up(self):
"""Obstacle 5mm above pin with rot=90 (stub goes up)."""
result = clamp_stub_length(100, 100, 90, 7.62, [(100, 95)])
assert result == pytest.approx(3.73)
def test_obstacle_in_path_shortens_stub_down(self):
"""Obstacle 5mm below pin with rot=270 (stub goes down)."""
result = clamp_stub_length(100, 100, 270, 7.62, [(100, 105)])
assert result == pytest.approx(3.73)
def test_obstacle_off_axis_ignored(self):
"""Obstacle far off the stub axis -> no clamping."""
# Stub goes right (rot=180), obstacle is 10mm above
result = clamp_stub_length(100, 100, 180, 7.62, [(105, 110)])
assert result == 7.62
def test_obstacle_behind_pin_ignored(self):
"""Obstacle behind the pin (opposite direction) -> no clamping."""
# Stub goes right (rot=180), obstacle is to the left
result = clamp_stub_length(100, 100, 180, 7.62, [(95, 100)])
assert result == 7.62
def test_minimum_floor(self):
"""Very close obstacle -> clamped to minimum, not zero."""
# Obstacle 1mm away, clearance 1.27 -> would give negative, clamped to 2.54
result = clamp_stub_length(100, 100, 180, 7.62, [(101, 100)])
assert result == 2.54
def test_small_passive_scenario(self):
"""Simulates a 5.08mm resistor with stubs on both pins."""
# Pin 1 at (100, 100), pin 2 at (105.08, 100)
# Pin 1 stub goes left (rot=0), pin 2 is to the right -> no collision
r1 = clamp_stub_length(100, 100, 0, 7.62, [(105.08, 100)])
assert r1 == 7.62 # pin 2 is behind, no collision
# Pin 2 stub goes right (rot=180), pin 1 is to the left -> no collision
r2 = clamp_stub_length(105.08, 100, 180, 7.62, [(100, 100)])
assert r2 == 7.62 # pin 1 is behind, no collision
def test_opposing_stubs_on_vertical_component(self):
"""Two pins on a vertical component with stubs pointing toward each other."""
# Pin 1 at (100, 100) stub goes down (rot=270)
# Pin 2 at (100, 115) is in the downward path
r1 = clamp_stub_length(100, 100, 270, 7.62, [(100, 115)])
# dist=15, but that's beyond 7.62, so no clamping
assert r1 == 7.62
# Now with closer spacing (5mm apart)
# Pin 1 at (100, 100) stub goes down, pin 2 at (100, 105)
r2 = clamp_stub_length(100, 100, 270, 7.62, [(100, 105)])
assert r2 == pytest.approx(3.73)
def test_custom_clearance(self):
"""Custom clearance value is respected."""
result = clamp_stub_length(
100, 100, 180, 7.62, [(105, 100)], clearance=2.54,
)
# 5.0 - 2.54 = 2.46, below minimum -> clamped to 2.54
assert result == 2.54
def test_custom_minimum(self):
"""Custom minimum value is respected."""
result = clamp_stub_length(
100, 100, 180, 7.62, [(101, 100)], minimum=1.27,
)
assert result == 1.27
def test_multiple_obstacles_uses_nearest(self):
"""Multiple obstacles -> shortest safe length wins."""
result = clamp_stub_length(
100, 100, 180, 7.62,
[(103, 100), (106, 100), (110, 100)],
)
# Nearest is 3mm away: 3.0 - 1.27 = 1.73, below minimum -> 2.54
assert result == 2.54
# ---------------------------------------------------------------------------
# generate_wire_sexp tests
# ---------------------------------------------------------------------------
class TestGenerateWireSexp:
def test_basic_wire_sexp(self):
sexp = generate_wire_sexp(100.0, 200.0, 110.5, 200.0)
assert "\t(wire\n" in sexp
assert "\t\t(pts\n" in sexp
assert "(xy 100 200) (xy 110.5 200)" in sexp
assert "\t\t(stroke\n" in sexp
assert "\t\t\t(width 0)" in sexp
assert "\t\t\t(type default)" in sexp
assert "\t\t(uuid" in sexp
def test_custom_uuid(self):
sexp = generate_wire_sexp(0, 0, 10, 10, uuid_str="test-wire-uuid")
assert '(uuid "test-wire-uuid")' in sexp
def test_auto_uuid_is_unique(self):
import re
sexp1 = generate_wire_sexp(0, 0, 10, 10)
sexp2 = generate_wire_sexp(0, 0, 10, 10)
uuids = re.findall(r'\(uuid "([^"]+)"\)', sexp1 + sexp2)
assert len(uuids) == 2
assert uuids[0] != uuids[1]
def test_round_trip_parse(self, tmp_path):
"""Generated wire should be parseable by parse_wire_segments."""
sexp = generate_wire_sexp(148.59, 194.31, 156.21, 194.31, uuid_str="rt-uuid")
content = f"(kicad_sch\n (version 20231120)\n{sexp})\n"
filepath = str(tmp_path / "wire_rt.kicad_sch")
with open(filepath, "w") as f:
f.write(content)
wires = parse_wire_segments(filepath)
assert len(wires) == 1
assert wires[0]["start"]["x"] == pytest.approx(148.59)
assert wires[0]["end"]["x"] == pytest.approx(156.21)
assert wires[0]["uuid"] == "rt-uuid"
# ---------------------------------------------------------------------------
# resolve_pin_position_and_orientation tests
# ---------------------------------------------------------------------------
class TestResolvePinPositionAndOrientation:
def test_returns_position_and_rotation(self, sample_schematic_file):
"""Sexp fallback returns x, y, and schematic_rotation."""
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position_and_orientation
sch = MagicMock()
# API path should fail so we exercise the sexp fallback
sch.get_component_pin_position.return_value = None
comp = MagicMock()
comp.lib_id = "Espressif:ESP32-P4"
comp.position = MagicMock()
comp.position.x = 200.0
comp.position.y = 150.0
comp.rotation = 0
comp.mirror = None
sch.components.get.return_value = comp
result = resolve_pin_position_and_orientation(
sch, sample_schematic_file, "U1", "1",
)
assert result is not None
# Pin 1 (GPIO0) at local (-25.4, 22.86) rotation 0° (Y-up)
# Component at (200, 150) with 0° rotation, Y negated for schematic Y-down
assert result["x"] == pytest.approx(200 - 25.4, abs=0.01)
assert result["y"] == pytest.approx(150 - 22.86, abs=0.01)
assert result["schematic_rotation"] == pytest.approx(0)
def test_returns_none_for_missing_component(self, sample_schematic_file):
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position_and_orientation
sch = MagicMock()
# API path should fail
sch.get_component_pin_position.return_value = None
sch.components.get.return_value = None
result = resolve_pin_position_and_orientation(
sch, sample_schematic_file, "U99", "1",
)
assert result is None
def test_mirrored_component_flips_rotation(self, sample_schematic_file):
"""Mirror-x should reflect the pin rotation (180 - rot) % 360."""
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position_and_orientation
sch = MagicMock()
# API path should fail so we exercise the sexp fallback
sch.get_component_pin_position.return_value = None
comp = MagicMock()
comp.lib_id = "Espressif:ESP32-P4"
comp.position = MagicMock()
comp.position.x = 200.0
comp.position.y = 150.0
comp.rotation = 0
comp.mirror = "x"
sch.components.get.return_value = comp
result = resolve_pin_position_and_orientation(
sch, sample_schematic_file, "U1", "1",
)
assert result is not None
# Pin 1 rotation is 0° local. With mirror_x: (180-0)%360 = 180°
assert result["schematic_rotation"] == pytest.approx(180)
class TestCheckWireCollision:
"""Tests for axis-aligned wire segment collision detection."""
def test_no_collision_empty_list(self):
from mckicad.utils.sexp_parser import check_wire_collision
result = check_wire_collision(
0, 0, 0, 2.54, "NET_A", [],
)
assert result is False
def test_no_collision_different_axis(self):
"""Vertical and horizontal wires on different axes never collide."""
from mckicad.utils.sexp_parser import check_wire_collision
placed = [((0.0, 0.0), (2.54, 0.0), "NET_A")] # horizontal
result = check_wire_collision(
0, 0, 0, 2.54, "NET_B", placed, # vertical
)
assert result is False
def test_no_collision_same_net(self):
"""Same-net wires overlapping is harmless."""
from mckicad.utils.sexp_parser import check_wire_collision
placed = [((0.0, 0.0), (0.0, 2.54), "NET_A")]
result = check_wire_collision(
0, 0, 0, 5.08, "NET_A", placed,
)
assert result is False
def test_collision_vertical_overlap(self):
"""Two vertical wires on same X with overlapping Y ranges collide."""
from mckicad.utils.sexp_parser import check_wire_collision
placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")]
result = check_wire_collision(
100, 101, 100, 103.54, "NET_B", placed,
)
assert result is True
def test_collision_horizontal_overlap(self):
"""Two horizontal wires on same Y with overlapping X ranges collide."""
from mckicad.utils.sexp_parser import check_wire_collision
placed = [((100.0, 100.0), (102.54, 100.0), "NET_A")]
result = check_wire_collision(
101, 100, 103.54, 100, "NET_B", placed,
)
assert result is True
def test_no_collision_parallel_but_offset(self):
"""Parallel vertical wires at different X don't collide."""
from mckicad.utils.sexp_parser import check_wire_collision
placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")]
result = check_wire_collision(
101.27, 100, 101.27, 102.54, "NET_B", placed,
)
assert result is False
def test_no_collision_non_overlapping_range(self):
"""Collinear wires with non-overlapping ranges don't collide."""
from mckicad.utils.sexp_parser import check_wire_collision
placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")]
result = check_wire_collision(
100, 105, 100, 107.54, "NET_B", placed,
)
assert result is False
class TestResolveWireCollision:
"""Tests for wire collision resolution with perpendicular shifting."""
def test_no_collision_returns_original(self):
from mckicad.utils.sexp_parser import resolve_wire_collision
placed = []
result = resolve_wire_collision(
0, 0, 0, 2.54, 270, "NET_A", placed,
)
assert result == (0, 0, 0, 2.54)
assert len(placed) == 1
def test_vertical_collision_shifts_horizontal(self):
"""Vertical stub collision shifts the wire+label horizontally."""
from mckicad.utils.sexp_parser import resolve_wire_collision
placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")]
result = resolve_wire_collision(
100, 101, 100, 103.54, 270, "NET_B", placed,
)
# Should shift by +1.27 in X (perpendicular to vertical)
assert result == (101.27, 101, 101.27, 103.54)
assert len(placed) == 2
def test_horizontal_collision_shifts_vertical(self):
"""Horizontal stub collision shifts the wire+label vertically."""
from mckicad.utils.sexp_parser import resolve_wire_collision
placed = [((100.0, 100.0), (102.54, 100.0), "NET_A")]
result = resolve_wire_collision(
101, 100, 103.54, 100, 0, "NET_B", placed,
)
# Should shift by -1.27 in Y (perpendicular to horizontal)
assert result == (101, 98.73, 103.54, 98.73)
assert len(placed) == 2
def test_custom_offset(self):
"""Custom offset value is used for perpendicular shift."""
from mckicad.utils.sexp_parser import resolve_wire_collision
placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")]
result = resolve_wire_collision(
100, 101, 100, 103.54, 270, "NET_B", placed, offset=2.54,
)
assert result == (102.54, 101, 102.54, 103.54)
def test_placed_wires_updated(self):
"""The placed_wires list is updated with the final (shifted) segment."""
from mckicad.utils.sexp_parser import resolve_wire_collision
placed = [((100.0, 100.0), (100.0, 102.54), "NET_A")]
resolve_wire_collision(
100, 101, 100, 103.54, 270, "NET_B", placed,
)
assert len(placed) == 2
# The new segment should be at the shifted position
new_seg = placed[1]
assert new_seg[2] == "NET_B"
assert new_seg[0][0] == 101.27 # shifted X
# ---------------------------------------------------------------------------
# Multi-unit pin-to-unit mapping
# ---------------------------------------------------------------------------
# TL072 has 3 units: unit 1 (pins 1,2,3), unit 2 (pins 5,6,7), unit 3 (pins 4,8)
MULTI_UNIT_SCHEMATIC = """\
(kicad_sch (version 20231120) (generator "test")
(uuid "root-uuid")
(lib_symbols
(symbol "Amplifier_Operational:TL072"
(symbol "TL072_1_1"
(pin output line (at 5.08 0 180) (length 2.54)
(name "~") (number "1"))
(pin input line (at -5.08 2.54 0) (length 2.54)
(name "+In") (number "3"))
(pin input inverted (at -5.08 -2.54 0) (length 2.54)
(name "-In") (number "2"))
)
(symbol "TL072_2_1"
(pin output line (at 5.08 0 180) (length 2.54)
(name "~") (number "7"))
(pin input line (at -5.08 2.54 0) (length 2.54)
(name "+In") (number "5"))
(pin input inverted (at -5.08 -2.54 0) (length 2.54)
(name "-In") (number "6"))
)
(symbol "TL072_3_1"
(pin power_in line (at -2.54 3.81 270) (length 2.54)
(name "V+") (number "8"))
(pin power_in line (at -2.54 -3.81 90) (length 2.54)
(name "V-") (number "4"))
)
)
)
)
"""
class TestParseLibSymbolPinUnits:
"""Tests for parse_lib_symbol_pin_units (pin-to-unit mapping)."""
def test_maps_pins_to_units(self):
"""Each pin number maps to its owning unit."""
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try:
pin_units = parse_lib_symbol_pin_units(
path, "Amplifier_Operational:TL072",
)
assert pin_units["1"] == 1
assert pin_units["2"] == 1
assert pin_units["3"] == 1
assert pin_units["5"] == 2
assert pin_units["6"] == 2
assert pin_units["7"] == 2
assert pin_units["4"] == 3
assert pin_units["8"] == 3
finally:
os.unlink(path)
def test_single_unit_returns_empty(self):
"""Single-unit symbols have no sub-symbols, so map is empty."""
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
f.write(SAMPLE_SCHEMATIC)
path = f.name
try:
pin_units = parse_lib_symbol_pin_units(path, "Device:R")
# Device:R has sub-symbols R_0_1 and R_1_1 which encode unit 0 and 1
# but single-unit symbols may still have sub-symbols
# The key test is that single-unit resolution still works
assert isinstance(pin_units, dict)
finally:
os.unlink(path)
def test_nonexistent_symbol_returns_empty(self):
"""Missing symbol returns empty dict."""
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try:
pin_units = parse_lib_symbol_pin_units(path, "Nonexistent:Symbol")
assert pin_units == {}
finally:
os.unlink(path)
def test_nonexistent_file_returns_empty(self):
"""Missing file returns empty dict."""
pin_units = parse_lib_symbol_pin_units(
"/tmp/does_not_exist.kicad_sch", "Amplifier_Operational:TL072",
)
assert pin_units == {}
class TestFindComponentForPin:
"""Tests for _find_component_for_pin (multi-unit unit selection)."""
def test_finds_correct_unit_for_pin(self):
"""Pin 8 (V+) belongs to unit 3 — returns the unit 3 instance."""
from kicad_sch_api import load_schematic
from mckicad.utils.sexp_parser import _find_component_for_pin
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try:
sch = load_schematic(path)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 102), unit=1,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 145), unit=2,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 175), unit=3,
)
# Pin 8 → unit 3
comp = _find_component_for_pin(
sch, "U2", "8", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 3
# Pin 1 → unit 1
comp = _find_component_for_pin(
sch, "U2", "1", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 1
# Pin 5 → unit 2
comp = _find_component_for_pin(
sch, "U2", "5", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 2
finally:
os.unlink(path)
def test_single_unit_returns_get(self):
"""Single-unit symbol falls back to sch.components.get()."""
from kicad_sch_api import load_schematic
from mckicad.utils.sexp_parser import _find_component_for_pin
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
f.write(SAMPLE_SCHEMATIC)
path = f.name
try:
sch = load_schematic(path)
sch.components.add(
lib_id="Device:R", reference="R1",
value="10k", position=(100, 100),
)
comp = _find_component_for_pin(
sch, "R1", "1", path, "Device:R",
)
assert comp is not None
assert comp.reference == "R1"
finally:
os.unlink(path)
class TestMultiUnitPinResolution:
"""Integration: resolve_pin_position returns correct coords per unit."""
def test_pin_resolves_to_correct_unit_position(self):
"""Pin 8 (unit 3 at y=175) resolves near y=175, not y=102."""
from mckicad.utils.sexp_parser import resolve_pin_position
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try:
from kicad_sch_api import load_schematic
sch = load_schematic(path)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 102), unit=1,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 145), unit=2,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 175), unit=3,
)
# Pin 8 (V+) → unit 3 at y=175
pos_8 = resolve_pin_position(sch, path, "U2", "8")
assert pos_8 is not None
# Y should be near 175 (unit 3), not near 102 (unit 1)
assert abs(pos_8[1] - 175) < 10, (
f"Pin 8 y={pos_8[1]:.1f}, expected near 175 (unit 3)"
)
# Pin 1 (output) → unit 1 at y=102
pos_1 = resolve_pin_position(sch, path, "U2", "1")
assert pos_1 is not None
assert abs(pos_1[1] - 102) < 10, (
f"Pin 1 y={pos_1[1]:.1f}, expected near 102 (unit 1)"
)
# Pin 5 (+In) → unit 2 at y=145
pos_5 = resolve_pin_position(sch, path, "U2", "5")
assert pos_5 is not None
assert abs(pos_5[1] - 145) < 10, (
f"Pin 5 y={pos_5[1]:.1f}, expected near 145 (unit 2)"
)
finally:
os.unlink(path)