kicad-mcp/tests/test_sexp_parser.py
Ryan Malloy f797e9e070 Fix Y-axis inversion and label_connections save-order race condition
Two bugs in pin position resolution that caused incorrect schematic
coordinates and 28% label placement failures:

1. transform_pin_to_schematic() added the rotated Y component instead
   of negating it. lib_symbol pins use Y-up; schematics use Y-down.
   Fix: comp_y + ry -> comp_y - ry.

2. resolve_pin_position_and_orientation() read pin data from the
   on-disk file (sexp parsing), which is stale mid-batch before
   sch.save(). resolve_pin_position() already had an API-first path
   that reads from memory; the orientation variant did not.
   Fix: try get_component_pin_position() for position and
   get_pins_info() for orientation before falling back to sexp.

Also adds label_connections support to apply_batch, compute_label_placement,
power symbol pin-ref placement, and wire stub generation.
2026-03-06 17:08:57 -07:00

1034 lines
33 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,
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,
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 TestGenerateLabelSexp:
def test_basic_local_label(self):
sexp = generate_label_sexp("NET_A", 100.5, 200.25)
assert '(label "NET_A"' in sexp
assert "(at 100.5 200.25 0)" in sexp
assert "(uuid" in sexp
assert "(effects" in sexp
def test_custom_uuid(self):
sexp = generate_label_sexp("X", 0, 0, uuid_str="test-uuid-123")
assert '(uuid "test-uuid-123")' in sexp
def test_rotation(self):
sexp = generate_label_sexp("CLK", 50, 50, rotation=90)
assert "(at 50 50 90)" in sexp
def test_auto_uuid_is_unique(self):
sexp1 = generate_label_sexp("A", 0, 0)
sexp2 = generate_label_sexp("A", 0, 0)
# Extract UUIDs
import re
uuids = re.findall(r'\(uuid "([^"]+)"\)', sexp1 + sexp2)
assert len(uuids) == 2
assert uuids[0] != uuids[1]
def test_quote_escaping(self):
sexp = generate_label_sexp('NET"SPECIAL', 0, 0)
assert r'(label "NET\"SPECIAL"' in sexp
def test_backslash_escaping(self):
sexp = generate_label_sexp("NET\\PATH", 0, 0)
assert r'(label "NET\\PATH"' in sexp
class TestGenerateGlobalLabelSexp:
def test_basic_global_label(self):
sexp = generate_global_label_sexp("VBUS_OUT", 187.96, 114.3)
assert '(global_label "VBUS_OUT"' in sexp
assert "(shape bidirectional)" in sexp
assert "(at 187.96 114.3 0)" in sexp
assert "Intersheetrefs" in sexp
assert "${INTERSHEET_REFS}" in sexp
def test_custom_shape(self):
sexp = generate_global_label_sexp("CLK", 0, 0, shape="output")
assert "(shape output)" in sexp
def test_rotation(self):
sexp = generate_global_label_sexp("SIG", 10, 20, rotation=180)
assert "(at 10 20 180)" in sexp
def test_round_trip_parse(self, tmp_path):
"""Generated global label should be parseable by parse_global_labels."""
sexp = generate_global_label_sexp("TEST_NET", 123.45, 67.89)
# Wrap in a minimal schematic
content = f"(kicad_sch\n (version 20231120)\n{sexp})\n"
filepath = str(tmp_path / "test.kicad_sch")
with open(filepath, "w") as f:
f.write(content)
labels = parse_global_labels(filepath)
assert len(labels) == 1
assert labels[0]["text"] == "TEST_NET"
assert labels[0]["x"] == pytest.approx(123.45)
assert labels[0]["y"] == pytest.approx(67.89)
class TestInsertSexpBeforeClose:
def test_insert_into_minimal_schematic(self, tmp_path):
filepath = str(tmp_path / "test.kicad_sch")
with open(filepath, "w") as f:
f.write("(kicad_sch\n (version 20231120)\n)\n")
insert_sexp_before_close(filepath, ' (label "X"\n (at 0 0 0)\n )\n')
with open(filepath) as f:
content = f.read()
assert '(label "X"' in content
assert content.strip().endswith(")")
assert content.startswith("(kicad_sch")
def test_preserves_existing_content(self, tmp_path):
filepath = str(tmp_path / "test.kicad_sch")
original = '(kicad_sch\n (version 20231120)\n (uuid "abc")\n)\n'
with open(filepath, "w") as f:
f.write(original)
insert_sexp_before_close(filepath, ' (label "Y"\n (at 1 2 0)\n )\n')
with open(filepath) as f:
content = f.read()
assert '(uuid "abc")' in content
assert '(label "Y"' in content
def test_rejects_non_kicad_file(self, tmp_path):
filepath = str(tmp_path / "bad.kicad_sch")
with open(filepath, "w") as f:
f.write("not a kicad file")
with pytest.raises(ValueError, match="Not a KiCad schematic"):
insert_sexp_before_close(filepath, "(label)")
def test_multiple_insertions(self, tmp_path):
filepath = str(tmp_path / "multi.kicad_sch")
with open(filepath, "w") as f:
f.write("(kicad_sch\n (version 20231120)\n)\n")
insert_sexp_before_close(filepath, ' (label "A"\n (at 0 0 0)\n )\n')
insert_sexp_before_close(filepath, ' (label "B"\n (at 10 10 0)\n )\n')
with open(filepath) as f:
content = f.read()
assert '(label "A"' in content
assert '(label "B"' in content
def test_file_not_found(self, tmp_path):
with pytest.raises(FileNotFoundError):
insert_sexp_before_close(str(tmp_path / "missing.kicad_sch"), "(label)")
class TestResolvePinPosition:
"""Tests for resolve_pin_position (requires mocking sch object)."""
def test_returns_api_result_when_available(self):
"""When the API returns a position, use it directly."""
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position
sch = MagicMock()
pos = MagicMock()
pos.x = 100.0
pos.y = 200.0
sch.get_component_pin_position.return_value = pos
result = resolve_pin_position(sch, "/fake/path.kicad_sch", "R1", "1")
assert result == (100.0, 200.0)
def test_returns_none_when_api_returns_none_and_no_component(self):
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position
sch = MagicMock()
sch.get_component_pin_position.return_value = None
sch.components.get.return_value = None
result = resolve_pin_position(sch, "/fake/path.kicad_sch", "U99", "1")
assert result is None
def test_falls_back_to_sexp(self, sample_schematic_file):
"""When API returns None, use sexp parsing for pin resolution."""
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position
sch = MagicMock()
sch.get_component_pin_position.return_value = None
# Mock a component at position (100, 100) with lib_id "Device:R"
comp = MagicMock()
comp.lib_id = "Device:R"
comp.position = MagicMock()
comp.position.x = 100.0
comp.position.y = 100.0
comp.rotation = 0
comp.mirror = None
sch.components.get.return_value = comp
result = resolve_pin_position(sch, sample_schematic_file, "R1", "1")
assert result is not None
# Pin 1 of Device:R is at (0, 3.81) in local coords (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 "(wire (pts (xy 100 200) (xy 110.5 200))" in sexp
assert "(stroke (width 0) (type default))" in sexp
assert "(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)