"""Tests for create_netclass persisting net class definitions to ``.kicad_pro`` (#185). Net class *definitions* live in ``.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"]