|
@@ -0,0 +1,158 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: ascii -*-
|
|
|
|
|
+"""Tests for web-side task cancellation in the executor point loop.
|
|
|
|
|
+
|
|
|
|
|
+Bug context (2026-09-04): the PlanDetail stop button never called the
|
|
|
|
|
+backend cancel API, and the executor never polled task status between
|
|
|
|
|
+points, so a "stopped" scan kept consuming Motor-CAD time. These tests
|
|
|
|
|
+cover the fix:
|
|
|
|
|
+ 1. _is_task_cancelled returns False without requests (local mode).
|
|
|
|
|
+ 2. _is_task_cancelled returns False on network/backend errors.
|
|
|
|
|
+ 3. execute_task aborts between points when the web reports 'cancelled'
|
|
|
|
|
+ and reports the partial results with status 'cancelled'.
|
|
|
|
|
+
|
|
|
|
|
+Note: the module-level `requests` in task_executor may be None in this
|
|
|
|
|
+dev environment (requests not installed), so tests patch the module
|
|
|
|
|
+attribute itself rather than requests.get.
|
|
|
|
|
+
|
|
|
|
|
+Run: python scripts/test_executor_cancel.py (exit 0 = PASS)
|
|
|
|
|
+"""
|
|
|
|
|
+import os
|
|
|
|
|
+import sys
|
|
|
|
|
+import tempfile
|
|
|
|
|
+import unittest
|
|
|
|
|
+from unittest.mock import MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
+
|
|
|
|
|
+import task_executor # noqa: E402
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class FakeResponse:
|
|
|
|
|
+ def __init__(self, status_code=200, payload=None):
|
|
|
|
|
+ self.status_code = status_code
|
|
|
|
|
+ self._payload = payload or {}
|
|
|
|
|
+
|
|
|
|
|
+ def json(self):
|
|
|
|
|
+ return self._payload
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def make_executor(**kwargs):
|
|
|
|
|
+ kwargs.setdefault("web_base_url", "http://web.test")
|
|
|
|
|
+ kwargs.setdefault("enable_mock", True)
|
|
|
|
|
+ kwargs.setdefault("task_dir", tempfile.mkdtemp())
|
|
|
|
|
+ return task_executor.TaskExecutor(**kwargs)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def make_task(n_points=5):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "task_id": "t-cancel-1",
|
|
|
|
|
+ "task_name": "cancel-test",
|
|
|
|
|
+ "status": "dispatched",
|
|
|
|
|
+ "parameters": [{"x": i} for i in range(n_points)],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def mock_requests(get_side_effect=None):
|
|
|
|
|
+ """Build a mock `requests` module with a controllable get()."""
|
|
|
|
|
+ m = MagicMock()
|
|
|
|
|
+ if get_side_effect is not None:
|
|
|
|
|
+ m.get.side_effect = get_side_effect
|
|
|
|
|
+ return m
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestIsTaskCancelled(unittest.TestCase):
|
|
|
|
|
+ def test_local_mode_never_cancelled(self):
|
|
|
|
|
+ ex = make_executor()
|
|
|
|
|
+ with patch.object(task_executor, "requests", None):
|
|
|
|
|
+ self.assertFalse(ex._is_task_cancelled("t1"))
|
|
|
|
|
+
|
|
|
|
|
+ def test_network_error_returns_false(self):
|
|
|
|
|
+ ex = make_executor()
|
|
|
|
|
+ m = mock_requests()
|
|
|
|
|
+ m.get.side_effect = Exception("boom")
|
|
|
|
|
+ with patch.object(task_executor, "requests", m):
|
|
|
|
|
+ self.assertFalse(ex._is_task_cancelled("t1"))
|
|
|
|
|
+
|
|
|
|
|
+ def test_cancelled_status_returns_true(self):
|
|
|
|
|
+ ex = make_executor()
|
|
|
|
|
+ m = mock_requests()
|
|
|
|
|
+ m.get.return_value = FakeResponse(200, {"status": "cancelled"})
|
|
|
|
|
+ with patch.object(task_executor, "requests", m):
|
|
|
|
|
+ self.assertTrue(ex._is_task_cancelled("t1"))
|
|
|
|
|
+
|
|
|
|
|
+ def test_running_status_returns_false(self):
|
|
|
|
|
+ ex = make_executor()
|
|
|
|
|
+ m = mock_requests()
|
|
|
|
|
+ m.get.return_value = FakeResponse(200, {"status": "running"})
|
|
|
|
|
+ with patch.object(task_executor, "requests", m):
|
|
|
|
|
+ self.assertFalse(ex._is_task_cancelled("t1"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestExecuteTaskCancellation(unittest.TestCase):
|
|
|
|
|
+ def test_aborts_mid_run_and_reports_cancelled(self):
|
|
|
|
|
+ """Cancel after the first point: only 1 point runs, status=cancelled."""
|
|
|
|
|
+ ex = make_executor()
|
|
|
|
|
+ task = make_task(n_points=5)
|
|
|
|
|
+ run_params = []
|
|
|
|
|
+
|
|
|
|
|
+ orig_run = ex._run_simulation_point
|
|
|
|
|
+
|
|
|
|
|
+ def spy_run(params, index):
|
|
|
|
|
+ run_params.append(index)
|
|
|
|
|
+ return orig_run(params, index)
|
|
|
|
|
+
|
|
|
|
|
+ ex._run_simulation_point = spy_run
|
|
|
|
|
+
|
|
|
|
|
+ # GET sequence: (1) _hydrate_task downloads the task payload, (2)
|
|
|
|
|
+ # status check before point 0 -> running, (3) check before point 1 ->
|
|
|
|
|
+ # cancelled -> exactly one point executes.
|
|
|
|
|
+ m = mock_requests()
|
|
|
|
|
+ m.get.side_effect = [
|
|
|
|
|
+ FakeResponse(200, {}), # hydrate: no payload, keep local params
|
|
|
|
|
+ FakeResponse(200, {"status": "running"}),
|
|
|
|
|
+ FakeResponse(200, {"status": "cancelled"}),
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ reported = {}
|
|
|
|
|
+
|
|
|
|
|
+ def fake_report_results(task_id, results, metrics, logs, duration, status):
|
|
|
|
|
+ reported["status"] = status
|
|
|
|
|
+ reported["n_results"] = len(results)
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(task_executor, "requests", m), \
|
|
|
|
|
+ patch.object(ex, "report_results", side_effect=fake_report_results), \
|
|
|
|
|
+ patch.object(ex, "report_progress", return_value=True):
|
|
|
|
|
+ ex.execute_task(task)
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(run_params, [0], "only the first point should run")
|
|
|
|
|
+ self.assertEqual(reported["status"], "cancelled")
|
|
|
|
|
+ self.assertEqual(reported["n_results"], 1)
|
|
|
|
|
+ self.assertTrue(ex._task_cancelled)
|
|
|
|
|
+
|
|
|
|
|
+ def test_not_cancelled_runs_all_points(self):
|
|
|
|
|
+ ex = make_executor()
|
|
|
|
|
+ task = make_task(n_points=3)
|
|
|
|
|
+ reported = {}
|
|
|
|
|
+
|
|
|
|
|
+ def fake_report_results(task_id, results, metrics, logs, duration, status):
|
|
|
|
|
+ reported["status"] = status
|
|
|
|
|
+ reported["n_results"] = len(results)
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ m = mock_requests()
|
|
|
|
|
+ m.get.return_value = FakeResponse(200, {"status": "running"})
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(task_executor, "requests", m), \
|
|
|
|
|
+ patch.object(ex, "report_results", side_effect=fake_report_results), \
|
|
|
|
|
+ patch.object(ex, "report_progress", return_value=True):
|
|
|
|
|
+ ex.execute_task(task)
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(reported["status"], "completed")
|
|
|
|
|
+ self.assertEqual(reported["n_results"], 3)
|
|
|
|
|
+ self.assertFalse(ex._task_cancelled)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ unittest.main(verbosity=2)
|