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>
213 lines
7.9 KiB
Python
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
|