Eliminate the external kicad-sch-api dependency by migrating all MCP tools to the internal SchDocument class built on sexp_tree.py. This fixes three serialization bugs (dropped global labels, TypeError on local labels, mis-quoted property private keywords) and removes ~1,900 lines of workaround code. New modules: - sexp_tree.py: S-expression parser and round-trip serializer - sch_document.py: schematic read/write/mutate API - lib_resolver.py: symbol library search and resolution - sch_helpers.py: shared load/validate/expand helpers Migrated all 9 tool files, resources, and tests. Removed 5 workaround functions from sexp_parser.py. 531 tests pass, ruff + mypy clean.
784 lines
26 KiB
Python
784 lines
26 KiB
Python
"""Tests for the batch operations tool."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
|
|
|
|
class TestBatchValidation:
|
|
"""Tests for batch JSON validation."""
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
class TestBatchApply:
|
|
"""Integration tests for applying batch operations."""
|
|
|
|
def test_apply_components_and_wires(self, populated_schematic_with_ic, batch_json_file):
|
|
from mckicad.tools.batch import apply_batch
|
|
from mckicad.utils.sch_helpers import load_schematic
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
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
|
|
|
|
|
|
class TestBatchLabelOffset:
|
|
"""Tests for label auto-offset and direction override."""
|
|
|
|
def test_default_stub_length_is_7_62(
|
|
self, populated_schematic_with_ic, tmp_output_dir,
|
|
):
|
|
"""Default stub_length should be 7.62mm (3 grid units) for body clearance."""
|
|
from mckicad.tools.batch import apply_batch
|
|
|
|
data = {
|
|
"label_connections": [
|
|
{
|
|
"net": "OFFSET_TEST",
|
|
"connections": [{"ref": "R1", "pin": "1"}],
|
|
},
|
|
],
|
|
}
|
|
batch_path = os.path.join(tmp_output_dir, "offset_default.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
|
|
|
|
with open(populated_schematic_with_ic) as f:
|
|
content = f.read()
|
|
|
|
# Wire stub should be 7.62mm long (3 grid units), not 2.54mm
|
|
# sexp_tree serializer puts each (xy ...) on its own line,
|
|
# so we extract pairs of consecutive xy nodes within (pts ...)
|
|
xy_matches = re.findall(
|
|
r'\(xy ([\d.-]+) ([\d.-]+)\)', content,
|
|
)
|
|
# Process consecutive xy pairs (each wire has 2 xy nodes)
|
|
found_stub = False
|
|
for i in range(0, len(xy_matches) - 1, 2):
|
|
x1, y1 = float(xy_matches[i][0]), float(xy_matches[i][1])
|
|
x2, y2 = float(xy_matches[i + 1][0]), float(xy_matches[i + 1][1])
|
|
dx = abs(x2 - x1)
|
|
dy = abs(y2 - y1)
|
|
length = (dx**2 + dy**2) ** 0.5
|
|
if abs(length - 7.62) < 0.5:
|
|
found_stub = True
|
|
break
|
|
assert found_stub, "No wire stub found with ~7.62mm length"
|
|
|
|
def test_custom_stub_length_per_connection(
|
|
self, populated_schematic_with_ic, tmp_output_dir,
|
|
):
|
|
"""Per-connection stub_length overrides the group default."""
|
|
from mckicad.tools.batch import apply_batch
|
|
|
|
data = {
|
|
"label_connections": [
|
|
{
|
|
"net": "CUSTOM_STUB",
|
|
"connections": [
|
|
{"ref": "R1", "pin": "1", "stub_length": 12.7},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
batch_path = os.path.join(tmp_output_dir, "custom_stub.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
|
|
|
|
with open(populated_schematic_with_ic) as f:
|
|
content = f.read()
|
|
|
|
xy_matches = re.findall(
|
|
r'\(xy ([\d.-]+) ([\d.-]+)\)', content,
|
|
)
|
|
found_stub = False
|
|
for i in range(0, len(xy_matches) - 1, 2):
|
|
x1, y1 = float(xy_matches[i][0]), float(xy_matches[i][1])
|
|
x2, y2 = float(xy_matches[i + 1][0]), float(xy_matches[i + 1][1])
|
|
dx = abs(x2 - x1)
|
|
dy = abs(y2 - y1)
|
|
length = (dx**2 + dy**2) ** 0.5
|
|
if abs(length - 12.7) < 0.5:
|
|
found_stub = True
|
|
break
|
|
assert found_stub, "No wire stub found with ~12.7mm length"
|
|
|
|
def test_direction_override(
|
|
self, populated_schematic_with_ic, tmp_output_dir,
|
|
):
|
|
"""Direction override changes label placement side."""
|
|
from mckicad.tools.batch import apply_batch
|
|
|
|
# Place two labels on same pin with different directions
|
|
data = {
|
|
"label_connections": [
|
|
{
|
|
"net": "DIR_RIGHT",
|
|
"connections": [
|
|
{"ref": "R1", "pin": "1", "direction": "right"},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
batch_path = os.path.join(tmp_output_dir, "dir_test.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
|
|
|
|
|
|
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"])
|
|
|
|
|
|
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 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)
|
|
|
|
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 written to the file
|
|
with open(populated_schematic_with_ic) as f:
|
|
content = f.read()
|
|
|
|
assert "sheet_instances" in content
|
|
assert "/aaaa-bbbb-cccc/dddd-eeee-ffff" in content
|
|
|
|
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 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
|