""" Unit + integration tests for schematic field-placement/layout commands. The text S-expression helpers and the set/batch property writers are exercised against a real minimal .kicad_sch written to a temp file (pure text manipulation — no KiCad needed). autoplace_schematic_fields is tested for parameter validation; the geometry helpers are unit-tested directly. """ import sys import types from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "python")) from commands.schematic_field_layout import ( # noqa: E402 SchematicFieldLayoutCommands, _bbox_overlaps, _gather_labels, ) from commands.schematic_text_utils import ( # noqa: E402 _extract_component_properties, _extract_property_position, _extract_property_visible, _find_matching_paren, _find_placed_symbol_block, _get_sheet_usable_area, _move_property_in_block, ) MINIMAL_SCH = """(kicad_sch (version 20230121) (paper "A4") (lib_symbols (symbol "Device:R" (property "Reference" "R" (at 0 0 0))) ) (symbol (lib_id "Device:R") (at 100 100 0) (uuid "11111111") (property "Reference" "R1" (at 102.0 98.0 0) (effects (font (size 1.27 1.27)))) (property "Value" "10k" (at 102.0 102.0 0) (effects (font (size 1.27 1.27)))) ) (symbol (lib_id "Device:C") (at 150 100 0) (uuid "22222222") (property "Reference" "C1" (at 152.0 98.0 0) (effects (font (size 1.27 1.27)))) (property "Value" "100nF" (at 152.0 102.0 0) (effects (font (size 1.27 1.27)))) ) ) """ class TestTextHelpers: def test_find_matching_paren(self): s = "(a (b) c)" assert _find_matching_paren(s, 0) == len(s) - 1 assert _find_matching_paren(s, 3) == 5 def test_find_placed_symbol_block_skips_lib_symbols(self): block, start, end = _find_placed_symbol_block(MINIMAL_SCH, "R1") assert block is not None # Must be the placed instance (has lib_id + (at 100 100 0)), not the lib_symbols def assert '(lib_id "Device:R")' in block and "(at 100 100 0)" in block def test_find_placed_symbol_block_missing(self): assert _find_placed_symbol_block(MINIMAL_SCH, "R99") == (None, -1, -1) def test_extract_property_position(self): block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1") assert _extract_property_position(block, "Reference") == { "x": 102.0, "y": 98.0, "angle": 0.0, } def test_extract_property_visible(self): block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1") assert _extract_property_visible(block, "Reference") is True def test_extract_component_properties(self): block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1") props = _extract_component_properties(block) assert props["Reference"] == "R1" and props["Value"] == "10k" def test_move_property_in_block_and_hide(self): block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1") new_block, n = _move_property_in_block(block, "Reference", 105, 95, 0, visible=False) assert n == 1 assert "(at 105 95 0)" in new_block assert "(hide yes)" in new_block # Re-show it reshown, _ = _move_property_in_block(new_block, "Reference", 105, 95, 0, visible=True) assert "(hide yes)" not in reshown def test_move_property_missing(self): block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1") _, n = _move_property_in_block(block, "Footprint", 1, 2, 0, True) assert n == 0 class TestGeometryHelpers: def test_bbox_overlaps(self): a = {"x_min": 0, "y_min": 0, "x_max": 10, "y_max": 10} b = {"x_min": 5, "y_min": 5, "x_max": 15, "y_max": 15} c = {"x_min": 20, "y_min": 20, "x_max": 30, "y_max": 30} assert _bbox_overlaps(a, b) assert not _bbox_overlaps(a, c) assert _bbox_overlaps(a, c, margin=11) # margin bridges the gap def test_get_sheet_usable_area_default_a4(self, tmp_path): f = tmp_path / "a4.kicad_sch" f.write_text('(kicad_sch (paper "A4")') left, top, right, bottom = _get_sheet_usable_area(str(f)) assert (left, top) == (12.7, 12.7) assert right == pytest.approx(297.0 - 12.7) assert bottom == pytest.approx(210.0 - 12.7) def test_get_sheet_usable_area_a3(self, tmp_path): f = tmp_path / "a3.kicad_sch" f.write_text('(kicad_sch (paper "A3")') _, _, right, _ = _get_sheet_usable_area(str(f)) assert right == pytest.approx(420.0 - 12.7) def test_gather_labels(self): def lbl(v, at): return types.SimpleNamespace(value=v, at=types.SimpleNamespace(value=at)) sch = types.SimpleNamespace( label=[lbl("SDA", [1, 2, 0])], global_label=[lbl("VBUS", [3, 4, 90])] ) labels = _gather_labels(sch) assert {x["name"] for x in labels} == {"SDA", "VBUS"} vbus = next(x for x in labels if x["name"] == "VBUS") assert vbus["type"] == "global" and vbus["angle"] == 90.0 class TestSetPropertyPosition: def _sch(self, tmp_path): f = tmp_path / "x.kicad_sch" f.write_text(MINIMAL_SCH) return f def test_missing_params(self): c = SchematicFieldLayoutCommands() assert ( c.set_schematic_property_position({"schematicPath": "/x", "reference": "R1"})["success"] is False ) def test_rejects_non_ref_value(self, tmp_path): f = self._sch(tmp_path) c = SchematicFieldLayoutCommands() r = c.set_schematic_property_position( {"schematicPath": str(f), "reference": "R1", "property": "Footprint", "x": 1, "y": 2} ) assert r["success"] is False def test_moves_field(self, tmp_path): f = self._sch(tmp_path) c = SchematicFieldLayoutCommands() r = c.set_schematic_property_position( {"schematicPath": str(f), "reference": "R1", "property": "Reference", "x": 105, "y": 95} ) assert r["success"] is True content = f.read_text() assert '(property "Reference" "R1" (at 105 95 0)' in content # C1 left untouched assert '(property "Reference" "C1" (at 152.0 98.0 0)' in content def test_component_not_found(self, tmp_path): f = self._sch(tmp_path) c = SchematicFieldLayoutCommands() r = c.set_schematic_property_position( {"schematicPath": str(f), "reference": "R99", "property": "Value", "x": 1, "y": 2} ) assert r["success"] is False class TestBatchSetPropertyPositions: def test_batch_applies_and_reports(self, tmp_path): f = tmp_path / "x.kicad_sch" f.write_text(MINIMAL_SCH) c = SchematicFieldLayoutCommands() r = c.batch_set_schematic_property_positions( { "schematicPath": str(f), "updates": [ {"reference": "R1", "property": "Reference", "x": 90, "y": 90}, {"reference": "C1", "property": "Value", "x": 160, "y": 110}, {"reference": "R99", "property": "Value", "x": 0, "y": 0}, # should fail ], } ) assert r["applied_count"] == 2 assert r["failed_count"] == 1 assert r["success"] is False # because one failed content = f.read_text() assert '(property "Reference" "R1" (at 90 90 0)' in content assert '(property "Value" "100nF" (at 160 110 0)' in content def test_requires_updates(self, tmp_path): f = tmp_path / "x.kicad_sch" f.write_text(MINIMAL_SCH) c = SchematicFieldLayoutCommands() assert ( c.batch_set_schematic_property_positions({"schematicPath": str(f)})["success"] is False ) class TestAutoplaceValidation: def test_autoplace_requires_path(self): assert SchematicFieldLayoutCommands().autoplace_schematic_fields({})["success"] is False