"""Tests for schematic analysis tools.""" import pytest from tests.conftest import requires_sch_api @requires_sch_api @pytest.mark.unit class TestRunSchematicErc: """Tests for the run_schematic_erc tool.""" def test_erc_on_populated_schematic(self, populated_schematic): from mckicad.tools.schematic_analysis import run_schematic_erc result = run_schematic_erc(schematic_path=populated_schematic) assert result["success"] is True assert "violation_count" in result or "error" not in result def test_erc_invalid_path(self): from mckicad.tools.schematic_analysis import run_schematic_erc result = run_schematic_erc(schematic_path="/tmp/nonexistent.kicad_sch") assert result["success"] is False @pytest.mark.unit class TestErcJsonParsing: """Tests for ERC JSON report parsing (no kicad-sch-api needed).""" def test_kicad9_sheets_format(self, tmp_path): """KiCad 9 nests violations under sheets[].violations.""" import json from unittest.mock import patch from mckicad.tools.schematic_analysis import run_schematic_erc # Create a minimal schematic file sch_path = str(tmp_path / "test.kicad_sch") with open(sch_path, "w") as f: f.write("(kicad_sch (version 20231120) (uuid \"abc\"))\n") # Create KiCad 9-style ERC JSON output erc_json = { "$schema": "https://...", "coordinate_units": "mm", "kicad_version": "9.0.0", "sheets": [ { "path": "/", "uuid_path": "/abc", "violations": [ { "description": "Pin not connected", "severity": "error", "type": "pin_not_connected", "items": [{"description": "U1 Pin 1"}], }, { "description": "Endpoint off grid", "severity": "warning", "type": "endpoint_off_grid", "items": [{"description": "Wire at (10, 20)"}], }, ], }, ], "source": "test.kicad_sch", } def fake_subprocess_run(cmd, **kwargs): # Write the ERC JSON to the -o path out_idx = cmd.index("-o") out_path = cmd[out_idx + 1] with open(out_path, "w") as f: json.dump(erc_json, f) class FakeResult: returncode = 0 stdout = "" stderr = "" return FakeResult() with ( patch("mckicad.tools.schematic_analysis._HAS_SCH_API", False), patch("mckicad.tools.schematic_analysis.find_kicad_cli", return_value="/usr/bin/kicad-cli"), patch("subprocess.run", side_effect=fake_subprocess_run), ): result = run_schematic_erc(schematic_path=sch_path, severity="all") assert result["success"] is True assert result["violation_count"] == 2 assert result["by_severity"]["error"] == 1 assert result["by_severity"]["warning"] == 1 assert result["passed"] is False assert result["engine"] == "kicad-cli" def test_flat_violations_format(self, tmp_path): """Legacy format with top-level violations key still works.""" import json from unittest.mock import patch from mckicad.tools.schematic_analysis import run_schematic_erc sch_path = str(tmp_path / "test.kicad_sch") with open(sch_path, "w") as f: f.write("(kicad_sch (version 20231120) (uuid \"abc\"))\n") erc_json = { "violations": [ {"message": "Test violation", "severity": "error"}, ], } def fake_subprocess_run(cmd, **kwargs): out_idx = cmd.index("-o") out_path = cmd[out_idx + 1] with open(out_path, "w") as f: json.dump(erc_json, f) class FakeResult: returncode = 0 stdout = "" stderr = "" return FakeResult() with ( patch("mckicad.tools.schematic_analysis._HAS_SCH_API", False), patch("mckicad.tools.schematic_analysis.find_kicad_cli", return_value="/usr/bin/kicad-cli"), patch("subprocess.run", side_effect=fake_subprocess_run), ): result = run_schematic_erc(schematic_path=sch_path, severity="all") assert result["success"] is True assert result["violation_count"] == 1 assert result["passed"] is False def test_multi_sheet_violations_aggregated(self, tmp_path): """Violations across multiple sheets are aggregated.""" import json from unittest.mock import patch from mckicad.tools.schematic_analysis import run_schematic_erc sch_path = str(tmp_path / "test.kicad_sch") with open(sch_path, "w") as f: f.write("(kicad_sch (version 20231120) (uuid \"abc\"))\n") erc_json = { "sheets": [ {"path": "/", "uuid_path": "/a", "violations": [ {"description": "V1", "severity": "error", "type": "t1", "items": []}, {"description": "V2", "severity": "error", "type": "t1", "items": []}, ]}, {"path": "/sub", "uuid_path": "/a/b", "violations": [ {"description": "V3", "severity": "warning", "type": "t2", "items": []}, ]}, ], } def fake_subprocess_run(cmd, **kwargs): out_idx = cmd.index("-o") out_path = cmd[out_idx + 1] with open(out_path, "w") as f: json.dump(erc_json, f) class FakeResult: returncode = 0 stdout = "" stderr = "" return FakeResult() with ( patch("mckicad.tools.schematic_analysis._HAS_SCH_API", False), patch("mckicad.tools.schematic_analysis.find_kicad_cli", return_value="/usr/bin/kicad-cli"), patch("subprocess.run", side_effect=fake_subprocess_run), ): result = run_schematic_erc(schematic_path=sch_path, severity="all") assert result["success"] is True assert result["violation_count"] == 3 assert result["by_severity"]["error"] == 2 assert result["by_severity"]["warning"] == 1 def test_root_resolves_to_project_schematic(self, tmp_path): """root=True resolves sub-sheet path to the project root schematic.""" import json from unittest.mock import patch from mckicad.tools.schematic_analysis import run_schematic_erc # Create a project structure with root + sub-sheet pro_file = tmp_path / "myproject.kicad_pro" pro_file.write_text("{}") root_sch = tmp_path / "myproject.kicad_sch" root_sch.write_text('(kicad_sch (version 20231120) (uuid "root"))\n') sub_dir = tmp_path / "sub" sub_dir.mkdir() sub_sch = sub_dir / "power.kicad_sch" sub_sch.write_text('(kicad_sch (version 20231120) (uuid "sub"))\n') erc_json = {"sheets": [{"path": "/", "uuid_path": "/root", "violations": []}]} captured_paths = [] def fake_subprocess_run(cmd, **kwargs): # Capture the schematic path passed to kicad-cli captured_paths.append(cmd[-1]) out_idx = cmd.index("-o") out_path = cmd[out_idx + 1] with open(out_path, "w") as f: json.dump(erc_json, f) class FakeResult: returncode = 0 stdout = "" stderr = "" return FakeResult() with ( patch("mckicad.tools.schematic_analysis._HAS_SCH_API", False), patch("mckicad.tools.schematic_analysis.find_kicad_cli", return_value="/usr/bin/kicad-cli"), patch("subprocess.run", side_effect=fake_subprocess_run), ): result = run_schematic_erc( schematic_path=str(sub_sch), severity="all", root=True, ) assert result["success"] is True # The captured path should be the root schematic, not the sub-sheet assert captured_paths[0] == str(root_sch) @requires_sch_api @pytest.mark.unit class TestAnalyzeConnectivity: """Tests for the analyze_connectivity tool.""" def test_connectivity_on_populated(self, populated_schematic): from mckicad.tools.schematic_analysis import analyze_connectivity result = analyze_connectivity(schematic_path=populated_schematic) assert result["success"] is True assert "net_count" in result or "error" not in result def test_connectivity_invalid_path(self): from mckicad.tools.schematic_analysis import analyze_connectivity result = analyze_connectivity(schematic_path="/tmp/nonexistent.kicad_sch") assert result["success"] is False @requires_sch_api @pytest.mark.unit class TestCheckPinConnection: """Tests for the check_pin_connection tool.""" def test_check_existing_pin(self, populated_schematic): from mckicad.tools.schematic_analysis import check_pin_connection result = check_pin_connection( schematic_path=populated_schematic, reference="R1", pin="1", ) # May succeed or fail depending on kicad-sch-api version assert "success" in result def test_check_nonexistent_pin(self, populated_schematic): from mckicad.tools.schematic_analysis import check_pin_connection result = check_pin_connection( schematic_path=populated_schematic, reference="Z99", pin="1", ) assert "success" in result @requires_sch_api @pytest.mark.unit class TestVerifyPinsConnected: """Tests for the verify_pins_connected tool.""" def test_verify_two_pins(self, populated_schematic): from mckicad.tools.schematic_analysis import verify_pins_connected result = verify_pins_connected( schematic_path=populated_schematic, ref1="R1", pin1="1", ref2="R2", pin2="1", ) # May succeed or fail depending on kicad-sch-api version assert "success" in result @requires_sch_api @pytest.mark.unit class TestGetComponentPins: """Tests for the get_component_pins tool.""" def test_get_pins(self, populated_schematic): from mckicad.tools.schematic_analysis import get_component_pins result = get_component_pins( schematic_path=populated_schematic, reference="R1", ) assert "success" in result def test_get_pins_nonexistent(self, populated_schematic): from mckicad.tools.schematic_analysis import get_component_pins result = get_component_pins( schematic_path=populated_schematic, reference="Z99", ) assert result["success"] is False @pytest.mark.unit class TestExportValidation: """Tests for input validation in export tools.""" def test_export_netlist_invalid_path(self): from mckicad.tools.schematic_analysis import export_netlist result = export_netlist(schematic_path="/tmp/nonexistent.kicad_sch") assert result["success"] is False def test_export_pdf_invalid_path(self): from mckicad.tools.schematic_analysis import export_schematic_pdf result = export_schematic_pdf(schematic_path="/tmp/nonexistent.kicad_sch") assert result["success"] is False def test_export_netlist_bad_format(self): from mckicad.tools.schematic_analysis import export_netlist result = export_netlist( schematic_path="/tmp/test.kicad_sch", format="invalid_format", ) assert result["success"] is False assert "Unsupported" in result.get("error", "") @requires_sch_api @pytest.mark.unit class TestAuditWiring: """Tests for the audit_wiring tool.""" def test_audit_existing_component(self, populated_schematic): from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path=populated_schematic, reference="R1", ) assert result["success"] is True assert result["reference"] == "R1" assert "net_summary" in result assert result.get("pin_count", 0) > 0 assert "net_count" in result def test_audit_nonexistent_component(self, populated_schematic): from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path=populated_schematic, reference="Z99", ) assert result["success"] is False def test_audit_invalid_path(self): from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path="/tmp/nonexistent.kicad_sch", reference="R1", ) assert result["success"] is False def test_audit_empty_reference(self): from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path="/tmp/nonexistent.kicad_sch", reference="", ) assert result["success"] is False def test_audit_net_summary_structure(self, populated_schematic): """Each net entry has pins, wire_count, connected_to — no wires by default.""" from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path=populated_schematic, reference="R1", ) if result["success"]: for _net_name, entry in result["net_summary"].items(): assert "pins" in entry assert isinstance(entry["pins"], list) assert "wire_count" in entry assert "connected_to" in entry assert isinstance(entry["connected_to"], list) # No wire coords by default assert "wires" not in entry def test_audit_include_wires(self, populated_schematic): """When include_wires=True, each net entry contains a wires list.""" from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path=populated_schematic, reference="R1", include_wires=True, ) if result["success"]: for _net_name, entry in result["net_summary"].items(): assert "wires" in entry assert isinstance(entry["wires"], list) def test_audit_pin_filter(self, populated_schematic): """Filtering by pin number limits the net_summary to matching nets.""" from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path=populated_schematic, reference="R1", pins=["1"], ) if result["success"]: # Only nets containing pin "1" should appear for entry in result["net_summary"].values(): assert "1" in entry["pins"] def test_audit_net_filter(self, populated_schematic): """Filtering by net name limits the summary to that net only.""" from mckicad.tools.schematic_analysis import audit_wiring # First get all nets to pick one full = audit_wiring( schematic_path=populated_schematic, reference="R1", ) if full["success"] and full["net_summary"]: target_net = next(iter(full["net_summary"])) filtered = audit_wiring( schematic_path=populated_schematic, reference="R1", net=target_net, ) assert filtered["success"] is True assert list(filtered["net_summary"].keys()) == [target_net] def test_audit_anomalies_structure(self, populated_schematic): """Anomalies dict always present with expected keys.""" from mckicad.tools.schematic_analysis import audit_wiring result = audit_wiring( schematic_path=populated_schematic, reference="R1", ) if result["success"]: assert "anomalies" in result anomalies = result["anomalies"] assert "unconnected_pins" in anomalies assert isinstance(anomalies["unconnected_pins"], list) assert "high_fanout_nets" in anomalies assert isinstance(anomalies["high_fanout_nets"], list) assert "auto_named_nets" in anomalies assert isinstance(anomalies["auto_named_nets"], list) @requires_sch_api @pytest.mark.unit class TestVerifyConnectivity: """Tests for the verify_connectivity tool.""" def test_verify_with_matching_net(self, populated_schematic): from mckicad.tools.schematic_analysis import ( analyze_connectivity, verify_connectivity, ) # First get actual connectivity to build a valid expected map conn = analyze_connectivity(schematic_path=populated_schematic) assert conn["success"] is True # Try to verify with an empty expected — should fail validation result = verify_connectivity( schematic_path=populated_schematic, expected={}, ) assert result["success"] is False def test_verify_missing_net(self, populated_schematic): from mckicad.tools.schematic_analysis import verify_connectivity result = verify_connectivity( schematic_path=populated_schematic, expected={"NONEXISTENT_NET": [["U99", "1"]]}, ) assert result["success"] is True assert result["failed"] >= 1 # Should report as missing_net or missing pin statuses = {r["status"] for r in result["results"]} assert statuses & {"missing_net", "mismatch"} def test_verify_invalid_path(self): from mckicad.tools.schematic_analysis import verify_connectivity result = verify_connectivity( schematic_path="/tmp/nonexistent.kicad_sch", expected={"NET": [["R1", "1"]]}, ) assert result["success"] is False def test_verify_result_structure(self, populated_schematic): from mckicad.tools.schematic_analysis import verify_connectivity result = verify_connectivity( schematic_path=populated_schematic, expected={"TEST_NET": [["R1", "1"]]}, ) assert result["success"] is True assert "verified" in result assert "failed" in result assert "total" in result assert "results" in result for r in result["results"]: assert "net" in r assert "status" in r @pytest.mark.unit class TestValidateSchematic: """Tests for the validate_schematic aggregate tool.""" def _make_project(self, tmp_path, violations=None): """Create a minimal project structure with mockable ERC.""" pro = tmp_path / "test.kicad_pro" pro.write_text("{}") sch = tmp_path / "test.kicad_sch" sch.write_text('(kicad_sch (version 20231120) (uuid "root"))\n') if violations is None: violations = [] erc_json = { "sheets": [ { "path": "/", "uuid_path": "/root", "violations": violations, }, ], } return str(sch), erc_json def _patch_erc(self, erc_json, netlist_xml=None): """Return a combined context manager that mocks kicad-cli ERC + netlist. Args: erc_json: ERC report dict to write for ``kicad-cli sch erc``. netlist_xml: Optional XML netlist string for ``kicad-cli sch export netlist``. If None, a minimal empty netlist is used. """ import contextlib import json from unittest.mock import patch if netlist_xml is None: netlist_xml = ( '\n' '' ) def fake_subprocess_run(cmd, **kwargs): out_idx = cmd.index("-o") out_path = cmd[out_idx + 1] if "erc" in cmd: with open(out_path, "w") as f: json.dump(erc_json, f) elif "netlist" in cmd: with open(out_path, "w") as f: f.write(netlist_xml) class FakeResult: returncode = 0 stdout = "" stderr = "" return FakeResult() stack = contextlib.ExitStack() stack.enter_context(patch("mckicad.tools.schematic_analysis._HAS_SCH_API", False)) stack.enter_context(patch("mckicad.tools.schematic_analysis.find_kicad_cli", return_value="/usr/bin/kicad-cli")) stack.enter_context(patch("subprocess.run", side_effect=fake_subprocess_run)) return stack def test_pass_no_violations(self, tmp_path): """Clean schematic returns status=pass.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) with self._patch_erc(erc_json): result = validate_schematic(schematic_path=sch_path) assert result["success"] is True assert result["status"] == "pass" assert result["erc"]["total_violations"] == 0 assert result["erc"]["fatal"] == [] def test_fail_on_fatal_violation_type(self, tmp_path): """Violations matching fail_on types cause status=fail.""" from mckicad.tools.schematic_analysis import validate_schematic violations = [ { "description": "Net short between VCC and GND", "severity": "error", "type": "multiple_net_names", "items": [], }, { "description": "Dangling label", "severity": "warning", "type": "global_label_dangling", "items": [], }, ] sch_path, erc_json = self._make_project(tmp_path, violations) with self._patch_erc(erc_json): result = validate_schematic(schematic_path=sch_path) assert result["status"] == "fail" assert result["erc"]["total_violations"] == 2 assert len(result["erc"]["fatal"]) == 1 assert result["erc"]["fatal"][0]["type"] == "multiple_net_names" assert result["erc"]["by_type"]["multiple_net_names"] == 1 assert result["erc"]["by_type"]["global_label_dangling"] == 1 def test_pass_with_non_fatal_violations(self, tmp_path): """Violations not in fail_on list don't cause failure.""" from mckicad.tools.schematic_analysis import validate_schematic violations = [ { "description": "Dangling label", "severity": "warning", "type": "global_label_dangling", "items": [], }, ] sch_path, erc_json = self._make_project(tmp_path, violations) with self._patch_erc(erc_json): result = validate_schematic(schematic_path=sch_path) assert result["status"] == "pass" assert result["erc"]["total_violations"] == 1 assert result["erc"]["fatal"] == [] def test_custom_fail_on(self, tmp_path): """Custom fail_on types are respected.""" from mckicad.tools.schematic_analysis import validate_schematic violations = [ { "description": "Wire overlap", "severity": "error", "type": "label_multiple_wires", "items": [], }, ] sch_path, erc_json = self._make_project(tmp_path, violations) with self._patch_erc(erc_json): result = validate_schematic( schematic_path=sch_path, fail_on=["label_multiple_wires", "multiple_net_names"], ) assert result["status"] == "fail" assert len(result["erc"]["fatal"]) == 1 def test_baseline_pass(self, tmp_path): """Baseline comparison passes when metrics match.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) with self._patch_erc(erc_json): result = validate_schematic( schematic_path=sch_path, baseline={"connections": 0, "unconnected": 0}, ) assert result["status"] == "pass" assert result["connectivity"]["baseline_delta"]["connections"] == 0 assert result["connectivity"]["baseline_delta"]["unconnected"] == 0 assert "regressions" not in result def test_baseline_regression_connections(self, tmp_path): """Decreased connections cause regression + fail.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) with self._patch_erc(erc_json): result = validate_schematic( schematic_path=sch_path, baseline={"connections": 100}, ) # connectivity returns 0 connections (no sch-api in mock), so delta is -100 assert result["status"] == "fail" assert result["connectivity"]["baseline_delta"]["connections"] == -100 assert len(result["regressions"]) >= 1 assert "connections decreased" in result["regressions"][0] def test_baseline_regression_unconnected(self, tmp_path): """Increased unconnected pins cause regression + fail.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) with self._patch_erc(erc_json): result = validate_schematic( schematic_path=sch_path, baseline={"unconnected": -1}, ) # unconnected_pins is 0, baseline is -1, so delta is +1 (regression) assert result["status"] == "fail" assert "regressions" in result def test_by_severity_counts(self, tmp_path): """Severity counts are aggregated correctly.""" from mckicad.tools.schematic_analysis import validate_schematic violations = [ {"description": "E1", "severity": "error", "type": "t1", "items": []}, {"description": "E2", "severity": "error", "type": "t2", "items": []}, {"description": "W1", "severity": "warning", "type": "t3", "items": []}, ] sch_path, erc_json = self._make_project(tmp_path, violations) with self._patch_erc(erc_json): result = validate_schematic( schematic_path=sch_path, fail_on=[], # don't fail on any type ) assert result["status"] == "pass" assert result["erc"]["by_severity"]["error"] == 2 assert result["erc"]["by_severity"]["warning"] == 1 def test_invalid_path(self): """Invalid schematic path returns error.""" from mckicad.tools.schematic_analysis import validate_schematic result = validate_schematic(schematic_path="/tmp/nonexistent.kicad_sch") assert result["success"] is False def test_result_structure(self, tmp_path): """Return dict has all expected top-level keys.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) with self._patch_erc(erc_json): result = validate_schematic(schematic_path=sch_path) assert "success" in result assert "status" in result assert "erc" in result assert "connectivity" in result assert "total_violations" in result["erc"] assert "by_type" in result["erc"] assert "by_severity" in result["erc"] assert "fatal" in result["erc"] assert "net_count" in result["connectivity"] assert "connection_count" in result["connectivity"] assert "unconnected_pins" in result["connectivity"] def test_netlist_connectivity_counts(self, tmp_path): """Netlist-based connectivity extracts net_count and connection_count.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) netlist_xml = ( '\n' '\n' ' \n' ' 10k\n' ' 100nF\n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' '' ) with self._patch_erc(erc_json, netlist_xml=netlist_xml): result = validate_schematic(schematic_path=sch_path) assert result["success"] is True assert result["connectivity"]["net_count"] == 2 assert result["connectivity"]["connection_count"] == 3 assert result["connectivity"]["engine"] == "kicad-cli-netlist" def test_netlist_baseline_with_real_data(self, tmp_path): """Baseline comparison works with netlist-derived connectivity.""" from mckicad.tools.schematic_analysis import validate_schematic sch_path, erc_json = self._make_project(tmp_path) netlist_xml = ( '\n' '\n' ' \n' ' 10k\n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' '' ) with self._patch_erc(erc_json, netlist_xml=netlist_xml): result = validate_schematic( schematic_path=sch_path, baseline={"connections": 2, "nets_min": 1}, ) assert result["status"] == "pass" assert result["connectivity"]["baseline_delta"]["connections"] == 0 def test_unconnected_from_erc_pin_not_connected(self, tmp_path): """Unconnected pins derived from ERC pin_not_connected count.""" from mckicad.tools.schematic_analysis import validate_schematic violations = [ {"description": "P1", "severity": "warning", "type": "pin_not_connected", "items": []}, {"description": "P2", "severity": "warning", "type": "pin_not_connected", "items": []}, {"description": "P3", "severity": "warning", "type": "pin_not_connected", "items": []}, ] sch_path, erc_json = self._make_project(tmp_path, violations) with self._patch_erc(erc_json): result = validate_schematic( schematic_path=sch_path, fail_on=[], ) assert result["connectivity"]["unconnected_pins"] == 3