""" Tests for SWIG board-dehydration detection and recovery in KiCADInterface. Reproduces the failure mode users hit on KiCAD nightly builds: after sequences like delete_trace + add_via, pcbnew.LoadBoard returns a "dehydrated" BOARD — a SWIG proxy whose method dispatch table is missing, so every method access raises AttributeError. Without recovery, every subsequent call fails and open_project keeps reporting fake success because LoadBoard didn't raise. Also covers the check_kicad_ui consistency fix where running and processes could disagree. """ import sys from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "python")) # --------------------------------------------------------------------------- # Fixtures: stand-ins for SWIG BOARD proxies # --------------------------------------------------------------------------- def _make_dehydrated_board() -> Any: """A bare object simulating the post-dehydration SwigPyObject — no methods.""" class _Dehydrated: # Intentionally empty: hasattr(...) for the health-check methods # returns False, matching the symptom users hit. pass return _Dehydrated() def _make_healthy_board() -> Any: """A MagicMock that satisfies _is_board_healthy.""" board = MagicMock(name="HealthyBoard") board.GetDesignSettings = MagicMock(return_value=MagicMock()) board.GetBoardEdgesBoundingBox = MagicMock(return_value=MagicMock()) board.GetFileName = MagicMock(return_value="/tmp/test.kicad_pcb") return board def _make_iface() -> Any: """Build a KiCADInterface skipping __init__ — same pattern as test_erc_handler. handle_command reads `use_ipc` and `ipc_board_api` early; set them so a test exercising the routing layer doesn't crash on the IPC short-circuit. """ with patch("kicad_interface.USE_IPC_BACKEND", False): from kicad_interface import KiCADInterface iface = KiCADInterface.__new__(KiCADInterface) iface.use_ipc = False iface.ipc_board_api = None # __init__ is skipped here, so initialise the auto-save signature # attribute that _auto_save_board's content-divergence guard reads. # (The guard came from the #151/#172 auto-save work that #173 was # merged on top of; without this the dehydration recovery test trips # an AttributeError before reaching the code under test.) iface._board_disk_signature = None return iface # --------------------------------------------------------------------------- # _is_board_healthy # --------------------------------------------------------------------------- def test_is_board_healthy_returns_false_for_none(): iface = _make_iface() iface.board = None assert iface._is_board_healthy() is False def test_is_board_healthy_returns_false_for_dehydrated_proxy(): iface = _make_iface() iface.board = _make_dehydrated_board() assert iface._is_board_healthy() is False def test_is_board_healthy_returns_true_for_real_board(): iface = _make_iface() iface.board = _make_healthy_board() assert iface._is_board_healthy() is True def test_is_board_healthy_accepts_explicit_target(): iface = _make_iface() iface.board = None # self.board is broken healthy = _make_healthy_board() # Explicit argument bypasses self.board assert iface._is_board_healthy(healthy) is True assert iface._is_board_healthy(_make_dehydrated_board()) is False # --------------------------------------------------------------------------- # _safe_load_board # --------------------------------------------------------------------------- def test_safe_load_board_returns_loaded_board_when_healthy(): iface = _make_iface() healthy = _make_healthy_board() with patch("kicad_interface.pcbnew") as mock_pcbnew: mock_pcbnew.LoadBoard = MagicMock(return_value=healthy) result = iface._safe_load_board("/tmp/x.kicad_pcb") assert result is healthy def test_safe_load_board_retries_after_pcbnew_reload_when_dehydrated(): iface = _make_iface() dehydrated = _make_dehydrated_board() healthy = _make_healthy_board() # First LoadBoard returns dehydrated → reload pcbnew → second LoadBoard returns healthy with patch("kicad_interface.pcbnew") as mock_pcbnew, patch("importlib.reload") as mock_reload: mock_pcbnew.LoadBoard = MagicMock(side_effect=[dehydrated, healthy]) mock_reload.return_value = mock_pcbnew # reload returns the (mock) module result = iface._safe_load_board("/tmp/x.kicad_pcb") assert result is healthy assert mock_pcbnew.LoadBoard.call_count == 2 mock_reload.assert_called_once() def test_safe_load_board_returns_none_when_recovery_fails(): iface = _make_iface() dehydrated = _make_dehydrated_board() with patch("kicad_interface.pcbnew") as mock_pcbnew, patch("importlib.reload") as mock_reload: mock_pcbnew.LoadBoard = MagicMock(return_value=dehydrated) # always dehydrated mock_reload.return_value = mock_pcbnew result = iface._safe_load_board("/tmp/x.kicad_pcb") assert result is None def test_safe_load_board_returns_none_when_loadboard_raises(): iface = _make_iface() with patch("kicad_interface.pcbnew") as mock_pcbnew: mock_pcbnew.LoadBoard = MagicMock(side_effect=RuntimeError("io error")) result = iface._safe_load_board("/tmp/missing.kicad_pcb") assert result is None # --------------------------------------------------------------------------- # handle_command("open_project") — surfacing dehydration vs. recovery # --------------------------------------------------------------------------- def _wire_open_project(iface: Any, board_to_assign: Any, board_path: str) -> None: """Make iface.project_commands.open_project return success and assign a board.""" iface.project_commands = MagicMock() def _fake(params): iface.project_commands.board = board_to_assign return { "success": True, "message": f"Opened project: {Path(board_path).name}", "project": { "name": Path(board_path).stem, "path": board_path, "boardPath": board_path, }, } iface.project_commands.open_project = _fake # Stub the rest of _update_command_handlers' targets for attr in ( "board_commands", "component_commands", "routing_commands", "design_rule_commands", "export_commands", "freerouting_commands", ): setattr(iface, attr, MagicMock()) # Provide minimal command_routes so handle_command can route iface.command_routes = {"open_project": iface.project_commands.open_project} def test_handle_open_project_surfaces_dehydration_when_recovery_fails(): """If LoadBoard returns a dehydrated proxy and recovery fails, the MCP must return success=False — never claim "Opened project" while the board is unusable. This is the fix for the silent-success bug users hit.""" iface = _make_iface() dehydrated = _make_dehydrated_board() _wire_open_project(iface, dehydrated, "/tmp/test.kicad_pcb") iface._safe_load_board = MagicMock(return_value=None) # recovery impossible result = iface.handle_command("open_project", {"filename": "/tmp/test.kicad_pro"}) assert result["success"] is False, f"Expected failure, got: {result}" combined = (result.get("errorDetails", "") + result.get("message", "")).lower() assert "dehydrated" in combined or "swigpyobject" in combined def test_handle_open_project_recovers_via_safe_load_board(): """When _safe_load_board succeeds, open_project should report success with a warning and the recovered board should be installed.""" iface = _make_iface() dehydrated = _make_dehydrated_board() healthy = _make_healthy_board() _wire_open_project(iface, dehydrated, "/tmp/test.kicad_pcb") iface._safe_load_board = MagicMock(return_value=healthy) result = iface.handle_command("open_project", {"filename": "/tmp/test.kicad_pro"}) assert result["success"] is True, f"Expected recovery success, got: {result}" assert iface.board is healthy iface._safe_load_board.assert_called_once_with("/tmp/test.kicad_pcb") warnings = result.get("warnings", []) assert any("dehydrated" in w.lower() for w in warnings) def test_handle_open_project_passes_through_when_already_healthy(): """The fast path: LoadBoard returned a healthy board, no recovery needed.""" iface = _make_iface() healthy = _make_healthy_board() _wire_open_project(iface, healthy, "/tmp/test.kicad_pcb") iface._safe_load_board = MagicMock( side_effect=AssertionError("should not be called when board is healthy") ) result = iface.handle_command("open_project", {"filename": "/tmp/test.kicad_pro"}) assert result["success"] is True assert iface.board is healthy assert "warnings" not in result # no recovery happened # --------------------------------------------------------------------------- # _auto_save_board — recover if save dehydrates the board in-memory # --------------------------------------------------------------------------- def test_auto_save_recovers_when_save_leaves_board_dehydrated(): """If pcbnew.SaveBoard somehow corrupts the in-memory BOARD (observed on nightlies after delete_trace + auto-save), _auto_save_board reloads from disk so the next command sees a usable proxy.""" iface = _make_iface() # Start with a healthy board that becomes dehydrated after SaveBoard runs healthy = _make_healthy_board() healthy.GetFileName = MagicMock(return_value="/tmp/test.kicad_pcb") iface.board = healthy recovered = _make_healthy_board() iface._safe_load_board = MagicMock(return_value=recovered) # Stub _update_command_handlers' downstream targets for attr in ( "project_commands", "board_commands", "component_commands", "routing_commands", "design_rule_commands", "export_commands", "freerouting_commands", ): setattr(iface, attr, MagicMock()) def _save_then_dehydrate(path, board): # SaveBoard succeeds but leaves iface.board unusable iface.board = _make_dehydrated_board() iface.board.GetFileName = MagicMock(return_value="/tmp/test.kicad_pcb") with patch("kicad_interface.pcbnew") as mock_pcbnew: mock_pcbnew.SaveBoard = MagicMock(side_effect=_save_then_dehydrate) iface._auto_save_board() assert iface.board is recovered iface._safe_load_board.assert_called_once_with("/tmp/test.kicad_pcb") # --------------------------------------------------------------------------- # check_kicad_ui consistency fix # --------------------------------------------------------------------------- def test_check_kicad_ui_running_false_when_no_processes(): iface = _make_iface() with patch("kicad_interface.KiCADProcessManager") as MockMgr: MockMgr.return_value.get_process_info.return_value = [] result = iface._handle_check_kicad_ui({}) assert result["success"] is True assert result["running"] is False assert result["processes"] == [] def test_check_kicad_ui_running_true_when_processes_present(): iface = _make_iface() with patch("kicad_interface.KiCADProcessManager") as MockMgr: MockMgr.return_value.get_process_info.return_value = [ {"pid": "1234", "name": "kicad", "command": "/Applications/KiCad/.../kicad"} ] result = iface._handle_check_kicad_ui({}) assert result["success"] is True assert result["running"] is True assert len(result["processes"]) == 1 def test_check_kicad_ui_running_and_processes_never_disagree(): """Regression test for the old race where running=True coexisted with processes=[] because is_running() and get_process_info() used different detection methods.""" iface = _make_iface() with patch("kicad_interface.KiCADProcessManager") as MockMgr: # Even if some hypothetical separate is_running() returned True, # only get_process_info matters now. MockMgr.return_value.is_running.return_value = True MockMgr.return_value.get_process_info.return_value = [] result = iface._handle_check_kicad_ui({}) assert result["running"] is False assert result["processes"] == [] # --------------------------------------------------------------------------- # run_drc — caller-overridable timeout # --------------------------------------------------------------------------- def test_run_drc_honors_timeout_sec_param(): """timeoutSec param must be passed to the subprocess.run timeout argument.""" import subprocess as _sp from commands.design_rules import DesignRuleCommands cmds = DesignRuleCommands(board=_make_healthy_board()) cmds.board.GetFileName = MagicMock(return_value="/tmp/exists.kicad_pcb") captured: dict = {} def _fake_run(cmd, **kwargs): captured["timeout"] = kwargs.get("timeout") # Raise TimeoutExpired so we don't need to fake JSON output raise _sp.TimeoutExpired(cmd, kwargs.get("timeout", 0)) with ( patch("os.path.exists", return_value=True), patch("subprocess.run", side_effect=_fake_run), patch.object(cmds, "_find_kicad_cli", return_value="/usr/bin/kicad-cli"), ): result = cmds.run_drc({"timeoutSec": 45}) assert captured["timeout"] == 45 assert result["success"] is False assert "45" in result["errorDetails"] def test_run_drc_clamps_extreme_timeout_values(): """Bad timeoutSec values must be clamped, not crash.""" import subprocess as _sp from commands.design_rules import DesignRuleCommands cmds = DesignRuleCommands(board=_make_healthy_board()) cmds.board.GetFileName = MagicMock(return_value="/tmp/exists.kicad_pcb") captured: dict = {} def _fake_run(cmd, **kwargs): captured.setdefault("timeouts", []).append(kwargs.get("timeout")) raise _sp.TimeoutExpired(cmd, kwargs.get("timeout", 0)) with ( patch("os.path.exists", return_value=True), patch("subprocess.run", side_effect=_fake_run), patch.object(cmds, "_find_kicad_cli", return_value="/usr/bin/kicad-cli"), ): cmds.run_drc({"timeoutSec": -100}) # below clamp cmds.run_drc({"timeoutSec": 99999}) # above clamp cmds.run_drc({"timeoutSec": "garbage"}) # unparseable → default 600 assert captured["timeouts"][0] == 10 # clamped to min assert captured["timeouts"][1] == 1800 # clamped to max assert captured["timeouts"][2] == 600 # fallback default