kicad-mcp/tests/test_schematic_analysis.py
Ryan Malloy 97ebc585f8
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 wire collision detection, project-local library resolution, and root ERC support
Wire collision detection: apply_batch now tracks placed wire segments and
detects collinear stubs on the same axis with overlapping ranges belonging
to different nets. Colliding wires shift perpendicular to their axis by
1.27mm, preventing KiCad from merging wire segments into mega-nets.

Project-local library resolution: apply_batch now scans batch component
lib_ids for unknown libraries and registers them with kicad-sch-api's
SymbolLibraryCache via sym-lib-table parsing before component placement.
Unblocks projects using Samacsys and other non-standard symbol libraries.

Root ERC: run_schematic_erc accepts root=True to resolve to the project
root schematic before running kicad-cli, enabling hierarchy-aware ERC
that eliminates ~180 false-positive global_label_dangling warnings from
sub-sheet isolation.

270/270 tests pass, ruff + mypy clean.
2026-03-08 03:13:45 -06:00

540 lines
19 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