"""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"