kicad-mcp/tests/test_batch.py
Ryan Malloy 97ebc585f8
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
Add wire collision detection, project-local library resolution, and root ERC support
Wire collision detection: apply_batch now tracks placed wire segments and
detects collinear stubs on the same axis with overlapping ranges belonging
to different nets. Colliding wires shift perpendicular to their axis by
1.27mm, preventing KiCad from merging wire segments into mega-nets.

Project-local library resolution: apply_batch now scans batch component
lib_ids for unknown libraries and registers them with kicad-sch-api's
SymbolLibraryCache via sym-lib-table parsing before component placement.
Unblocks projects using Samacsys and other non-standard symbol libraries.

Root ERC: run_schematic_erc accepts root=True to resolve to the project
root schematic before running kicad-cli, enabling hierarchy-aware ERC
that eliminates ~180 false-positive global_label_dangling warnings from
sub-sheet isolation.

270/270 tests pass, ruff + mypy clean.
2026-03-08 03:13:45 -06:00

656 lines
21 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 == []