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.
1583 lines
51 KiB
Python
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"
|