kicad-mcp/tests/test_batch.py
Ryan Malloy 1fd3886077
Some checks are pending
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions
Support multi-unit component placement in apply_batch
Pass unit field through to kicad-sch-api's native multi-unit validation
instead of custom bypass. Removes _add_multi_unit() that used incompatible
internal API (_add_item vs _add_item_to_collection across API versions).
2026-03-08 03:40:28 -06:00

763 lines
25 KiB
Python

"""Tests for the batch operations tool."""
import json
import os
import pytest
from tests.conftest import requires_sch_api
class TestBatchValidation:
"""Tests for batch JSON validation (no kicad-sch-api needed for some)."""
def test_bad_json_file(self, tmp_output_dir):
from mckicad.tools.batch import apply_batch
# Create a schematic file (minimal)
sch_path = os.path.join(tmp_output_dir, "test.kicad_sch")
with open(sch_path, "w") as f:
f.write("(kicad_sch (version 20230121))")
# Create a bad JSON file
bad_json = os.path.join(tmp_output_dir, "bad.json")
with open(bad_json, "w") as f:
f.write("{invalid json}")
result = apply_batch(
schematic_path=sch_path,
batch_file=bad_json,
)
assert result["success"] is False
assert "json" in result["error"].lower() or "JSON" in result["error"]
def test_nonexistent_batch_file(self, tmp_output_dir):
from mckicad.tools.batch import apply_batch
sch_path = os.path.join(tmp_output_dir, "test.kicad_sch")
with open(sch_path, "w") as f:
f.write("(kicad_sch (version 20230121))")
result = apply_batch(
schematic_path=sch_path,
batch_file="/nonexistent/batch.json",
)
assert result["success"] is False
assert "not found" in result["error"].lower()
def test_bad_schematic_path(self, batch_json_file):
from mckicad.tools.batch import apply_batch
result = apply_batch(
schematic_path="/nonexistent/path.kicad_sch",
batch_file=batch_json_file,
)
assert result["success"] is False
@requires_sch_api
class TestBatchDryRun:
"""Tests for batch dry_run mode."""
def test_dry_run_returns_preview(self, populated_schematic_with_ic, batch_json_file):
from mckicad.tools.batch import apply_batch
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_json_file,
dry_run=True,
)
assert result["success"] is True
assert result["dry_run"] is True
assert "preview" in result
assert result["preview"]["components"] == 2
assert result["preview"]["wires"] == 1
assert result["preview"]["labels"] == 1
assert result["preview"]["no_connects"] == 1
assert result["validation"] == "passed"
def test_dry_run_catches_missing_fields(self, populated_schematic_with_ic, tmp_output_dir):
from mckicad.tools.batch import apply_batch
bad_data = {
"components": [{"lib_id": "Device:R"}], # missing x, y
}
batch_path = os.path.join(tmp_output_dir, "bad_batch.json")
with open(batch_path, "w") as f:
json.dump(bad_data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is False
assert "validation_errors" in result
def test_empty_batch_rejected(self, populated_schematic_with_ic, tmp_output_dir):
from mckicad.tools.batch import apply_batch
batch_path = os.path.join(tmp_output_dir, "empty_batch.json")
with open(batch_path, "w") as f:
json.dump({}, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is False
@requires_sch_api
class TestBatchApply:
"""Integration tests for applying batch operations."""
def test_apply_components_and_wires(self, populated_schematic_with_ic, batch_json_file):
from kicad_sch_api import load_schematic
from mckicad.tools.batch import apply_batch
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_json_file,
)
assert result["success"] is True
assert result["components_placed"] == 2
assert result["wires_placed"] == 1
assert result["labels_placed"] == 1
assert result["no_connects_placed"] == 1
# Verify components exist in saved schematic
sch = load_schematic(populated_schematic_with_ic)
r10 = sch.components.get("R10")
assert r10 is not None
assert r10.value == "1k"
def test_apply_with_power_symbols(self, populated_schematic_with_ic, tmp_output_dir):
from mckicad.tools.batch import apply_batch
data = {
"components": [
{"lib_id": "Device:C", "reference": "C20", "value": "100nF", "x": 300, "y": 100},
],
"power_symbols": [
{"net": "GND", "pin_ref": "C20", "pin_number": "2"},
],
}
batch_path = os.path.join(tmp_output_dir, "pwr_batch.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
assert result["components_placed"] == 1
assert result["power_symbols_placed"] == 1
def test_mckicad_sidecar_lookup(self, populated_schematic_with_ic, tmp_output_dir):
"""Test that relative paths resolve to .mckicad/ directory."""
from mckicad.tools.batch import apply_batch
# Create .mckicad/ sidecar next to schematic
sch_dir = os.path.dirname(populated_schematic_with_ic)
mckicad_dir = os.path.join(sch_dir, ".mckicad")
os.makedirs(mckicad_dir, exist_ok=True)
data = {
"labels": [{"text": "SIDECAR_TEST", "x": 100, "y": 100}],
}
sidecar_path = os.path.join(mckicad_dir, "sidecar_batch.json")
with open(sidecar_path, "w") as f:
json.dump(data, f)
# Use relative path — should find it in .mckicad/
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file="sidecar_batch.json",
)
assert result["success"] is True
assert result["labels_placed"] == 1
def test_batch_labels_persist_in_file(self, populated_schematic_with_ic, tmp_output_dir):
"""Batch labels (local and global) must appear in the saved file."""
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "BATCH_LOCAL", "x": 150, "y": 100},
{"text": "BATCH_GLOBAL", "x": 160, "y": 110, "global": True},
],
}
batch_path = os.path.join(tmp_output_dir, "label_persist_batch.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
assert result["labels_placed"] == 2
with open(populated_schematic_with_ic) as f:
content = f.read()
assert '(label "BATCH_LOCAL"' in content
assert '(global_label "BATCH_GLOBAL"' in content
@requires_sch_api
class TestBatchPinRefLabels:
"""Tests for pin-referenced label placement in batch operations."""
def test_pin_ref_label_validation_accepts_valid(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Pin-ref labels with valid references pass validation."""
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "PIN_REF_NET", "pin_ref": "R1", "pin_number": "1", "global": True},
],
}
batch_path = os.path.join(tmp_output_dir, "pinref_valid.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is True
def test_pin_ref_label_validation_rejects_missing_ref(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Pin-ref labels with unknown references fail validation."""
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "BAD", "pin_ref": "U99", "pin_number": "1"},
],
}
batch_path = os.path.join(tmp_output_dir, "pinref_bad.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is False
assert any("U99" in e for e in result["validation_errors"])
def test_label_requires_coords_or_pin_ref(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Labels without coords or pin_ref fail validation."""
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "ORPHAN"},
],
}
batch_path = os.path.join(tmp_output_dir, "pinref_orphan.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is False
assert any("pin-reference" in e or "coordinate" in e for e in result["validation_errors"])
def test_pin_ref_label_creates_label_and_wire(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Pin-referenced label creates both a label and a wire stub in the file."""
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "GPIO_TEST", "pin_ref": "R1", "pin_number": "1", "global": True},
],
}
batch_path = os.path.join(tmp_output_dir, "pinref_apply.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
assert result["labels_placed"] == 1
with open(populated_schematic_with_ic) as f:
content = f.read()
assert '(global_label "GPIO_TEST"' in content
assert "(wire\n" in content
@requires_sch_api
class TestBatchLabelConnections:
"""Tests for label_connections batch operations."""
def test_label_connections_validation_accepts_valid(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""label_connections with valid refs pass validation."""
from mckicad.tools.batch import apply_batch
data = {
"label_connections": [
{
"net": "SHARED_NET",
"global": True,
"connections": [
{"ref": "R1", "pin": "1"},
{"ref": "C1", "pin": "1"},
],
},
],
}
batch_path = os.path.join(tmp_output_dir, "lc_valid.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is True
assert result["preview"]["label_connections"] == 2
def test_label_connections_validation_rejects_bad_ref(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""label_connections with unknown refs fail validation."""
from mckicad.tools.batch import apply_batch
data = {
"label_connections": [
{
"net": "BAD_NET",
"connections": [
{"ref": "MISSING_REF", "pin": "1"},
],
},
],
}
batch_path = os.path.join(tmp_output_dir, "lc_bad.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is False
assert any("MISSING_REF" in e for e in result["validation_errors"])
def test_label_connections_creates_labels_at_different_positions(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""label_connections places same-named labels at unique pin positions."""
from mckicad.tools.batch import apply_batch
data = {
"label_connections": [
{
"net": "MULTI_PIN_NET",
"global": True,
"connections": [
{"ref": "R1", "pin": "1"},
{"ref": "C1", "pin": "1"},
],
},
],
}
batch_path = os.path.join(tmp_output_dir, "lc_multi.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
assert result["labels_placed"] >= 2
with open(populated_schematic_with_ic) as f:
content = f.read()
# Two labels with same text
import re
matches = re.findall(r'\(global_label "MULTI_PIN_NET"', content)
assert len(matches) == 2
# Wire stubs present
wire_matches = re.findall(r"\(wire\n", content)
assert len(wire_matches) >= 2
@requires_sch_api
class TestBatchPinRefNoConnects:
"""Tests for pin-referenced no_connect placement in batch operations."""
def test_pin_ref_no_connect_placed(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Pin-referenced no_connect resolves pin position and places marker."""
from mckicad.tools.batch import apply_batch
data = {
"no_connects": [
{"pin_ref": "R1", "pin_number": "1"},
],
}
batch_path = os.path.join(tmp_output_dir, "nc_pinref.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
assert result["no_connects_placed"] == 1
def test_pin_ref_no_connect_validation_rejects_bad_ref(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Pin-referenced no_connect with unknown ref fails validation."""
from mckicad.tools.batch import apply_batch
data = {
"no_connects": [
{"pin_ref": "MISSING99", "pin_number": "1"},
],
}
batch_path = os.path.join(tmp_output_dir, "nc_bad_ref.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is False
assert any("MISSING99" in e for e in result["validation_errors"])
def test_no_connect_requires_coords_or_pin_ref(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""No-connect without coords or pin_ref fails validation."""
from mckicad.tools.batch import apply_batch
data = {
"no_connects": [
{},
],
}
batch_path = os.path.join(tmp_output_dir, "nc_orphan.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
dry_run=True,
)
assert result["success"] is False
assert any("pin-reference" in e or "coordinate" in e for e in result["validation_errors"])
@requires_sch_api
class TestBatchHierarchyContext:
"""Tests for hierarchy context in batch operations."""
def test_hierarchy_context_sets_instance_path(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Passing parent_uuid and sheet_uuid sets hierarchy context on the schematic."""
from unittest.mock import patch
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "HIERARCHY_TEST", "x": 100, "y": 100},
],
}
batch_path = os.path.join(tmp_output_dir, "hier_batch.json")
with open(batch_path, "w") as f:
json.dump(data, f)
with patch("mckicad.tools.batch._ksa_load") as mock_load:
# Let the real load happen but spy on set_hierarchy_context
from kicad_sch_api import load_schematic
real_sch = load_schematic(populated_schematic_with_ic)
mock_load.return_value = real_sch
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
parent_uuid="aaaa-bbbb-cccc",
sheet_uuid="dddd-eeee-ffff",
)
assert result["success"] is True
# Verify the hierarchy path was set
assert real_sch._hierarchy_path == "/aaaa-bbbb-cccc/dddd-eeee-ffff"
def test_no_hierarchy_context_without_params(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Without parent_uuid/sheet_uuid, no hierarchy context is set."""
from mckicad.tools.batch import apply_batch
data = {
"labels": [
{"text": "NO_HIER_TEST", "x": 100, "y": 100},
],
}
batch_path = os.path.join(tmp_output_dir, "no_hier_batch.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
@pytest.mark.unit
class TestRegisterProjectLibraries:
"""Tests for project-local library registration in apply_batch."""
def test_registers_unknown_library_from_sym_lib_table(self, tmp_path):
"""Libraries listed in sym-lib-table are registered with the cache."""
from mckicad.tools.batch import _register_project_libraries
# Create a minimal .kicad_sym file
lib_dir = tmp_path / "libs"
lib_dir.mkdir()
kicad_sym = lib_dir / "CustomLib.kicad_sym"
kicad_sym.write_text(
'(kicad_symbol_lib (version 20211014) (generator test)\n'
' (symbol "MyPart" (in_bom yes) (on_board yes)\n'
' (property "Reference" "U" (at 0 0 0) (effects (font (size 1.27 1.27))))\n'
' (property "Value" "MyPart" (at 0 0 0) (effects (font (size 1.27 1.27))))\n'
' )\n'
')\n'
)
# Create a sym-lib-table that references it
sym_lib_table = tmp_path / "sym-lib-table"
sym_lib_table.write_text(
'(sym_lib_table\n'
' (version 7)\n'
' (lib (name "CustomLib")(type "KiCad")(uri "${KIPRJMOD}/libs/CustomLib.kicad_sym")(options "")(descr "Test lib"))\n'
')\n'
)
# Create a minimal .kicad_pro so _find_project_root works
kicad_pro = tmp_path / "test.kicad_pro"
kicad_pro.write_text("{}")
sch_path = str(tmp_path / "test.kicad_sch")
data = {
"components": [
{"lib_id": "CustomLib:MyPart", "reference": "U1", "value": "MyPart", "x": 100, "y": 100},
],
}
registered = _register_project_libraries(data, sch_path)
assert "CustomLib" in registered
def test_skips_already_known_library(self, tmp_path):
"""Standard libraries (Device, etc.) are not re-registered."""
from mckicad.tools.batch import _register_project_libraries
sch_path = str(tmp_path / "test.kicad_sch")
data = {
"components": [
{"lib_id": "Device:R", "reference": "R1", "value": "10k", "x": 100, "y": 100},
],
}
# Device is already in the cache from /usr/share/kicad/symbols/
registered = _register_project_libraries(data, sch_path)
assert "Device" not in registered
def test_no_components_returns_empty(self, tmp_path):
"""Batch with no components produces no registrations."""
from mckicad.tools.batch import _register_project_libraries
sch_path = str(tmp_path / "test.kicad_sch")
data = {"labels": [{"text": "NET1", "x": 0, "y": 0}]}
registered = _register_project_libraries(data, sch_path)
assert registered == []
def test_missing_library_file_not_registered(self, tmp_path):
"""Non-existent library file returns empty (no crash)."""
from mckicad.tools.batch import _register_project_libraries
sch_path = str(tmp_path / "test.kicad_sch")
data = {
"components": [
{"lib_id": "NonExistentLib:FakePart", "reference": "U1", "value": "X", "x": 0, "y": 0},
],
}
registered = _register_project_libraries(data, sch_path)
assert registered == []
@requires_sch_api
@pytest.mark.unit
class TestMultiUnitComponents:
"""Tests for multi-unit component placement in apply_batch."""
def test_multi_unit_places_both_units(self, tmp_output_dir):
"""Two entries with same reference but different units are both placed."""
from mckicad.tools.batch import apply_batch
sch_path = os.path.join(tmp_output_dir, "multi_unit.kicad_sch")
with open(sch_path, "w") as f:
f.write("(kicad_sch (version 20230121) (generator test)\n")
f.write(" (lib_symbols)\n")
f.write(")\n")
data = {
"components": [
{
"lib_id": "Amplifier_Operational:TL072",
"reference": "U1",
"value": "TL072",
"x": 100,
"y": 100,
"unit": 1,
},
{
"lib_id": "Amplifier_Operational:TL072",
"reference": "U1",
"value": "TL072",
"x": 100,
"y": 150,
"unit": 2,
},
],
}
batch_path = os.path.join(tmp_output_dir, "multi_unit.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(schematic_path=sch_path, batch_file=batch_path)
assert result["success"] is True
assert result["components_placed"] == 2
# Verify the file contains two symbol blocks with the same reference
with open(sch_path) as f:
content = f.read()
assert content.count('"U1"') >= 2
def test_single_unit_with_explicit_unit(self, tmp_output_dir):
"""A single unit=1 entry works the same as before."""
from mckicad.tools.batch import apply_batch
sch_path = os.path.join(tmp_output_dir, "single_unit.kicad_sch")
with open(sch_path, "w") as f:
f.write("(kicad_sch (version 20230121) (generator test)\n")
f.write(" (lib_symbols)\n")
f.write(")\n")
data = {
"components": [
{
"lib_id": "Device:R",
"reference": "R1",
"value": "10k",
"x": 100,
"y": 100,
"unit": 1,
},
],
}
batch_path = os.path.join(tmp_output_dir, "single_unit.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(schematic_path=sch_path, batch_file=batch_path)
assert result["success"] is True
assert result["components_placed"] == 1
def test_unit_default_is_one(self, tmp_output_dir):
"""Omitting unit field defaults to unit 1 (backwards compatible)."""
from mckicad.tools.batch import apply_batch
sch_path = os.path.join(tmp_output_dir, "default_unit.kicad_sch")
with open(sch_path, "w") as f:
f.write("(kicad_sch (version 20230121) (generator test)\n")
f.write(" (lib_symbols)\n")
f.write(")\n")
data = {
"components": [
{
"lib_id": "Device:R",
"reference": "R1",
"value": "10k",
"x": 100,
"y": 100,
},
],
}
batch_path = os.path.join(tmp_output_dir, "default_unit.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(schematic_path=sch_path, batch_file=batch_path)
assert result["success"] is True