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
clamp_stub_length() was treating all pins as potential obstacles, including pins on the same component. On vertical caps like C7 with 5.08mm pin spacing, pin 1 clamped pin 2's stub to near-zero. Added exclude_points parameter so callers can skip same-component pins that cannot cause external net bridges.
1708 lines
58 KiB
Python
1708 lines
58 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
|
|
|
|
def test_exclude_points_skips_same_component(self):
|
|
"""Excluded obstacles (same-component pins) are ignored."""
|
|
# Pin at (100, 100), obstacle at (105, 100) would normally clamp.
|
|
# But if (105, 100) is excluded (same component), no clamping.
|
|
result = clamp_stub_length(
|
|
100, 100, 180, 7.62,
|
|
[(105, 100)],
|
|
exclude_points=[(105, 100)],
|
|
)
|
|
assert result == 7.62
|
|
|
|
def test_exclude_points_only_skips_matching(self):
|
|
"""Non-excluded obstacles still clamp even when exclude_points is set."""
|
|
# Two obstacles: (105, 100) excluded, (104, 100) not excluded
|
|
result = clamp_stub_length(
|
|
100, 100, 180, 7.62,
|
|
[(105, 100), (104, 100)],
|
|
exclude_points=[(105, 100)],
|
|
)
|
|
# (104, 100) is 4mm away: 4.0 - 1.27 = 2.73
|
|
assert result == pytest.approx(2.73)
|
|
|
|
def test_exclude_points_vertical_cap_scenario(self):
|
|
"""Simulates C7: vertical cap where pin 1 is in pin 2's stub path.
|
|
|
|
Pin 1 at (302.26, 215.46), pin 2 at (302.26, 220.54).
|
|
Pin 2 stub goes down (rot=270). Pin 1 is above (dist negative) so
|
|
it wouldn't clamp anyway — but the *real* scenario is pin 1's stub
|
|
going up from pin 1 wouldn't collide with pin 2. The key case:
|
|
pin 1 stub going down (rot=270) at (302.26, 215.46) would see
|
|
pin 2 at (302.26, 220.54) as an obstacle. With exclude, it doesn't.
|
|
"""
|
|
result = clamp_stub_length(
|
|
302.26, 215.46, 270, 7.62,
|
|
[(302.26, 220.54)],
|
|
exclude_points=[(302.26, 220.54)],
|
|
)
|
|
assert result == 7.62 # Not clamped — same component excluded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_label_only(self):
|
|
"""Vertical stub collision shifts only the label end, not the pin start."""
|
|
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,
|
|
)
|
|
# Start stays anchored at pin, label shifts +1.27 in X
|
|
assert result == (100, 101, 101.27, 103.54)
|
|
assert len(placed) == 2
|
|
|
|
def test_horizontal_collision_shifts_label_only(self):
|
|
"""Horizontal stub collision shifts only the label end, not the pin start."""
|
|
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,
|
|
)
|
|
# Start stays anchored at pin, label shifts -1.27 in Y
|
|
assert result == (101, 100, 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,
|
|
)
|
|
# Start anchored, label shifts by custom offset
|
|
assert result == (100, 101, 102.54, 103.54)
|
|
|
|
def test_placed_wires_updated(self):
|
|
"""The placed_wires list is updated with the final 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
|
|
# Start stays at pin, end is shifted
|
|
new_seg = placed[1]
|
|
assert new_seg[2] == "NET_B"
|
|
assert new_seg[0][0] == 100 # start X anchored at pin
|
|
assert new_seg[1][0] == 101.27 # end X shifted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|