kicad-mcp/tests/test_schematic_analysis.py
Ryan Malloy 1fb608ef5d
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 validate_schematic tool for single-call project health checks
Combines hierarchy-aware ERC (via root schematic resolution) with
connectivity analysis and optional baseline comparison into one atomic
call. Supports fail_on parameter to gate pass/fail on specific ERC
violation types (default: multiple_net_names). Baseline comparison
detects connection count decreases and unconnected pin increases as
regressions.

Replaces the 20+ tool call workflow of running ERC + connectivity on
each sub-sheet individually.

280/280 tests pass, ruff + mypy clean.
2026-03-08 03:28:25 -06:00

778 lines
27 KiB
Python

"""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):
"""Return a combined context manager that mocks kicad-cli ERC."""
import contextlib
import json
from unittest.mock import patch
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()
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"]