Adds batch authoring tools that collapse the dozens of round-trips needed to stand up a schematic into a handful: batch_add_components, batch_edit_schematic_components, replace_schematic_component, batch_add_no_connects, batch_connect, and batch_add_and_connect (place a set of parts and wire them in one call). These reuse the existing single-item handlers internally, so behavior matches exactly. - python/commands/schematic_batch.py: SchematicBatchCommands(iface) - python/commands/schematic_text_utils.py: shared .kicad_sch text helpers - src/tools/schematic-batch.ts + registry 'schematic_batch' category - python/kicad_interface.py: import + instantiate + dispatch routes - tests/test_schematic_batch.py: 13 unit tests Note: schematic_text_utils.py is also shipped by the field-placement and hierarchy PRs; if those merge first, drop the duplicate copy on rebase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
8.1 KiB
Python
216 lines
8.1 KiB
Python
"""
|
|
Unit tests for batch schematic authoring commands (commands/schematic_batch.py).
|
|
|
|
Pure helpers are tested directly; handlers are tested for parameter validation and
|
|
orchestration with the heavy dependencies (DynamicSymbolLoader / PinLocator /
|
|
WireManager / interface handlers) stubbed, so no real KiCad install is needed.
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
|
|
|
from commands import schematic_batch as sb # noqa: E402
|
|
from commands.schematic_batch import ( # noqa: E402
|
|
SchematicBatchCommands,
|
|
_bbox_from_pins,
|
|
_field_positions_for_pins,
|
|
_find_facing_label,
|
|
_find_project_root,
|
|
_snap,
|
|
)
|
|
|
|
|
|
class TestHelpers:
|
|
def test_snap(self):
|
|
# 1.27mm (50-mil) grid
|
|
assert _snap(1.27) == 1.27
|
|
assert _snap(2.54) == 2.54
|
|
assert _snap(0.6) == 0.0 # rounds down to 0
|
|
assert _snap(2.0) == 2.54 # nearest grid point
|
|
|
|
def test_find_project_root(self, tmp_path):
|
|
(tmp_path / "proj.kicad_pro").write_text("{}")
|
|
sub = tmp_path / "a" / "b"
|
|
sub.mkdir(parents=True)
|
|
assert _find_project_root(sub) == tmp_path.resolve()
|
|
|
|
def test_find_project_root_fallback(self, tmp_path):
|
|
sub = tmp_path / "x"
|
|
sub.mkdir()
|
|
assert _find_project_root(sub) == sub # no .kicad_pro anywhere -> start dir
|
|
|
|
def test_find_facing_label(self, tmp_path):
|
|
f = tmp_path / "s.kicad_sch"
|
|
# A label "VBUS" at (10,20) angle 0; a pin at (24,20) facing it needs orientation 180.
|
|
f.write_text('(kicad_sch (label "VBUS" (at 10 20 0)))')
|
|
assert _find_facing_label(f, "VBUS", [12, 20], orientation=180) == [10.0, 20.0]
|
|
# Wrong net -> None
|
|
assert _find_facing_label(f, "GND", [12, 20], orientation=180) is None
|
|
# Too far -> None
|
|
assert _find_facing_label(f, "VBUS", [100, 200], orientation=180) is None
|
|
|
|
def test_field_positions_two_pin_vertical(self):
|
|
# pins stacked vertically -> labels go left/right
|
|
pins = {"1": [0, 5], "2": [0, -5]}
|
|
fp = _field_positions_for_pins(0, 0, pins)
|
|
names = {n for n, *_ in fp}
|
|
assert names == {"Reference", "Value"}
|
|
ref = next(p for p in fp if p[0] == "Reference")
|
|
assert ref[1] != 0 # x offset (left/right), y unchanged
|
|
|
|
def test_field_positions_two_pin_horizontal(self):
|
|
pins = {"1": [-5, 0], "2": [5, 0]}
|
|
fp = _field_positions_for_pins(0, 0, pins)
|
|
ref = next(p for p in fp if p[0] == "Reference")
|
|
assert ref[1] == 0 and ref[2] != 0 # above/below
|
|
|
|
def test_bbox_from_pins(self):
|
|
bb = _bbox_from_pins({"1": [0, 0], "2": [10, 4]}, 5, 2)
|
|
assert bb["x_min"] == -1.27 and bb["x_max"] == 11.27
|
|
|
|
def test_bbox_from_pins_fallback(self):
|
|
bb = _bbox_from_pins({}, 5, 5)
|
|
assert bb == {"x_min": 2.46, "y_min": 2.46, "x_max": 7.54, "y_max": 7.54}
|
|
|
|
|
|
class TestParamValidation:
|
|
def setup_method(self):
|
|
self.c = SchematicBatchCommands(types.SimpleNamespace())
|
|
|
|
def test_validation(self):
|
|
assert self.c.batch_add_components({})["success"] is False
|
|
assert (
|
|
self.c.batch_add_components({"schematicPath": "/x"})["success"] is False
|
|
) # no components
|
|
assert self.c.batch_edit_schematic_components({"schematicPath": "/x"})["success"] is False
|
|
assert (
|
|
self.c.replace_schematic_component({"schematicPath": "/x", "reference": "R1"})[
|
|
"success"
|
|
]
|
|
is False
|
|
)
|
|
assert self.c.batch_add_no_connects({"schematicPath": "/x"})["success"] is False
|
|
assert self.c.batch_connect({"schematicPath": "/x"})["success"] is False
|
|
assert self.c.batch_add_and_connect({"schematicPath": "/x"})["success"] is False
|
|
|
|
|
|
class TestBatchEdit:
|
|
def test_aggregates_results(self):
|
|
calls = []
|
|
|
|
def fake_edit(params):
|
|
calls.append(params)
|
|
ref = params["reference"]
|
|
if ref == "BAD":
|
|
return {"success": False, "message": "nope"}
|
|
return {"success": True, "updated": {"value": params.get("value")}}
|
|
|
|
iface = types.SimpleNamespace(_handle_edit_schematic_component=fake_edit)
|
|
c = SchematicBatchCommands(iface)
|
|
r = c.batch_edit_schematic_components(
|
|
{
|
|
"schematicPath": "/x.kicad_sch",
|
|
"components": {"R1": {"value": "10k"}, "BAD": {"value": "x"}},
|
|
}
|
|
)
|
|
assert r["updated_count"] == 1
|
|
assert r["error_count"] == 1
|
|
assert r["success"] is False
|
|
# sub-handler received merged params
|
|
assert any(c_["reference"] == "R1" and c_["value"] == "10k" for c_ in calls)
|
|
|
|
|
|
class TestBatchAddNoConnects:
|
|
def test_happy_and_fallback(self, monkeypatch):
|
|
fake_loc = types.SimpleNamespace(
|
|
get_pin_location=lambda p, ref, pin: [10.0, 20.0] if pin == "1" else None,
|
|
get_all_symbol_pins=lambda p, ref: {"1": [10.0, 20.0]}, # single-pin fallback
|
|
)
|
|
added = []
|
|
fake_wm = types.SimpleNamespace(add_no_connect=lambda p, loc: added.append(loc) or True)
|
|
monkeypatch.setattr(sb, "PinLocator", lambda: fake_loc)
|
|
monkeypatch.setattr(sb, "WireManager", fake_wm)
|
|
|
|
c = SchematicBatchCommands(types.SimpleNamespace())
|
|
r = c.batch_add_no_connects(
|
|
{
|
|
"schematicPath": "/x.kicad_sch",
|
|
"pins": [
|
|
{"componentRef": "U1", "pinName": "1"},
|
|
{"componentRef": "TP1", "pinName": "9"},
|
|
],
|
|
}
|
|
)
|
|
assert r["success"] is True # both succeed (2nd via single-pin fallback)
|
|
assert len(r["placed"]) == 2
|
|
assert len(added) == 2
|
|
|
|
|
|
class TestBatchConnect:
|
|
def test_places_label(self, monkeypatch, tmp_path):
|
|
f = tmp_path / "x.kicad_sch"
|
|
f.write_text("(kicad_sch)")
|
|
fake_loc = types.SimpleNamespace(
|
|
get_pin_location=lambda p, ref, pin: [10.0, 20.0],
|
|
get_pin_angle=lambda p, ref, pin: 0,
|
|
get_all_symbol_pins=lambda p, ref: {"1": [10.0, 20.0]},
|
|
)
|
|
labels = []
|
|
fake_wm = types.SimpleNamespace(
|
|
add_label=lambda p, net, pos, label_type="label", orientation=0: labels.append(
|
|
(net, orientation)
|
|
)
|
|
or True,
|
|
add_wire=lambda *a: True,
|
|
delete_label=lambda *a, **k: True,
|
|
)
|
|
monkeypatch.setattr(sb, "PinLocator", lambda: fake_loc)
|
|
monkeypatch.setattr(sb, "WireManager", fake_wm)
|
|
monkeypatch.setattr(sb, "_find_facing_label", lambda *a, **k: None)
|
|
|
|
c = SchematicBatchCommands(types.SimpleNamespace())
|
|
r = c.batch_connect({"schematicPath": str(f), "connections": {"R1": {"1": "SDA"}}})
|
|
assert r["success"] is True
|
|
assert len(r["placed"]) == 1
|
|
assert labels == [("SDA", 180)] # pin angle 0 -> label orientation 180
|
|
|
|
|
|
class TestBatchAddAndConnect:
|
|
def test_splits_nets_and_orchestrates(self):
|
|
c = SchematicBatchCommands(types.SimpleNamespace())
|
|
add_args = {}
|
|
conn_args = {}
|
|
|
|
def fake_add(params):
|
|
add_args.update(params)
|
|
return {
|
|
"added_count": 1,
|
|
"added": [{"reference": "R1"}],
|
|
"errors": [],
|
|
"placement_bbox": None,
|
|
}
|
|
|
|
def fake_connect(params):
|
|
conn_args.update(params)
|
|
return {"placed": [{"ref": "R1", "pin": "1", "net": "VCC"}], "failed": []}
|
|
|
|
c.batch_add_components = fake_add
|
|
c.batch_connect = fake_connect
|
|
|
|
r = c.batch_add_and_connect(
|
|
{
|
|
"schematicPath": "/x.kicad_sch",
|
|
"components": [{"symbol": "Device:R", "reference": "R1", "nets": {"1": "VCC"}}],
|
|
}
|
|
)
|
|
assert r["success"] is True
|
|
assert r["added_count"] == 1
|
|
assert r["connected_count"] == 1
|
|
# 'nets' stripped from the components passed to batch_add_components
|
|
assert "nets" not in add_args["components"][0]
|
|
# nets routed to batch_connect keyed by reference
|
|
assert conn_args["connections"] == {"R1": {"1": "VCC"}}
|