"""Tests for the batch operations tool.""" import json import os import re 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 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 # Check that xy coordinates in the wire span ~7.62mm wire_matches = re.findall( r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content, ) assert len(wire_matches) >= 1 for x1, y1, x2, y2 in wire_matches: dx = abs(float(x2) - float(x1)) dy = abs(float(y2) - float(y1)) length = (dx**2 + dy**2) ** 0.5 # At least one stub should be ~7.62mm if abs(length - 7.62) < 0.5: break else: pytest.fail("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() wire_matches = re.findall( r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content, ) assert len(wire_matches) >= 1 for x1, y1, x2, y2 in wire_matches: dx = abs(float(x2) - float(x1)) dy = abs(float(y2) - float(y1)) length = (dx**2 + dy**2) ** 0.5 if abs(length - 12.7) < 0.5: break else: pytest.fail("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 @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