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
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.
540 lines
19 KiB
Python
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
|