kicad-mcp/tests/test_sch_document.py
Ryan Malloy 57872e59c1 Replace kicad-sch-api with internal SchDocument engine
Eliminate the external kicad-sch-api dependency by migrating all MCP
tools to the internal SchDocument class built on sexp_tree.py. This
fixes three serialization bugs (dropped global labels, TypeError on
local labels, mis-quoted property private keywords) and removes ~1,900
lines of workaround code.

New modules:
- sexp_tree.py: S-expression parser and round-trip serializer
- sch_document.py: schematic read/write/mutate API
- lib_resolver.py: symbol library search and resolution
- sch_helpers.py: shared load/validate/expand helpers

Migrated all 9 tool files, resources, and tests. Removed 5 workaround
functions from sexp_parser.py. 531 tests pass, ruff + mypy clean.
2026-07-11 16:47:22 -06:00

1583 lines
51 KiB
Python

"""Tests for the SchDocument class — read and write paths."""
import os
import textwrap
import pytest
from mckicad.utils.sch_document import (
Component,
ComponentCollection,
GlobalLabel,
HierarchicalLabel,
Label,
LabelCollection,
Point,
SchDocument,
Sheet,
Wire,
load_schematic,
)
from mckicad.utils.sexp_tree import find_one
# ---------------------------------------------------------------------------
# Test fixture: a comprehensive .kicad_sch file
# ---------------------------------------------------------------------------
# Two-pin resistor and a 3-pin ESP32 stand-in in lib_symbols, plus instances
# R1, R2 (rotated), U1 (mirrored), wires, labels of every type, a sheet,
# no_connect and junction nodes.
FIXTURE_SCH = textwrap.dedent("""\
(kicad_sch
(version 20231120)
(generator "test")
(generator_version "9.0")
(uuid "sch-uuid-0001")
(paper "A4")
(lib_symbols
(symbol "Device:R"
(pin_names (offset 0))
(symbol "Device:R_0_1"
(polyline (pts (xy 0 -1.016) (xy 0 1.016)))
)
(symbol "Device:R_1_1"
(pin passive line
(at 0 1.27 270)
(length 0.508)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line
(at 0 -1.27 90)
(length 0.508)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "TestLib:ESP32_Mini"
(symbol "TestLib:ESP32_Mini_1_1"
(pin input line
(at -7.62 2.54 0)
(length 2.54)
(name "EN" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin power_in line
(at 0 7.62 270)
(length 2.54)
(name "VCC" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
(pin power_in line
(at 0 -7.62 90)
(length 2.54)
(name "GND" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27))))
)
)
)
)
(symbol
(lib_id "Device:R")
(at 100.33 100.33 0)
(unit 1)
(in_bom yes)
(on_board yes)
(dnp no)
(uuid "comp-uuid-r1")
(property "Reference" "R1" (at 101.6 99.06 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at 101.6 101.6 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "Resistor_SMD:R_0402" (at 100.33 100.33 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(pin "1" (uuid "pin-uuid-r1-1"))
(pin "2" (uuid "pin-uuid-r1-2"))
)
(symbol
(lib_id "Device:R")
(at 200.66 100.33 90)
(unit 1)
(in_bom yes)
(on_board yes)
(dnp no)
(uuid "comp-uuid-r2")
(property "Reference" "R2" (at 201.93 99.06 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "4.7k" (at 201.93 101.6 0)
(effects (font (size 1.27 1.27)))
)
(pin "1" (uuid "pin-uuid-r2-1"))
(pin "2" (uuid "pin-uuid-r2-2"))
)
(symbol
(lib_id "TestLib:ESP32_Mini")
(at 150.0 200.0 0)
(mirror x)
(unit 1)
(in_bom yes)
(on_board yes)
(dnp no)
(uuid "comp-uuid-u1")
(property "Reference" "U1" (at 152.0 198.0 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "ESP32" (at 152.0 202.0 0)
(effects (font (size 1.27 1.27)))
)
(pin "1" (uuid "pin-uuid-u1-1"))
(pin "2" (uuid "pin-uuid-u1-2"))
(pin "3" (uuid "pin-uuid-u1-3"))
)
(wire
(pts
(xy 100.33 101.6) (xy 100.33 110.0)
)
(stroke (width 0) (type default))
(uuid "wire-uuid-0001")
)
(wire
(pts
(xy 100.33 110.0) (xy 150.0 110.0)
)
(stroke (width 0) (type default))
(uuid "wire-uuid-0002")
)
(label "NET_A"
(at 100.33 110.0 0)
(effects
(font (size 1.27 1.27))
(justify left bottom)
)
(uuid "label-uuid-0001")
)
(label "NET_B"
(at 200.66 110.0 0)
(effects
(font (size 1.27 1.27))
(justify left bottom)
)
(uuid "label-uuid-0002")
)
(global_label "VCC_3V3"
(shape input)
(at 150.0 50.0 90)
(effects
(font (size 1.27 1.27))
(justify left)
)
(uuid "glabel-uuid-0001")
(property "Intersheetrefs" "${INTERSHEET_REFS}"
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
)
(global_label "GND"
(shape passive)
(at 150.0 250.0 270)
(effects
(font (size 1.27 1.27))
(justify left)
)
(uuid "glabel-uuid-0002")
)
(hierarchical_label "SPI_CLK"
(shape output)
(at 250.0 150.0 0)
(effects
(font (size 1.27 1.27))
(justify left)
)
(uuid "hlabel-uuid-0001")
)
(sheet
(at 50.0 50.0) (size 30.0 20.0)
(fields_autoplaced yes)
(stroke (width 0.1524) (type solid))
(fill (color 0 0 0 0.0000))
(uuid "sheet-uuid-0001")
(property "Sheetname" "PowerSupply" (at 50.0 49.0 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
)
(property "Sheetfile" "power.kicad_sch" (at 50.0 70.0 0)
(effects (font (size 1.27 1.27)) (justify left top) (hide yes))
)
)
(no_connect (at 300.0 300.0) (uuid "nc-uuid-0001"))
(junction (at 100.33 110.0) (diameter 0) (color 0 0 0 0) (uuid "junc-uuid-0001"))
)
""")
@pytest.fixture
def sch_file(tmp_path):
"""Write the fixture schematic to a temp file and return the path."""
path = tmp_path / "test.kicad_sch"
path.write_text(FIXTURE_SCH)
return str(path)
@pytest.fixture
def doc(sch_file):
"""Load the fixture as a SchDocument."""
return SchDocument(sch_file)
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
class TestLoading:
def test_loads_from_file(self, sch_file):
doc = SchDocument(sch_file)
assert doc.uuid == "sch-uuid-0001"
def test_extracts_uuid(self, doc):
assert doc.uuid == "sch-uuid-0001"
def test_load_schematic_convenience(self, sch_file):
doc = load_schematic(sch_file)
assert isinstance(doc, SchDocument)
assert doc.uuid == "sch-uuid-0001"
def test_handles_empty_schematic(self, tmp_path):
path = tmp_path / "empty.kicad_sch"
path.write_text('(kicad_sch (version 20231120) (uuid "empty-uuid"))')
doc = SchDocument(str(path))
assert doc.uuid == "empty-uuid"
assert len(doc.components) == 0
assert len(doc.wires) == 0
assert len(doc.labels.all()) == 0
assert len(doc.global_labels) == 0
assert len(doc.hierarchical_labels) == 0
assert len(doc.sheets) == 0
# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------
class TestComponents:
def test_iterate(self, doc):
refs = [c.reference for c in doc.components]
assert sorted(refs) == ["R1", "R2", "U1"]
def test_count(self, doc):
assert len(doc.components) == 3
def test_get_by_ref(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
assert r1.reference == "R1"
def test_get_missing_returns_none(self, doc):
assert doc.components.get("R99") is None
def test_filter_regex(self, doc):
resistors = doc.components.filter(reference_pattern=r"^R\d+$")
refs = sorted(c.reference for c in resistors)
assert refs == ["R1", "R2"]
def test_filter_no_match(self, doc):
result = doc.components.filter(reference_pattern=r"^Q\d+$")
assert result == []
def test_all_attributes(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
assert r1.lib_id == "Device:R"
assert r1.reference == "R1"
assert r1.value == "10k"
assert r1.footprint == "Resistor_SMD:R_0402"
assert r1.position.x == pytest.approx(100.33)
assert r1.position.y == pytest.approx(100.33)
assert r1.rotation == pytest.approx(0.0)
assert r1.mirror is None
assert r1.unit == 1
assert r1.in_bom is True
assert r1.on_board is True
assert r1.uuid == "comp-uuid-r1"
def test_rotated_component(self, doc):
r2 = doc.components.get("R2")
assert r2 is not None
assert r2.rotation == pytest.approx(90.0)
assert r2.value == "4.7k"
def test_mirrored_component(self, doc):
u1 = doc.components.get("U1")
assert u1 is not None
assert u1.mirror == "x"
def test_no_footprint_returns_none(self, doc):
r2 = doc.components.get("R2")
assert r2 is not None
assert r2.footprint is None
def test_properties_dict(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
props = r1.properties
assert props["Reference"] == "R1"
assert props["Value"] == "10k"
assert props["Footprint"] == "Resistor_SMD:R_0402"
def test_to_dict(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
d = r1.to_dict()
assert d["lib_id"] == "Device:R"
assert d["reference"] == "R1"
assert d["value"] == "10k"
assert d["position"]["x"] == pytest.approx(100.33)
assert d["rotation"] == pytest.approx(0.0)
assert d["uuid"] == "comp-uuid-r1"
def test_repr(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
assert "R1" in repr(r1)
assert "Device:R" in repr(r1)
# ---------------------------------------------------------------------------
# Pin resolution
# ---------------------------------------------------------------------------
class TestPinResolution:
def test_list_component_pins_resistor(self, doc):
pins = doc.list_component_pins("R1")
assert len(pins) == 2
pin_numbers = {num for num, _ in pins}
assert pin_numbers == {"1", "2"}
def test_pin_positions_no_rotation(self, doc):
# R1 at (100.33, 100.33) rotation 0, no mirror
# Resistor pin 1: local (0, 1.27, 270°) → tip after length offset
# pin 2: local (0, -1.27, 90°)
# transform_pin_to_schematic handles the math
pins = doc.list_component_pins("R1")
pin_map = dict(pins)
# With no rotation, pin 1 (at local y=1.27) maps to schematic
# y = comp_y - ry where ry accounts for Y-flip
assert "1" in pin_map
assert "2" in pin_map
def test_get_component_pin_position_single(self, doc):
pos = doc.get_component_pin_position("R1", "1")
assert pos is not None
assert isinstance(pos, Point)
def test_missing_pin_returns_none(self, doc):
pos = doc.get_component_pin_position("R1", "99")
assert pos is None
def test_missing_component_returns_none(self, doc):
pos = doc.get_component_pin_position("R99", "1")
assert pos is None
def test_missing_component_list_returns_empty(self, doc):
pins = doc.list_component_pins("R99")
assert pins == []
def test_rotated_component_pins(self, doc):
# R2 is rotated 90°
pins = doc.list_component_pins("R2")
assert len(pins) == 2
pin_map = dict(pins)
# With 90° rotation, local Y axis becomes horizontal
p1 = pin_map["1"]
# Pins should be at different positions from unrotated R1
r1_pins = dict(doc.list_component_pins("R1"))
r1_p1 = r1_pins["1"]
# The rotated pins should differ from unrotated
assert not (
p1.x == pytest.approx(r1_p1.x) and p1.y == pytest.approx(r1_p1.y)
)
def test_mirrored_component_pins(self, doc):
# U1 has mirror x
pins = doc.list_component_pins("U1")
assert len(pins) == 3
pin_numbers = {num for num, _ in pins}
assert pin_numbers == {"1", "2", "3"}
def test_pins_from_embedded_lib_symbols(self, doc):
# All pins come from embedded lib_symbols — no external lookup needed
pins = doc.list_component_pins("R1")
assert len(pins) == 2
# Verify they have real coordinates (not 0,0)
for _, pos in pins:
assert isinstance(pos.x, float)
assert isinstance(pos.y, float)
# ---------------------------------------------------------------------------
# Wires
# ---------------------------------------------------------------------------
class TestWires:
def test_wire_count(self, doc):
assert len(doc.wires) == 2
def test_wire_start_end(self, doc):
w = doc.wires[0]
assert isinstance(w.start, Point)
assert isinstance(w.end, Point)
assert w.start.x == pytest.approx(100.33)
assert w.start.y == pytest.approx(101.6)
assert w.end.x == pytest.approx(100.33)
assert w.end.y == pytest.approx(110.0)
def test_wire_uuid(self, doc):
assert doc.wires[0].uuid == "wire-uuid-0001"
assert doc.wires[1].uuid == "wire-uuid-0002"
# ---------------------------------------------------------------------------
# Labels
# ---------------------------------------------------------------------------
class TestLabels:
def test_labels_all(self, doc):
all_labels = doc.labels.all()
assert len(all_labels) == 2
def test_label_attributes(self, doc):
all_labels = doc.labels.all()
net_a = next(lbl for lbl in all_labels if lbl.text == "NET_A")
assert net_a.position.x == pytest.approx(100.33)
assert net_a.position.y == pytest.approx(110.0)
assert net_a.uuid == "label-uuid-0001"
def test_label_statistics(self, doc):
stats = doc.labels.get_statistics()
assert stats["count"] == 2
assert stats["unique_texts"] == 2
assert sorted(stats["texts"]) == ["NET_A", "NET_B"]
def test_global_labels_list(self, doc):
assert len(doc.global_labels) == 2
def test_global_label_attributes(self, doc):
vcc = next(gl for gl in doc.global_labels if gl.text == "VCC_3V3")
assert isinstance(vcc, GlobalLabel)
assert vcc.shape == "input"
assert vcc.position.x == pytest.approx(150.0)
assert vcc.position.y == pytest.approx(50.0)
assert vcc.uuid == "glabel-uuid-0001"
def test_global_label_gnd(self, doc):
gnd = next(gl for gl in doc.global_labels if gl.text == "GND")
assert gnd.shape == "passive"
def test_hierarchical_labels_list(self, doc):
assert len(doc.hierarchical_labels) == 1
def test_hierarchical_label_attributes(self, doc):
hl = doc.hierarchical_labels[0]
assert isinstance(hl, HierarchicalLabel)
assert hl.text == "SPI_CLK"
assert hl.shape == "output"
assert hl.position.x == pytest.approx(250.0)
assert hl.position.y == pytest.approx(150.0)
assert hl.uuid == "hlabel-uuid-0001"
# ---------------------------------------------------------------------------
# Sheets
# ---------------------------------------------------------------------------
class TestSheets:
def test_sheet_count(self, doc):
assert len(doc.sheets) == 1
def test_sheet_attributes(self, doc):
s = doc.sheets[0]
assert isinstance(s, Sheet)
assert s.name == "PowerSupply"
assert s.filename == "power.kicad_sch"
assert s.uuid == "sheet-uuid-0001"
# ---------------------------------------------------------------------------
# Statistics
# ---------------------------------------------------------------------------
class TestStatistics:
def test_counts_match_fixture(self, doc):
stats = doc.get_statistics()
assert stats["components"] == 3
assert stats["wires"] == 2
assert stats["labels"] == 2
assert stats["global_labels"] == 2
assert stats["hierarchical_labels"] == 1
assert stats["sheets"] == 1
assert stats["no_connects"] == 1
assert stats["junctions"] == 1
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestValidation:
def test_clean_schematic_passes(self, doc):
warnings = doc.validate()
assert warnings == []
def test_detects_missing_value(self, tmp_path):
# Component with empty Value property
sch_text = textwrap.dedent("""\
(kicad_sch
(version 20231120)
(uuid "val-test")
(lib_symbols
(symbol "Device:R"
(symbol "Device:R_1_1"
(pin passive line
(at 0 1.27 270)
(length 0.508)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
)
)
)
(symbol
(lib_id "Device:R")
(at 100 100 0)
(unit 1)
(in_bom yes)
(on_board yes)
(uuid "val-comp-uuid")
(property "Reference" "R1" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
)
)
""")
path = tmp_path / "missing_value.kicad_sch"
path.write_text(sch_text)
doc = SchDocument(str(path))
warnings = doc.validate()
assert any("no Value" in w for w in warnings)
def test_detects_missing_lib_symbol(self, tmp_path):
# Component references a lib_symbol not in lib_symbols section
sch_text = textwrap.dedent("""\
(kicad_sch
(version 20231120)
(uuid "lib-test")
(lib_symbols)
(symbol
(lib_id "Device:R")
(at 100 100 0)
(unit 1)
(in_bom yes)
(on_board yes)
(uuid "lib-comp-uuid")
(property "Reference" "R1" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
)
)
""")
path = tmp_path / "missing_lib.kicad_sch"
path.write_text(sch_text)
doc = SchDocument(str(path))
warnings = doc.validate()
assert any("lib_symbol" in w and "Device:R" in w for w in warnings)
# ---------------------------------------------------------------------------
# KiCad 9 private property format
# ---------------------------------------------------------------------------
class TestKicad9Properties:
def test_private_property_extraction(self, tmp_path):
sch_text = textwrap.dedent("""\
(kicad_sch
(version 20231120)
(uuid "k9-test")
(lib_symbols
(symbol "Device:R"
(symbol "Device:R_1_1"
(pin passive line
(at 0 1.27 270)
(length 0.508)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
)
)
)
(symbol
(lib_id "Device:R")
(at 100 100 0)
(unit 1)
(in_bom yes)
(on_board yes)
(uuid "k9-comp-uuid")
(property "Reference" "R1" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
(property private "Footprint" "Resistor_SMD:R_0805" (at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
)
)
""")
path = tmp_path / "kicad9.kicad_sch"
path.write_text(sch_text)
doc = SchDocument(str(path))
r1 = doc.components.get("R1")
assert r1 is not None
assert r1.footprint == "Resistor_SMD:R_0805"
assert r1.properties["Footprint"] == "Resistor_SMD:R_0805"
# ---------------------------------------------------------------------------
# Duck-type compatibility with _build_connectivity_state access patterns
# ---------------------------------------------------------------------------
class TestDuckTypeCompat:
"""Verify SchDocument can be used with the same access patterns
as kicad-sch-api's loaded schematic in _build_connectivity_state().
"""
def test_iterate_wires_with_start_end(self, doc):
for wire in doc.wires:
assert hasattr(wire.start, "x")
assert hasattr(wire.start, "y")
assert hasattr(wire.end, "x")
assert hasattr(wire.end, "y")
assert hasattr(wire, "uuid")
def test_iterate_components_with_attributes(self, doc):
for comp in doc.components:
assert hasattr(comp, "reference")
assert hasattr(comp, "lib_id")
assert hasattr(comp, "position")
assert hasattr(comp.position, "x")
assert hasattr(comp.position, "y")
assert hasattr(comp, "rotation")
assert hasattr(comp, "mirror")
assert hasattr(comp, "value")
def test_list_component_pins_returns_point_like(self, doc):
for comp in doc.components:
pins = doc.list_component_pins(comp.reference)
for pin_num, pos in pins:
assert isinstance(pin_num, str)
assert hasattr(pos, "x")
assert hasattr(pos, "y")
def test_labels_all_returns_text_and_position(self, doc):
for label in doc.labels.all():
assert hasattr(label, "text")
assert hasattr(label, "position")
assert hasattr(label.position, "x")
assert hasattr(label.position, "y")
def test_hierarchical_labels_iterable(self, doc):
for hl in doc.hierarchical_labels:
assert hasattr(hl, "text")
assert hasattr(hl, "position")
def test_global_labels_available(self, doc):
# kicad-sch-api never had this — SchDocument does!
assert hasattr(doc, "global_labels")
assert len(doc.global_labels) == 2
# ---------------------------------------------------------------------------
# Multi-unit components (external library fixture)
# ---------------------------------------------------------------------------
class TestMultiUnit:
@pytest.fixture
def multi_unit_doc(self, tmp_path):
"""Schematic with a dual op-amp (2 units of same reference)."""
lib_content = textwrap.dedent("""\
(kicad_symbol_lib
(version 20241209)
(symbol "TL072"
(symbol "TL072_1_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
(symbol "TL072_2_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "7" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "5" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "6" (effects (font (size 1.27 1.27))))
)
)
)
)
""")
lib_file = tmp_path / "OpAmps.kicad_sym"
lib_file.write_text(lib_content)
table_content = (
"(sym_lib_table\n"
' (lib (name "OpAmps")(type "KiCad")'
'(uri "${KIPRJMOD}/OpAmps.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
(tmp_path / "test.kicad_pro").write_text('{"meta": {}}')
sch_text = textwrap.dedent("""\
(kicad_sch
(version 20231120)
(uuid "multi-unit-test")
(lib_symbols
(symbol "OpAmps:TL072"
(symbol "OpAmps:TL072_1_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
(symbol "OpAmps:TL072_2_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "7" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "5" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "6" (effects (font (size 1.27 1.27))))
)
)
)
)
(symbol
(lib_id "OpAmps:TL072")
(at 100.0 100.0 0)
(unit 1)
(in_bom yes)
(on_board yes)
(uuid "mu-comp-unit1")
(property "Reference" "U1" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "TL072" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
)
(symbol
(lib_id "OpAmps:TL072")
(at 200.0 100.0 0)
(unit 2)
(in_bom yes)
(on_board yes)
(uuid "mu-comp-unit2")
(property "Reference" "U1" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "TL072" (at 0 0 0)
(effects (font (size 1.27 1.27)))
)
)
)
""")
sch_path = tmp_path / "test.kicad_sch"
sch_path.write_text(sch_text)
return SchDocument(str(sch_path), project_dir=str(tmp_path))
def test_multi_unit_component_count(self, multi_unit_doc):
# Two instances of U1 (unit 1 and unit 2)
assert len(multi_unit_doc.components) == 2
def test_multi_unit_get_returns_first(self, multi_unit_doc):
u1 = multi_unit_doc.components.get("U1")
assert u1 is not None
assert u1.unit == 1
def test_multi_unit_filter_returns_both(self, multi_unit_doc):
all_u1 = multi_unit_doc.components.filter(reference_pattern=r"^U1$")
assert len(all_u1) == 2
units = sorted(c.unit for c in all_u1)
assert units == [1, 2]
def test_unit1_gets_only_unit1_pins(self, multi_unit_doc):
# The first U1 (unit 1) should get pins 1, 2, 3
# Need to use list_component_pins which uses get() -> returns unit 1
pins = multi_unit_doc.list_component_pins("U1")
pin_numbers = {num for num, _ in pins}
assert pin_numbers == {"1", "2", "3"}
# ---------------------------------------------------------------------------
# ComponentCollection edge cases
# ---------------------------------------------------------------------------
class TestComponentCollection:
def test_empty_collection(self):
cc = ComponentCollection([])
assert len(cc) == 0
assert list(cc) == []
assert cc.get("R1") is None
assert cc.filter(reference_pattern=".*") == []
# ---------------------------------------------------------------------------
# LabelCollection edge cases
# ---------------------------------------------------------------------------
class TestLabelCollection:
def test_empty_label_collection(self):
lc = LabelCollection([])
assert lc.all() == []
stats = lc.get_statistics()
assert stats["count"] == 0
assert stats["unique_texts"] == 0
assert stats["texts"] == []
def test_duplicate_label_texts(self):
labels = [
Label(text="NET_A", position=Point(0, 0), uuid="a1"),
Label(text="NET_A", position=Point(10, 10), uuid="a2"),
Label(text="NET_B", position=Point(20, 20), uuid="b1"),
]
lc = LabelCollection(labels)
stats = lc.get_statistics()
assert stats["count"] == 3
assert stats["unique_texts"] == 2
# ---------------------------------------------------------------------------
# Point dataclass
# ---------------------------------------------------------------------------
class TestPoint:
def test_attributes(self):
p = Point(1.5, 2.5)
assert p.x == 1.5
assert p.y == 2.5
def test_equality(self):
assert Point(1.0, 2.0) == Point(1.0, 2.0)
assert Point(1.0, 2.0) != Point(1.0, 3.0)
# ===========================================================================
# WRITE PATH TESTS (Phase 3)
# ===========================================================================
# ---------------------------------------------------------------------------
# Save / reload
# ---------------------------------------------------------------------------
class TestSaveReload:
def test_save_and_reload(self, doc, tmp_path):
out = str(tmp_path / "saved.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
orig_stats = doc.get_statistics()
new_stats = reloaded.get_statistics()
assert new_stats == orig_stats
def test_save_default_path(self, sch_file):
doc = SchDocument(sch_file)
doc.save() # saves back to sch_file
reloaded = SchDocument(sch_file)
assert reloaded.uuid == doc.uuid
def test_roundtrip_preserves_components(self, doc, tmp_path):
out = str(tmp_path / "rt.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
orig_refs = sorted(c.reference for c in doc.components)
new_refs = sorted(c.reference for c in reloaded.components)
assert new_refs == orig_refs
def test_roundtrip_preserves_wires(self, doc, tmp_path):
out = str(tmp_path / "rt_wires.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert len(reloaded.wires) == len(doc.wires)
def test_roundtrip_preserves_labels(self, doc, tmp_path):
out = str(tmp_path / "rt_labels.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert len(reloaded.labels.all()) == len(doc.labels.all())
assert len(reloaded.global_labels) == len(doc.global_labels)
assert len(reloaded.hierarchical_labels) == len(doc.hierarchical_labels)
def test_double_roundtrip_stable(self, doc, tmp_path):
"""Save → reload → save → reload should produce identical statistics."""
p1 = str(tmp_path / "pass1.kicad_sch")
p2 = str(tmp_path / "pass2.kicad_sch")
doc.save(p1)
doc2 = SchDocument(p1)
doc2.save(p2)
doc3 = SchDocument(p2)
assert doc3.get_statistics() == doc.get_statistics()
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
class TestCreate:
def test_create_empty(self, tmp_path):
path = str(tmp_path / "new.kicad_sch")
doc = SchDocument.create(path)
assert doc.uuid # non-empty UUID
assert len(doc.components) == 0
assert len(doc.wires) == 0
def test_create_paper_size(self, tmp_path):
path = str(tmp_path / "a3.kicad_sch")
doc = SchDocument.create(path, paper="A3")
paper = find_one(doc._tree, "paper")
assert paper is not None
assert paper.values[0] == "A3"
def test_create_has_lib_symbols(self, tmp_path):
path = str(tmp_path / "ls.kicad_sch")
doc = SchDocument.create(path)
assert doc._lib_symbols is not None
def test_create_file_exists(self, tmp_path):
path = str(tmp_path / "exists.kicad_sch")
SchDocument.create(path)
import os
assert os.path.isfile(path)
# ---------------------------------------------------------------------------
# Component mutations
# ---------------------------------------------------------------------------
class TestComponentMove:
def test_move(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
r1.move(50.0, 75.0)
assert r1.position.x == pytest.approx(50.0)
assert r1.position.y == pytest.approx(75.0)
def test_move_invalidates_cache(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
_ = r1.position # populate cache
r1.move(99.0, 88.0)
assert r1.position.x == pytest.approx(99.0)
class TestComponentRotate:
def test_rotate(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
r1.rotate(180.0)
assert r1.rotation == pytest.approx(180.0)
def test_rotate_invalidates_cache(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
_ = r1.rotation # populate cache
r1.rotate(270.0)
assert r1.rotation == pytest.approx(270.0)
class TestComponentSetValue:
def test_set_value(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
r1.set_value("100k")
assert r1.value == "100k"
def test_set_value_invalidates_cache(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
_ = r1.value
r1.set_value("22k")
assert r1.value == "22k"
class TestComponentSetFootprint:
def test_set_footprint(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
r1.set_footprint("Resistor_SMD:R_0805")
assert r1.footprint == "Resistor_SMD:R_0805"
class TestComponentSetReference:
def test_set_reference(self, doc):
r1 = doc.components.get("R1")
assert r1 is not None
r1.set_reference("R99")
assert r1.reference == "R99"
class TestMutationsSurviveSave:
def test_move_survives_save(self, doc, tmp_path):
r1 = doc.components.get("R1")
assert r1 is not None
r1.move(42.0, 84.0)
out = str(tmp_path / "moved.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
r1_new = reloaded.components.get("R1")
assert r1_new is not None
assert r1_new.position.x == pytest.approx(42.0)
assert r1_new.position.y == pytest.approx(84.0)
def test_set_value_survives_save(self, doc, tmp_path):
r1 = doc.components.get("R1")
assert r1 is not None
r1.set_value("47k")
out = str(tmp_path / "valued.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
r1_new = reloaded.components.get("R1")
assert r1_new is not None
assert r1_new.value == "47k"
# ---------------------------------------------------------------------------
# Add / remove components
# ---------------------------------------------------------------------------
class TestAddComponent:
def test_add_basic(self, doc):
comp = doc.components.add(
"Device:R", "R3", "1k", 300.0, 200.0,
)
assert isinstance(comp, Component)
assert comp.reference == "R3"
assert comp.value == "1k"
assert comp.lib_id == "Device:R"
assert len(doc.components) == 4
def test_add_with_footprint(self, doc):
comp = doc.components.add(
"Device:R", "R4", "2.2k", 350.0, 200.0,
footprint="Resistor_SMD:R_0603",
)
assert comp.footprint == "Resistor_SMD:R_0603"
def test_add_with_rotation(self, doc):
comp = doc.components.add(
"Device:R", "R5", "330", 400.0, 200.0,
rotation=90,
)
assert comp.rotation == pytest.approx(90.0)
def test_add_with_mirror(self, doc):
comp = doc.components.add(
"Device:R", "R6", "470", 450.0, 200.0,
mirror="x",
)
assert comp.mirror == "x"
def test_added_component_findable(self, doc):
doc.components.add("Device:R", "R3", "1k", 300.0, 200.0)
found = doc.components.get("R3")
assert found is not None
assert found.reference == "R3"
def test_add_survives_save(self, doc, tmp_path):
doc.components.add("Device:R", "R3", "1k", 300.0, 200.0)
out = str(tmp_path / "added.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert len(reloaded.components) == 4
r3 = reloaded.components.get("R3")
assert r3 is not None
assert r3.value == "1k"
def test_add_has_uuid(self, doc):
comp = doc.components.add("Device:R", "R3", "1k", 300.0, 200.0)
assert comp.uuid # non-empty
class TestRemoveComponent:
def test_remove_existing(self, doc):
assert doc.components.remove("R1")
assert len(doc.components) == 2
assert doc.components.get("R1") is None
def test_remove_missing(self, doc):
assert not doc.components.remove("R99")
assert len(doc.components) == 3
def test_remove_survives_save(self, doc, tmp_path):
doc.components.remove("R1")
out = str(tmp_path / "removed.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert len(reloaded.components) == 2
assert reloaded.components.get("R1") is None
# ---------------------------------------------------------------------------
# Wire operations
# ---------------------------------------------------------------------------
class TestAddWire:
def test_add_wire(self, doc):
wire = doc.add_wire(10.0, 20.0, 30.0, 40.0)
assert isinstance(wire, Wire)
assert wire.start.x == pytest.approx(10.0)
assert wire.end.y == pytest.approx(40.0)
assert wire.uuid
assert len(doc.wires) == 3
def test_add_wire_survives_save(self, doc, tmp_path):
doc.add_wire(10.0, 20.0, 30.0, 40.0)
out = str(tmp_path / "wired.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert len(reloaded.wires) == 3
class TestRemoveWire:
def test_remove_existing(self, doc):
uuid = doc.wires[0].uuid
assert doc.remove_wire(uuid)
assert len(doc.wires) == 1
def test_remove_missing(self, doc):
assert not doc.remove_wire("nonexistent-uuid")
assert len(doc.wires) == 2
# ---------------------------------------------------------------------------
# Label operations
# ---------------------------------------------------------------------------
class TestAddLabel:
def test_add_local_label(self, doc):
label = doc.add_label("NET_C", 50.0, 60.0)
assert isinstance(label, Label)
assert label.text == "NET_C"
assert label.uuid
assert len(doc.labels.all()) == 3
def test_add_global_label(self, doc):
gl = doc.add_global_label("VCC_5V", 80.0, 90.0)
assert isinstance(gl, GlobalLabel)
assert gl.text == "VCC_5V"
assert gl.shape == "bidirectional"
assert len(doc.global_labels) == 3
def test_add_global_label_shape(self, doc):
gl = doc.add_global_label("RESET", 80.0, 90.0, shape="input")
assert gl.shape == "input"
def test_add_hierarchical_label(self, doc):
hl = doc.add_hierarchical_label("SPI_MOSI", 120.0, 130.0)
assert isinstance(hl, HierarchicalLabel)
assert hl.text == "SPI_MOSI"
assert hl.shape == "bidirectional"
assert len(doc.hierarchical_labels) == 2
def test_labels_survive_save(self, doc, tmp_path):
doc.add_label("NET_C", 50.0, 60.0)
doc.add_global_label("VCC_5V", 80.0, 90.0)
doc.add_hierarchical_label("SPI_MOSI", 120.0, 130.0)
out = str(tmp_path / "labeled.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert len(reloaded.labels.all()) == 3
assert len(reloaded.global_labels) == 3
assert len(reloaded.hierarchical_labels) == 2
class TestRemoveLabel:
def test_remove_local(self, doc):
uuid = doc.labels.all()[0].uuid
assert doc.remove_label(uuid)
assert len(doc.labels.all()) == 1
def test_remove_global(self, doc):
uuid = doc.global_labels[0].uuid
assert doc.remove_label(uuid)
assert len(doc.global_labels) == 1
def test_remove_hierarchical(self, doc):
uuid = doc.hierarchical_labels[0].uuid
assert doc.remove_label(uuid)
assert len(doc.hierarchical_labels) == 0
def test_remove_missing(self, doc):
assert not doc.remove_label("nonexistent-uuid")
# ---------------------------------------------------------------------------
# No-connect and junction
# ---------------------------------------------------------------------------
class TestNoConnectJunction:
def test_add_no_connect(self, doc):
uuid = doc.add_no_connect(200.0, 300.0)
assert uuid
stats = doc.get_statistics()
assert stats["no_connects"] == 2
def test_add_junction(self, doc):
uuid = doc.add_junction(150.0, 110.0)
assert uuid
stats = doc.get_statistics()
assert stats["junctions"] == 2
def test_survive_save(self, doc, tmp_path):
doc.add_no_connect(200.0, 300.0)
doc.add_junction(150.0, 110.0)
out = str(tmp_path / "nc_junc.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
assert reloaded.get_statistics()["no_connects"] == 2
assert reloaded.get_statistics()["junctions"] == 2
# ---------------------------------------------------------------------------
# Title block
# ---------------------------------------------------------------------------
class TestTitleBlock:
def test_set_title_block(self, doc):
doc.set_title_block(title="My Project", date="2025-01-01")
tb = find_one(doc._tree, "title_block")
assert tb is not None
title_node = find_one(tb, "title")
assert title_node is not None
assert title_node.values[0] == "My Project"
def test_title_block_survives_save(self, doc, tmp_path):
doc.set_title_block(
title="Test", date="2025-06-15", rev="1.0", company="ACME",
)
out = str(tmp_path / "titled.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
tb = find_one(reloaded._tree, "title_block")
assert tb is not None
assert find_one(tb, "title").values[0] == "Test"
assert find_one(tb, "date").values[0] == "2025-06-15"
assert find_one(tb, "rev").values[0] == "1.0"
assert find_one(tb, "company").values[0] == "ACME"
def test_update_existing_title(self, doc):
doc.set_title_block(title="First")
doc.set_title_block(title="Second")
tb = find_one(doc._tree, "title_block")
assert find_one(tb, "title").values[0] == "Second"
# ---------------------------------------------------------------------------
# Paper size
# ---------------------------------------------------------------------------
class TestSetPaperSize:
def test_change_paper(self, doc):
doc.set_paper_size("A3")
paper = find_one(doc._tree, "paper")
assert paper is not None
assert paper.values[0] == "A3"
def test_paper_survives_save(self, doc, tmp_path):
doc.set_paper_size("A3")
out = str(tmp_path / "paper.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
paper = find_one(reloaded._tree, "paper")
assert paper is not None
assert paper.values[0] == "A3"
# ---------------------------------------------------------------------------
# Round-trip fidelity
# ---------------------------------------------------------------------------
class TestRoundTripFidelity:
def test_save_reload_save_identical(self, doc, tmp_path):
"""Save → reload → save produces identical file content."""
p1 = str(tmp_path / "pass1.kicad_sch")
p2 = str(tmp_path / "pass2.kicad_sch")
doc.save(p1)
doc2 = SchDocument(p1)
doc2.save(p2)
with open(p1, encoding="utf-8") as f1, open(p2, encoding="utf-8") as f2:
assert f1.read() == f2.read()
# ---------------------------------------------------------------------------
# Phase 4: Compatibility shims and new methods
# ---------------------------------------------------------------------------
class TestWireStr:
def test_wire_str_returns_uuid(self):
w = Wire(start=Point(0, 0), end=Point(10, 10), uuid="abc-123")
assert str(w) == "abc-123"
def test_wire_str_in_string_format(self):
w = Wire(start=Point(0, 0), end=Point(10, 10), uuid="wire-uuid")
assert f"Wire ID: {w}" == "Wire ID: wire-uuid"
class TestAddWireShim:
def test_add_wire_start_end_kwargs(self, doc, tmp_path):
wire = doc.add_wire(start=(100.0, 200.0), end=(110.0, 200.0))
assert wire.start.x == 100.0
assert wire.end.x == 110.0
def test_add_wire_positional(self, doc, tmp_path):
wire = doc.add_wire(100.0, 200.0, 110.0, 200.0)
assert wire.start.x == 100.0
assert wire.end.y == 200.0
def test_add_wire_no_args_raises(self, doc):
with pytest.raises(ValueError, match="add_wire requires"):
doc.add_wire()
class TestComponentAddShim:
def test_add_with_position_kwarg(self, doc, tmp_path):
comp = doc.components.add("Device:R", "R99", "10k", position=(50.0, 60.0))
assert comp.reference == "R99"
assert comp.position.x == 50.0
assert comp.position.y == 60.0
def test_add_with_positional_xy(self, doc, tmp_path):
comp = doc.components.add("Device:R", "R98", "4.7k", 70.0, 80.0)
assert comp.position.x == 70.0
def test_add_with_pin_at(self, doc, tmp_path):
comp = doc.components.add_with_pin_at(
lib_id="power:GND",
pin_number="1",
pin_position=(30.0, 40.0),
reference="#PWR01",
value="GND",
)
assert comp.position.x == 30.0
assert comp.position.y == 40.0
class TestNoConnectShim:
def test_add_no_connect_with_position(self, doc, tmp_path):
nc_uuid = doc.add_no_connect(position=(55.0, 65.0))
assert nc_uuid # non-empty UUID
def test_add_no_connect_positional(self, doc, tmp_path):
nc_uuid = doc.add_no_connect(55.0, 65.0)
assert nc_uuid
def test_add_no_connect_no_args_raises(self, doc):
with pytest.raises(ValueError, match="add_no_connect requires"):
doc.add_no_connect()
class TestComponentMutations:
def test_set_in_bom(self, doc, tmp_path):
comp = doc.components.get("R1")
assert comp is not None
comp.set_in_bom(False)
assert comp.in_bom is False
comp.set_in_bom(True)
assert comp.in_bom is True
def test_set_on_board(self, doc, tmp_path):
comp = doc.components.get("R1")
assert comp is not None
comp.set_on_board(False)
assert comp.on_board is False
def test_set_property_public(self, doc, tmp_path):
comp = doc.components.get("R1")
assert comp is not None
comp.set_property("MPN", "RC0603FR-0710KL")
assert comp.properties["MPN"] == "RC0603FR-0710KL"
class TestAddText:
def test_add_text(self, doc, tmp_path):
text_uuid = doc.add_text("Design note", 100.0, 200.0)
assert text_uuid
out = str(tmp_path / "text.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
# Verify text node exists in the tree
from mckicad.utils.sexp_tree import find
texts = find(reloaded._tree, "text")
found = any(t.values and t.values[0] == "Design note" for t in texts)
assert found
class TestAddSheet:
def test_add_sheet(self, doc, tmp_path):
result = doc.add_sheet("PowerSupply", "power.kicad_sch", 50.0, 100.0)
assert "uuid" in result
assert result["name"] == "PowerSupply"
assert len(doc.sheets) >= 2 # original fixture has 1 sheet
def test_add_sheet_roundtrip(self, doc, tmp_path):
doc.add_sheet("IO", "io.kicad_sch", 80.0, 120.0, width=30.0, height=20.0)
out = str(tmp_path / "sheet.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
sheet_names = [s.name for s in reloaded.sheets]
assert "IO" in sheet_names
class TestBackup:
def test_backup_creates_file(self, doc, tmp_path):
out = str(tmp_path / "orig.kicad_sch")
doc.save(out)
doc2 = SchDocument(out)
backup_path = doc2.backup()
assert os.path.isfile(backup_path)
assert "_backup_" in backup_path
assert backup_path.endswith(".kicad_sch")
class TestAddWireBetweenPins:
def test_wire_between_pins(self, doc, tmp_path):
# R1 pin 1 and R2 pin 2 exist in the fixture
wire = doc.add_wire_between_pins("R1", "1", "R2", "2")
assert wire.uuid
assert wire.start.x != 0.0 or wire.start.y != 0.0 # resolved to real positions
def test_wire_between_invalid_ref_raises(self, doc):
with pytest.raises(ValueError, match="not found"):
doc.add_wire_between_pins("RNOEXIST", "1", "R1", "1")
class TestSetTitleBlockComments:
def test_set_comments(self, doc, tmp_path):
doc.set_title_block(comments={1: "Author Name", 2: "Design notes"})
out = str(tmp_path / "tb.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
from mckicad.utils.sexp_tree import find, find_one
tb = find_one(reloaded._tree, "title_block")
assert tb is not None
comments = find(tb, "comment")
texts = {}
for c in comments:
if c.values and len(c.values) >= 2:
texts[c.values[0]] = c.values[1]
assert texts.get("1") == "Author Name"
assert texts.get("2") == "Design notes"
def test_set_title_and_comments(self, doc, tmp_path):
doc.set_title_block(title="My Design", comments={1: "Rev A"})
out = str(tmp_path / "tb2.kicad_sch")
doc.save(out)
reloaded = SchDocument(out)
from mckicad.utils.sexp_tree import find_one
tb = find_one(reloaded._tree, "title_block")
title = find_one(tb, "title")
assert title is not None
assert title.values[0] == "My Design"