From 3b0948c15d30d4ac3e3d11527c8d90e0a66a49f8 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:24:22 +0530 Subject: [PATCH 1/3] docs(pscad): correct comparison guide run lifecycle --- PSCAD/COMPARISON_GUIDE.md | 90 +++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/PSCAD/COMPARISON_GUIDE.md b/PSCAD/COMPARISON_GUIDE.md index d4bf974d..7c988806 100644 --- a/PSCAD/COMPARISON_GUIDE.md +++ b/PSCAD/COMPARISON_GUIDE.md @@ -1,44 +1,70 @@ # Automation Comparison: Traditional vs. MCP -## 1. Traditional Approach (The "Hard" Way) -To run a simple simulation and check results, an engineer must write: +## 1. Traditional scripting + +A script should start a simulation using a non-blocking operation before polling its status. Do not call `Project.run()` and then expect the polling loop to run: `Project.run()` blocks for the build-and-run operation, so the code cannot poll until that call returns. + +A simplified lifecycle using the non-blocking PSCAD project API is: ```python -import mhi.pscad import time +import mhi.pscad -try: - pscad = mhi.pscad.launch() - project = pscad.load("C:\\temp\\vdiv.pscx") - project.parameters(R1="100 [ohm]") - project.run() - - # Must wait manually or poll - while project.run_status()[0] == "Run": - time.sleep(1) - - print(project.output()) -except Exception as e: - # If PSCAD freezes here, the script hangs forever - print(f"Failed: {e}") +pscad = mhi.pscad.launch() +project = pscad.load(r"C:\temp\vdiv.pscx") +project.parameters(R1="100 [ohm]") + +# Start the build/run without blocking on the whole simulation. +project.start() + +saw_running = False +while True: + status, progress = project.run_status() + if status == "Run": + saw_running = True + print(f"Simulation progress: {progress}%") + elif status == "Build": + print("Building project...") + else: + # Idle can mean completed, not started, or failed. It is not + # sufficient on its own to prove a successful simulation. + break + time.sleep(1) + +if not saw_running: + print("The project never reported running; inspect build messages/output.") +print(project.output()) ``` -## 2. MCP Approach (The "Intelligent" Way) -The user simply provides a goal to the AI: +This is an illustrative lifecycle, not a substitute for checking the installed PSCAD API version and the project's build/run messages. In particular, a return to idle is not, by itself, proof that the build and simulation succeeded. + +## 2. MCP workflow -**User Prompt:** -> "Launch PSCAD, load the vdiv project, set R1 to 100 ohms and run it. Summarize the output messages for me." +The PowerMCP server exposes the lifecycle as separate tools so an agent can start work, poll progress, and inspect diagnostics without holding a single call open for the full simulation. -**What happens behind the scenes (The MCP Advantage):** -1. **Auto-Connection**: MCP uses `get_local_pscad` to find an existing instance (Saving 10s of startup time). -2. **Watchdog Protection**: If `project.run()` takes too long to respond, the MCP Executor triggers a timeout instead of hanging the AI. -3. **Contextual Knowledge**: The AI reads `docs/pydoc_mhi_pscad_project.txt` to know that `run_status` returns a tuple, something a human might forget. -4. **Data Translation**: The binary results are translated into a JSON summary automatically. +For a typical run: -## Summary of Value -| Feature | Manual Scripting | MCP Server | +1. **Optional compile preflight — `build_project`**: compile the project without starting a simulation. The tool returns `built`, `errors`, and `warnings`. This is a blocking compile operation; its watchdog is disabled, so it can occupy the PSCAD worker until the build finishes. +2. **Start — `run_project`**: starts the build-and-run using PSCAD's non-blocking `Project.start()` and returns promptly. It first checks that PSCAD is licensed. +3. **Poll — `get_run_status`**: call periodically. Its high-level `state` is `building`, `running`, or `idle_or_finished`; progress is reported while running. +4. **Diagnose — `get_build_messages`**: if the project returns to idle without ever reporting `running`, inspect build errors and warnings. Also inspect project output/results when needed. The idle state alone does not distinguish successful completion from a run that never started or failed. + +Do not infer that a simulation was cancelled just because a client-side wait timed out. A timeout means the caller did not obtain completion within its wait; it does not establish that PSCAD stopped the operation. + +## 3. Watchdogs and long-running simulations + +The executor's watchdog is not a mechanism for making a blocking `project.run()` pattern safe. A blocking call that exceeds its watchdog can time out while occupying the single PSCAD worker, preventing other PSCAD commands from being serviced. Long simulations should use the non-blocking start-and-poll workflow instead. + +The `build_project` tool is intentionally different: it performs a blocking compile preflight with its watchdog disabled. Treat it as a potentially long-running operation and do not assume other PSCAD commands can run concurrently while it is building. + +## Summary of value + +| Feature | Traditional scripting | PowerMCP | | :--- | :--- | :--- | -| **Development Speed** | Slow (Coding required) | Instant (Conversational) | -| **Maintenance** | High (Scripts break on API update) | Low (AI adapts via `sync_documentation`) | -| **Accessibility** | Limited to Programmers | Available to all Power Engineers | -| **Stability** | Fragile (COM hangs) | Robust (Watchdogs & OS monitoring) | +| **Orchestration** | The script manages start, polling, and diagnostics | Separate tools expose start, status, and build diagnostics | +| **Progress** | The script polls PSCAD and decides what to report | `get_run_status` returns a high-level state and progress | +| **Failure diagnosis** | The script must inspect messages and output | `get_build_messages` provides structured errors and warnings | +| **Long operations** | The script must manage waits and recovery | Non-blocking `run_project` avoids waiting for the full run inside the start call | +| **Success detection** | Must distinguish completion from idle/failure | The agent must still inspect the observed lifecycle and diagnostics; idle alone is ambiguous | + +The server reduces orchestration work, but it does not make every idle status a successful run or make a timed-out blocking operation safe. From 7edb57adf66930a65c9ab8201a605435e766b5d4 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:40:27 +0530 Subject: [PATCH 2/3] feat(pscad): add run-to-completion workflow with progress --- PSCAD/pscad_mcp/tools/project_tools.py | 270 +++++++++++++++++++++---- 1 file changed, 231 insertions(+), 39 deletions(-) diff --git a/PSCAD/pscad_mcp/tools/project_tools.py b/PSCAD/pscad_mcp/tools/project_tools.py index f8b7f01e..a4c97e35 100644 --- a/PSCAD/pscad_mcp/tools/project_tools.py +++ b/PSCAD/pscad_mcp/tools/project_tools.py @@ -1,10 +1,14 @@ from typing import List, Dict, Any, Optional +import asyncio import os +import time + from mcp.server.mcpserver import MCPServer as FastMCP from pscad_mcp.core.connection_manager import pscad_manager from pscad_mcp.core.executor import robust_executor from pscad_mcp.utils.sandbox import checked_path + async def load_projects(filenames: List[str]) -> str: """Load projects or workspace into PSCAD.""" pscad = pscad_manager.pscad @@ -17,21 +21,19 @@ async def load_projects(filenames: List[str]) -> str: await robust_executor.run_safe(pscad.load, *abs_paths, _timeout=120) return f"Loaded: {', '.join(abs_paths)}" + async def list_projects() -> List[Dict[str, str]]: """List all projects in the workspace.""" pscad = pscad_manager.pscad return await robust_executor.run_safe(pscad.projects) + async def run_project(project_name: str) -> Dict[str, Any]: """Start a (build &) simulation for a given project, without blocking. - Uses the non-blocking ``Project.start()`` (focuses the project, then fires - the run) so this call returns immediately rather than holding the single - PSCAD worker for the entire multi-minute build+run. Poll ``get_run_status`` - to follow progress: it transitions ``building`` -> ``running`` -> - ``idle_or_finished``. If it returns to ``idle_or_finished`` without ever - reporting ``running``, the build failed -- call ``get_build_messages`` and - ``get_project_output`` to see why (e.g. a missing Fortran compiler). + Uses non-blocking ``Project.start()`` so it returns immediately rather + than holding the PSCAD worker for the entire simulation. Poll + ``get_run_status`` or use ``run_project_to_completion`` to wait. """ pscad = pscad_manager.pscad if not await robust_executor.run_safe(pscad.licensed): @@ -42,17 +44,208 @@ async def run_project(project_name: str) -> Dict[str, Any]: return { "started": True, "project": project_name, - "note": "Build+run started. Poll get_run_status until state is 'idle_or_finished'.", + "note": "Build+run started. Poll get_run_status or call run_project_to_completion.", } -async def build_project(project_name: str, clean: bool = True) -> Dict[str, Any]: - """Build (compile) a project without running it, and report any messages. - This is a blocking compile used as a pre-flight check: it surfaces compiler - and link errors (for example a missing/misconfigured Fortran compiler) - before committing to a long simulation. The watchdog is disabled for the - build, so no other PSCAD call can run until it finishes. +async def run_project_to_completion( + project_name: str, + timeout_seconds: float = 1800, + poll_interval_seconds: float = 1.0, +) -> Dict[str, Any]: + """Start a PSCAD build+simulation and poll it to a terminal outcome. + + Progress is reported through MCP logging while the project is building or + running. An idle state only counts as successful completion after this call + has observed the project running. If it returns idle before running, build + messages are inspected and the result is reported as failed or not_started, + never as a fabricated success. + + `timeout_seconds` is an end-to-end deadline. It intentionally does not + use the executor's watchdog timeout because legitimate PSCAD runs can take + many minutes. Tests can inject a smaller poll interval and mocked clock. """ + if timeout_seconds <= 0: + return { + "status": "failed", + "final_status": "invalid_timeout", + "project": project_name, + "message": "timeout_seconds must be greater than zero.", + } + if poll_interval_seconds <= 0: + return { + "status": "failed", + "final_status": "invalid_poll_interval", + "project": project_name, + "message": "poll_interval_seconds must be greater than zero.", + } + + pscad = pscad_manager.pscad + if not await robust_executor.run_safe(pscad.licensed): + return { + "status": "failed", + "final_status": "not_started", + "started": False, + "project": project_name, + "message": "PSCAD is not licensed.", + } + + project = await robust_executor.run_safe(pscad.project, project_name) + try: + await robust_executor.run_safe(project.start) + except Exception as exc: + return { + "status": "failed", + "final_status": "not_started", + "started": False, + "project": project_name, + "message": f"PSCAD could not start the project: {exc}", + } + + started_at = time.monotonic() + deadline = started_at + timeout_seconds + saw_running = False + last_progress = None + next_delay = poll_interval_seconds + delay_schedule = (1.0, 2.0, 5.0, 10.0) + + while time.monotonic() < deadline: + try: + status, progress = await robust_executor.run_safe( + project.run_status, _timeout=0 + ) + except Exception as exc: + return { + "status": "failed", + "final_status": "status_error", + "started": True, + "project": project_name, + "message": f"Unable to read PSCAD run status: {exc}", + "saw_running": saw_running, + } + + raw_status = str(status or "").strip() + normalized = raw_status.lower() + if normalized in {"run", "running"}: + state = "running" + saw_running = True + elif normalized in {"build", "building", "compile", "compiling"}: + state = "building" + else: + # PSCAD's non-active status is ambiguous: idle before the first + # running observation can mean a rejected build or a run that never + # started. Only after seeing running can this indicate completion. + if saw_running: + await _report_progress( + project_name, "completed", 100, "Simulation completed." + ) + return { + "status": "completed", + "final_status": "completed", + "started": True, + "project": project_name, + "progress": 100, + "raw_status": raw_status, + "elapsed_seconds": round(time.monotonic() - started_at, 3), + } + + messages = await _read_build_messages(project) + errors, warnings = _split_messages(messages) + if errors: + final_status = "build_failed" + message = "PSCAD returned to idle before simulation started and reported build errors." + else: + final_status = "not_started" + message = ( + "PSCAD returned to idle before simulation started; no build " + "errors were reported, so completion cannot be confirmed." + ) + await _report_progress(project_name, final_status, 0, message) + return { + "status": "failed", + "final_status": final_status, + "started": False, + "project": project_name, + "message": message, + "errors": errors, + "warnings": warnings, + "raw_status": raw_status, + "elapsed_seconds": round(time.monotonic() - started_at, 3), + } + + try: + progress_value = float(progress) if progress is not None else None + except (TypeError, ValueError): + progress_value = None + if progress_value is not None: + progress_value = max(0.0, min(100.0, progress_value)) + if progress_value != last_progress or state == "building": + await _report_progress( + project_name, state, progress_value, f"PSCAD {state}." + ) + last_progress = progress_value + + elapsed = time.monotonic() - started_at + remaining = deadline - time.monotonic() + if remaining <= 0: + break + # Back off while progress is unchanged; reset on progress/state changes. + if progress_value != last_progress: + next_delay = poll_interval_seconds + else: + next_delay = min( + next_delay if next_delay > 0 else poll_interval_seconds, + delay_schedule[-1], + ) + if next_delay < delay_schedule[-1]: + next_delay = delay_schedule[ + min(delay_schedule.index(next_delay) + 1, len(delay_schedule) - 1) + ] if next_delay in delay_schedule else min(next_delay * 2, delay_schedule[-1]) + await asyncio.sleep(min(next_delay, remaining)) + + return { + "status": "failed", + "final_status": "timeout", + "started": True, + "project": project_name, + "message": f"Simulation did not reach a terminal state within {timeout_seconds:g} seconds.", + "saw_running": saw_running, + "elapsed_seconds": round(time.monotonic() - started_at, 3), + } + + +async def _report_progress(project_name: str, state: str, progress: Optional[float], message: str) -> None: + """Stream progress to clients that support MCP logging; tolerate no logging session.""" + try: + from mcp.server.lowlevel.server import request_ctx + + ctx = request_ctx.get() + await ctx.session.send_log_message( + level="info", + data={ + "project": project_name, + "state": state, + "progress": progress, + "message": message, + }, + logger="pscad.run", + ) + except (LookupError, AttributeError, RuntimeError, ValueError): + # Direct calls and clients without a logging-capable session still get + # meaningful polling results; progress reporting must not break a run. + return + + +async def _read_build_messages(project: Any) -> List[Any]: + """Read build diagnostics after a run returns idle before starting.""" + try: + return await robust_executor.run_safe(project.messages, _timeout=0) or [] + except Exception: + return [] + + +async def build_project(project_name: str, clean: bool = True) -> Dict[str, Any]: + """Build (compile) a project without running it, and report any messages.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) builder = project.build if clean else project.build_modified @@ -65,13 +258,9 @@ async def build_project(project_name: str, clean: bool = True) -> Dict[str, Any] "warnings": warnings, } -async def get_run_status(project_name: str) -> Dict[str, Any]: - """Get simulation progress and a derived high-level state. - ``state`` is one of ``building`` (compiling), ``running`` (simulating, with - ``progress`` 0-100), or ``idle_or_finished`` (not building or running -- - i.e. completed, not yet started, or failed). - """ +async def get_run_status(project_name: str) -> Dict[str, Any]: + """Get simulation progress and a derived high-level state.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) status, progress = await robust_executor.run_safe(project.run_status) @@ -83,6 +272,7 @@ async def get_run_status(project_name: str) -> Dict[str, Any]: state = "idle_or_finished" return {"state": state, "raw_status": status, "progress": progress} + async def get_build_messages(project_name: str) -> Dict[str, Any]: """Return the load/build messages for a project, split into errors/warnings.""" pscad = pscad_manager.pscad @@ -92,6 +282,7 @@ async def get_build_messages(project_name: str) -> Dict[str, Any]: info_count = len(messages) - len(errors) - len(warnings) return {"errors": errors, "warnings": warnings, "info_count": info_count} + def _split_messages(messages: Any) -> tuple: """Split PSCAD build messages into (errors, warnings) lists of dicts.""" errors, warnings = [], [] @@ -108,17 +299,21 @@ def _split_messages(messages: Any) -> tuple: warnings.append(entry) return errors, warnings + async def find_components( - project_name: str, - definition: Optional[str] = None, - name: Optional[str] = None + project_name: str, + definition: Optional[str] = None, + name: Optional[str] = None, ) -> List[Dict[str, Any]]: """Find components matching criteria in a project.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) - components = await robust_executor.run_safe(project.find_all, definition=definition, name=name) + components = await robust_executor.run_safe( + project.find_all, definition=definition, name=name + ) return [{"id": c.id, "name": c.name, "definition": c.definition} for c in components] + async def get_component_parameters(project_name: str, component_id: int) -> Dict[str, Any]: """Get all parameter values for a specific component by its ID.""" pscad = pscad_manager.pscad @@ -127,6 +322,7 @@ async def get_component_parameters(project_name: str, component_id: int) -> Dict params = await robust_executor.run_safe(component.parameters) return params if params else {} + async def set_component_parameters(project_name: str, component_id: int, parameters: Dict[str, Any]) -> str: """Set parameter values for a specific component.""" pscad = pscad_manager.pscad @@ -135,50 +331,46 @@ async def set_component_parameters(project_name: str, component_id: int, paramet await robust_executor.run_safe(component.parameters, parameters=parameters) return f"Parameters updated for component {component_id}." + async def validate_component_parameters(project_name: str, component_id: int, parameters: Dict[str, Any]) -> Dict[str, Any]: """Validate if the given parameters are within the legal range for a component.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) component = await robust_executor.run_safe(project.component, component_id) - + validation_results = {} for param_name, value in parameters.items(): try: legal_range = await robust_executor.run_safe(component.range, param_name) validation_results[param_name] = {"valid": True, "range": str(legal_range)} - except Exception as e: - validation_results[param_name] = {"valid": False, "error": str(e)} - + except Exception as exc: + validation_results[param_name] = {"valid": False, "error": str(exc)} + return validation_results -async def get_project_settings(project_name: str) -> Dict[str, Any]: - """Get all settings (run parameters) for a project. - Includes keys such as ``time_step``, ``time_duration``, ``sample_step``, - ``PlotType`` ("NONE"/"OUT"/"PSOUT"), ``StartType``, ``SnapType`` and - ``MrunType``. - """ +async def get_project_settings(project_name: str) -> Dict[str, Any]: + """Get simulation run parameters for a project.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) settings = await robust_executor.run_safe(project.parameters) return settings if settings else {} -async def set_project_settings(project_name: str, settings: Dict[str, Any]) -> str: - """Update project settings (run parameters). - For example ``{"PlotType": "PSOUT"}`` to write a consolidated .psout output - file, or ``{"time_duration": 5.0}`` to change the run length. - """ +async def set_project_settings(project_name: str, settings: Dict[str, Any]) -> str: + """Update project settings.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) await robust_executor.run_safe(project.parameters, **settings) return f"Settings updated for project '{project_name}'." + def register_project_tools(mcp: FastMCP): """Register tools for managing projects and components.""" mcp.tool()(load_projects) mcp.tool()(list_projects) mcp.tool()(run_project) + mcp.tool()(run_project_to_completion) mcp.tool()(build_project) mcp.tool()(get_run_status) mcp.tool()(get_build_messages) From 1e48568fdea521bc30928e0e3579bf5440009e01 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:40:45 +0530 Subject: [PATCH 3/3] test(pscad): cover completion failure and timeout paths --- PSCAD/tests/test_tools.py | 142 ++++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 37 deletions(-) diff --git a/PSCAD/tests/test_tools.py b/PSCAD/tests/test_tools.py index 09b9a406..4e05b7a4 100644 --- a/PSCAD/tests/test_tools.py +++ b/PSCAD/tests/test_tools.py @@ -1,41 +1,45 @@ import asyncio -import unittest -from unittest.mock import MagicMock, patch import os +import unittest from pathlib import Path import tempfile -from pscad_mcp.tools.project_tools import register_project_tools, run_project, load_projects, find_components +from unittest.mock import AsyncMock, MagicMock, patch + +from mcp.server.mcpserver import MCPServer as FastMCP +from pscad_mcp.core.connection_manager import pscad_manager from pscad_mcp.tools.app_tools import register_app_tools, get_pscad_status, read_documentation from pscad_mcp.tools.data_tools import _resolve_psout -from pscad_mcp.core.connection_manager import pscad_manager +from pscad_mcp.tools.project_tools import ( + get_build_messages, + get_run_status, + load_projects, + find_components, + register_project_tools, + run_project, + run_project_to_completion, +) from pscad_mcp.utils.doc_manager import DocumentationManager -from mcp.server.mcpserver import MCPServer as FastMCP + class TestAllTools(unittest.IsolatedAsyncioTestCase): - """ - Comprehensive tool logic testing covering edge and error cases. - """ + """Comprehensive tool logic testing covering edge and error cases.""" async def asyncSetUp(self): self.mcp = FastMCP("Test") - # Registering tools is not strictly necessary for unit tests if we call functions directly, - # but it validates registration logic. register_project_tools(self.mcp) register_app_tools(self.mcp) - # Mock the PSCAD instance for all tests self.mock_pscad = MagicMock() pscad_manager._pscad = self.mock_pscad - # Mock OS check - self.os_patcher = patch('pscad_mcp.core.connection_manager.PSCADConnectionManager.is_process_running', return_value=True) + self.os_patcher = patch( + "pscad_mcp.core.connection_manager.PSCADConnectionManager.is_process_running", + return_value=True, + ) self.os_patcher.start() async def asyncTearDown(self): self.os_patcher.stop() - # --- Connection Tools --- - async def test_get_status_unresponsive(self): - """Edge case: PSCAD is running but RMI call fails.""" self.mock_pscad.is_busy.side_effect = Exception("COM Error") result = await get_pscad_status() self.assertEqual(result["connected"], False) @@ -45,9 +49,8 @@ async def test_documentation_name_is_not_a_path(self): self.assertIn("dotted ASCII Python module name", result) async def test_documentation_manager_does_not_write_at_import(self): - with self.subTest("constructor is read-only"): - manager = DocumentationManager("missing/docs") - self.assertFalse(os.path.exists(manager.base_dir)) + manager = DocumentationManager("missing/docs") + self.assertFalse(os.path.exists(manager.base_dir)) @unittest.skipIf(os.name == "nt", "POSIX symlink semantics") async def test_out_sibling_psout_is_checked_after_selection(self): @@ -60,41 +63,106 @@ async def test_out_sibling_psout_is_checked_after_selection(self): outside = root / "outside.psout" outside.write_text("output") (allowed / "run.psout").symlink_to(outside) - - with patch.dict( - os.environ, {"POWERIO_MCP_ALLOWED_ROOTS": str(allowed)}, clear=False - ): + with patch.dict(os.environ, {"POWERIO_MCP_ALLOWED_ROOTS": str(allowed)}, clear=False): with self.assertRaisesRegex(ValueError, "outside allowed MCP roots"): await _resolve_psout(str(legacy)) - # --- Project Tools --- - async def test_load_nonexistent_project(self): - """Edge case: Loading a file that doesn't exist on disk.""" self.mock_pscad.load.side_effect = FileNotFoundError("File not found") - with self.assertRaises(Exception): - await load_projects(filenames=["C:\\missing.pscx"]) + with self.assertRaises(Exception): + await load_projects(filenames=["C:\\missing.pscx"]) async def test_run_unlicensed_project(self): - """Edge case: Attempting simulation without a valid license.""" self.mock_pscad.licensed.return_value = False result = await run_project(project_name="test") self.assertFalse(result["started"]) self.assertIn("not licensed", result["error"]) async def test_find_no_components(self): - """Edge case: Searching for components that don't exist.""" - mock_prj = MagicMock() - mock_prj.find_all.return_value = [] - self.mock_pscad.project.return_value = mock_prj - result = await find_components(project_name="test", name="Ghost") - self.assertEqual(len(result), 0) + self.mock_pscad.project.return_value.find_all.return_value = [] + self.assertEqual(await find_components(project_name="test", name="Ghost"), []) async def test_invalid_project_name(self): - """Edge case: Using a project name that isn't loaded.""" self.mock_pscad.project.side_effect = Exception("Project not found") with self.assertRaises(Exception): - await run_project(project_name="unknown") + await run_project(project_name="unknown") + + +class TestRunProjectToCompletion(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.pscad = MagicMock() + self.project = MagicMock() + pscad_manager._pscad = self.pscad + self.pscad.licensed.return_value = True + self.pscad.project.return_value = self.project + self.project.start = MagicMock() + self.project.messages.return_value = [] + self.progress = patch( + "pscad_mcp.tools.project_tools._report_progress", + new=AsyncMock(), + ) + self.progress.start() + + async def asyncTearDown(self): + self.progress.stop() + + async def test_completion_requires_observing_running(self): + self.project.run_status.side_effect = [ + ("Build", 0), + ("Run", 25), + ("Run", 100), + (None, 100), + ] + with patch("pscad_mcp.tools.project_tools.asyncio.sleep", new=AsyncMock()): + result = await run_project_to_completion("case", timeout_seconds=5, poll_interval_seconds=0.001) + self.assertEqual(result["final_status"], "completed") + self.assertEqual(result["progress"], 100) + self.project.start.assert_called_once() + + async def test_compilation_error_never_claims_completion(self): + error = MagicMock(status="Error", text="Fortran compiler missing", name="Compiler") + self.project.run_status.return_value = (None, 0) + self.project.messages.return_value = [error] + result = await run_project_to_completion("case", timeout_seconds=2, poll_interval_seconds=0.001) + self.assertEqual(result["final_status"], "build_failed") + self.assertEqual(result["status"], "failed") + self.assertIn("Fortran compiler missing", result["errors"][0]["text"]) + + async def test_idle_without_errors_is_not_reported_as_completed(self): + self.project.run_status.return_value = ("Idle", 0) + result = await run_project_to_completion("case", timeout_seconds=2, poll_interval_seconds=0.001) + self.assertEqual(result["final_status"], "not_started") + self.assertNotEqual(result["status"], "completed") + + async def test_timeout_is_explicit(self): + self.project.run_status.return_value = ("Run", 20) + with patch("pscad_mcp.tools.project_tools.time.monotonic", side_effect=[0, 0, 2, 2, 2, 2, 2]): + with patch("pscad_mcp.tools.project_tools.asyncio.sleep", new=AsyncMock()): + result = await run_project_to_completion("case", timeout_seconds=1, poll_interval_seconds=0.001) + self.assertEqual(result["final_status"], "timeout") + self.assertEqual(result["status"], "failed") + + async def test_start_exception_means_not_started(self): + self.project.start.side_effect = RuntimeError("PSCAD rejected start") + result = await run_project_to_completion("case", timeout_seconds=1) + self.assertEqual(result["final_status"], "not_started") + self.assertFalse(result["started"]) + + async def test_unlicensed_is_not_started(self): + self.pscad.licensed.return_value = False + result = await run_project_to_completion("case") + self.assertEqual(result["final_status"], "not_started") + self.assertFalse(result["started"]) + + async def test_invalid_timeout_rejected(self): + result = await run_project_to_completion("case", timeout_seconds=0) + self.assertEqual(result["final_status"], "invalid_timeout") + + async def test_status_query_failure_is_failure_not_success(self): + self.project.run_status.side_effect = RuntimeError("RMI unavailable") + result = await run_project_to_completion("case", timeout_seconds=1) + self.assertEqual(result["final_status"], "status_error") + if __name__ == "__main__": unittest.main()