Some checks are pending
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
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
KiCad 9 nests violations under sheets[].violations instead of a top-level violations key. The parser now iterates all sheets and aggregates violations, with a fallback for flat format.
490 lines
17 KiB
Python
490 lines
17 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
|
|
|
|
|
|
@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
|