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
test_root_resolves_to_project_schematic built its project directly in tmp_path, where the autouse _set_test_search_paths fixture also drops a test_project.kicad_pro. With two .kicad_pro files in one directory, root resolution returned whichever os.listdir yielded first — myproject on ext4, test_project on APFS — so it passed on Linux and failed on macOS. Anchor the test's project in its own subdirectory so the walk stops at the intended root.
871 lines
31 KiB
Python
871 lines
31 KiB
Python
"""Tests for schematic analysis tools."""
|
|
|
|
import pytest
|
|
|
|
|
|
@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.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.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.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
|
|
|
|
# Anchor the project in its own subdirectory. The autouse
|
|
# _set_test_search_paths fixture drops a second "test_project.kicad_pro"
|
|
# into tmp_path; keeping our project one level down means the root walk
|
|
# finds *this* project's .kicad_pro and stops before it reaches (and is
|
|
# confused by) the sibling. Without this, resolution depends on
|
|
# os.listdir order, which differs between ext4 and APFS.
|
|
proj_dir = tmp_path / "myproject"
|
|
proj_dir.mkdir()
|
|
|
|
pro_file = proj_dir / "myproject.kicad_pro"
|
|
pro_file.write_text("{}")
|
|
|
|
root_sch = proj_dir / "myproject.kicad_sch"
|
|
root_sch.write_text('(kicad_sch (version 20231120) (uuid "root"))\n')
|
|
|
|
sub_dir = proj_dir / "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.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)
|
|
|
|
|
|
@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
|
|
|
|
|
|
@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
|
|
|
|
|
|
@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
|
|
|
|
|
|
@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", "")
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@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 = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<export version="D"><components/><nets/></export>'
|
|
)
|
|
|
|
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.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 = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<export version="D">\n'
|
|
' <components>\n'
|
|
' <comp ref="R1"><value>10k</value></comp>\n'
|
|
' <comp ref="C1"><value>100nF</value></comp>\n'
|
|
' </components>\n'
|
|
' <nets>\n'
|
|
' <net code="1" name="GND">\n'
|
|
' <node ref="R1" pin="1"/>\n'
|
|
' <node ref="C1" pin="2"/>\n'
|
|
' </net>\n'
|
|
' <net code="2" name="VCC">\n'
|
|
' <node ref="R1" pin="2"/>\n'
|
|
' </net>\n'
|
|
' </nets>\n'
|
|
'</export>'
|
|
)
|
|
|
|
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 = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<export version="D">\n'
|
|
' <components>\n'
|
|
' <comp ref="R1"><value>10k</value></comp>\n'
|
|
' </components>\n'
|
|
' <nets>\n'
|
|
' <net code="1" name="GND">\n'
|
|
' <node ref="R1" pin="1"/>\n'
|
|
' <node ref="R1" pin="2"/>\n'
|
|
' </net>\n'
|
|
' </nets>\n'
|
|
'</export>'
|
|
)
|
|
|
|
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
|