add_label bypasses kicad-sch-api serializer entirely — generates s-expression strings and inserts them directly into the .kicad_sch file via atomic write. Fixes two upstream bugs: global labels silently dropped on save (serializer never iterates "global_label" key), and local labels raising TypeError (parameter signature mismatch in LabelCollection.add()). add_power_symbol now falls back to sexp pin parsing when the API returns None for custom library symbols (e.g. SMF5.0CA). Extracts shared resolve_pin_position() utility used by both add_power_symbol and batch operations. Batch labels also fixed — collected as sexp strings during the batch loop and inserted after sch.save() so the serializer can't overwrite them.
217 lines
7.1 KiB
Python
217 lines
7.1 KiB
Python
"""Tests for the batch operations tool."""
|
|
|
|
import json
|
|
import os
|
|
|
|
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
|