Files
kicad-mcp-server/tests/test_schematic_field_layout.py
Ravi 7c385b993f feat(schematic): reposition and auto-place Ref/Value field labels
Adds field-placement tools:
- set_schematic_property_position / batch_set_schematic_property_positions:
  move a symbol's Reference/Value field labels
- autoplace_schematic_fields: place every symbol's fields clear of its body
  and nearby net labels (the #1 readability problem in generated schematics)
- check_schematic_layout: audit out-of-bounds / fields-in-body / duplicate
  labels (note: overlaps upstream find_overlapping_elements etc. — reuse or
  drop on request)

Generic .kicad_sch text helpers are factored into commands/schematic_text_utils.py
so the batch/hierarchy modules don't import from one another.

- python/commands/schematic_text_utils.py: shared text/S-expr helpers
- python/commands/schematic_field_layout.py: SchematicFieldLayoutCommands
- src/tools/schematic-layout.ts + registry 'schematic_layout' category
- python/kicad_interface.py: import + instantiate + dispatch routes
- tests/test_schematic_field_layout.py: 23 unit tests incl. end-to-end on real .kicad_sch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:45:17 -04:00

213 lines
7.9 KiB
Python

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