Net class definitions live in <project>.kicad_pro (net_settings) on KiCad 7+, not in the .kicad_pcb that the SWIG board save writes -- so create_netclass mutated the in-memory board and reported success, but nothing survived a reload. Write the class definition and its net memberships (netclass_patterns) into the project JSON, which is what KiCad reads on open. The transform is a pure, unit-tested function that needs no live KiCad/SWIG round-trip; the in-memory NETCLASS path is kept for live-session consistency.
202 lines
7.8 KiB
Python
202 lines
7.8 KiB
Python
"""Tests for create_netclass persisting net class definitions to ``.kicad_pro`` (#185).
|
|
|
|
Net class *definitions* live in ``<project>.kicad_pro`` (``net_settings.classes``),
|
|
not in the ``.kicad_pcb`` board file, on KiCad 7+. These tests exercise the pure
|
|
JSON transform, the atomic file round-trip, and the create_netclass wiring without
|
|
needing a live KiCad / SWIG board.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
|
|
|
from commands.routing import ( # noqa: E402
|
|
RoutingCommands,
|
|
apply_netclass_to_project_settings,
|
|
persist_netclass_to_project,
|
|
)
|
|
|
|
|
|
def _project_with_default():
|
|
return {
|
|
"net_settings": {
|
|
"classes": [
|
|
{
|
|
"name": "Default",
|
|
"clearance": 0.2,
|
|
"track_width": 0.2,
|
|
"via_diameter": 0.6,
|
|
"via_drill": 0.3,
|
|
"priority": 2147483647,
|
|
}
|
|
],
|
|
"netclass_patterns": [],
|
|
}
|
|
}
|
|
|
|
|
|
# --- pure transform -------------------------------------------------------
|
|
|
|
|
|
def test_apply_adds_class_with_mm_fields():
|
|
data = _project_with_default()
|
|
apply_netclass_to_project_settings(
|
|
data, "HV", {"clearance": 0.5, "track_width": 0.4, "via_diameter": 1.0, "via_drill": 0.5}
|
|
)
|
|
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
|
|
assert hv["clearance"] == 0.5
|
|
assert hv["track_width"] == 0.4
|
|
assert hv["via_diameter"] == 1.0
|
|
assert hv["via_drill"] == 0.5
|
|
# custom classes get priority 0; the Default class keeps its own
|
|
assert hv["priority"] == 0
|
|
|
|
|
|
def test_apply_clones_default_field_shape():
|
|
data = _project_with_default()
|
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
|
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
|
|
assert "track_width" in hv and "via_diameter" in hv
|
|
|
|
|
|
def test_apply_creates_class_template_when_no_default():
|
|
data = {"net_settings": {"classes": []}}
|
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
|
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
|
|
# full KiCad-10 field set from the fallback template
|
|
for key in ("bus_width", "track_width", "via_diameter", "via_drill", "line_style"):
|
|
assert key in hv
|
|
|
|
|
|
def test_apply_updates_existing_class_without_duplicating():
|
|
data = _project_with_default()
|
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.8})
|
|
hv = [c for c in data["net_settings"]["classes"] if c["name"] == "HV"]
|
|
assert len(hv) == 1
|
|
assert hv[0]["clearance"] == 0.8
|
|
|
|
|
|
def test_apply_creates_net_settings_when_absent():
|
|
data = {}
|
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
|
assert data["net_settings"]["classes"][0]["name"] == "HV"
|
|
|
|
|
|
# --- file persistence -----------------------------------------------------
|
|
|
|
|
|
def test_persist_round_trips_through_a_real_file(tmp_path):
|
|
pro = tmp_path / "proj.kicad_pro"
|
|
pro.write_text(json.dumps(_project_with_default()))
|
|
result = persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5, "track_width": 0.4})
|
|
assert result["persisted"] is True
|
|
assert result["projectFile"] == str(pro)
|
|
hv = next(
|
|
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
|
|
)
|
|
assert hv["clearance"] == 0.5 and hv["track_width"] == 0.4
|
|
|
|
|
|
def test_persist_preserves_unrelated_project_and_net_settings_content(tmp_path):
|
|
pro = tmp_path / "proj.kicad_pro"
|
|
project = _project_with_default()
|
|
project["board"] = {"design_settings": {"rules": {"min_clearance": 0.1}}}
|
|
project["net_settings"]["meta"] = {"version": 4}
|
|
project["net_settings"]["net_colors"] = {"GND": "rgba(1, 2, 3, 0.5)"}
|
|
project["net_settings"]["netclass_assignments"] = {"GND": "Default"}
|
|
pro.write_text(json.dumps(project))
|
|
persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
|
|
reloaded = json.loads(pro.read_text())
|
|
assert reloaded["board"]["design_settings"]["rules"]["min_clearance"] == 0.1
|
|
assert reloaded["net_settings"]["meta"] == {"version": 4}
|
|
assert reloaded["net_settings"]["net_colors"] == {"GND": "rgba(1, 2, 3, 0.5)"}
|
|
assert reloaded["net_settings"]["netclass_assignments"] == {"GND": "Default"}
|
|
default = next(c for c in reloaded["net_settings"]["classes"] if c["name"] == "Default")
|
|
assert default["priority"] == 2147483647
|
|
|
|
|
|
def test_persist_writes_atomically_leaving_no_temp_file(tmp_path):
|
|
pro = tmp_path / "proj.kicad_pro"
|
|
pro.write_text(json.dumps(_project_with_default()))
|
|
persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
|
|
assert [p.name for p in tmp_path.iterdir()] == ["proj.kicad_pro"]
|
|
|
|
|
|
def test_persist_warns_when_no_project_file():
|
|
result = persist_netclass_to_project(None, "HV", {"clearance": 0.5})
|
|
assert result["persisted"] is False
|
|
assert "warning" in result
|
|
|
|
|
|
def test_persist_warns_on_malformed_json_and_leaves_file_intact(tmp_path):
|
|
pro = tmp_path / "proj.kicad_pro"
|
|
pro.write_text("{not valid json")
|
|
result = persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
|
|
assert result["persisted"] is False
|
|
assert str(pro) in result["warning"]
|
|
assert pro.read_text() == "{not valid json" # never half-written
|
|
|
|
|
|
def test_persist_warns_when_path_is_a_directory(tmp_path):
|
|
# a directory passes os.path.exists() but open()-for-read fails -> hits the except
|
|
result = persist_netclass_to_project(str(tmp_path), "HV", {"clearance": 0.5})
|
|
assert result["persisted"] is False
|
|
assert str(tmp_path) in result["warning"]
|
|
|
|
|
|
# --- create_netclass wiring ----------------------------------------------
|
|
|
|
|
|
def test_create_netclass_persists_canonical_trace_width(tmp_path):
|
|
# Regression: a schema-conformant call (canonical "traceWidth") must reach
|
|
# .kicad_pro as track_width, not the cloned Default's value.
|
|
pro = tmp_path / "p.kicad_pro"
|
|
pro.write_text(json.dumps(_project_with_default()))
|
|
board = MagicMock()
|
|
board.GetFileName.return_value = str(tmp_path / "p.kicad_pcb")
|
|
result = RoutingCommands(board).create_netclass(
|
|
{"name": "HV", "traceWidth": 5.0, "clearance": 0.5}
|
|
)
|
|
assert result["success"] is True
|
|
assert result["persisted"] is True
|
|
hv = next(
|
|
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
|
|
)
|
|
assert hv["track_width"] == 5.0
|
|
assert hv["clearance"] == 0.5
|
|
|
|
|
|
def test_create_netclass_persists_even_when_swig_path_fails(tmp_path):
|
|
# The .kicad_pro write is SWIG-independent: a SWIG throw must not skip it.
|
|
pro = tmp_path / "p.kicad_pro"
|
|
pro.write_text(json.dumps(_project_with_default()))
|
|
board = MagicMock()
|
|
board.GetNetClasses.side_effect = RuntimeError("swig boom")
|
|
board.GetFileName.return_value = str(tmp_path / "p.kicad_pcb")
|
|
result = RoutingCommands(board).create_netclass(
|
|
{"name": "HV", "traceWidth": 0.4, "clearance": 0.5}
|
|
)
|
|
assert result["success"] is True
|
|
assert result["persisted"] is True
|
|
assert "swig boom" in result.get("warning", "")
|
|
hv = next(
|
|
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
|
|
)
|
|
assert hv["track_width"] == 0.4
|
|
|
|
|
|
def test_create_netclass_fails_when_neither_memory_nor_disk_succeeds(tmp_path):
|
|
board = MagicMock()
|
|
board.GetNetClasses.side_effect = RuntimeError("swig boom")
|
|
# sibling .kicad_pro does not exist -> persistence can't happen either
|
|
board.GetFileName.return_value = str(tmp_path / "missing.kicad_pcb")
|
|
result = RoutingCommands(board).create_netclass(
|
|
{"name": "HV", "traceWidth": 0.4, "clearance": 0.5}
|
|
)
|
|
assert result["success"] is False
|
|
assert "swig boom" in result["errorDetails"]
|