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
Wire collision detection: apply_batch now tracks placed wire segments and detects collinear stubs on the same axis with overlapping ranges belonging to different nets. Colliding wires shift perpendicular to their axis by 1.27mm, preventing KiCad from merging wire segments into mega-nets. Project-local library resolution: apply_batch now scans batch component lib_ids for unknown libraries and registers them with kicad-sch-api's SymbolLibraryCache via sym-lib-table parsing before component placement. Unblocks projects using Samacsys and other non-standard symbol libraries. Root ERC: run_schematic_erc accepts root=True to resolve to the project root schematic before running kicad-cli, enabling hierarchy-aware ERC that eliminates ~180 false-positive global_label_dangling warnings from sub-sheet isolation. 270/270 tests pass, ruff + mypy clean.
1318 lines
44 KiB
Python
1318 lines
44 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 (
|
|
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_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"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|