test_executor_cancel.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. #!/usr/bin/env python3
  2. # -*- coding: ascii -*-
  3. """Tests for web-side task cancellation in the executor point loop.
  4. Bug context (2026-09-04): the PlanDetail stop button never called the
  5. backend cancel API, and the executor never polled task status between
  6. points, so a "stopped" scan kept consuming Motor-CAD time. These tests
  7. cover the fix:
  8. 1. _is_task_cancelled returns False without requests (local mode).
  9. 2. _is_task_cancelled returns False on network/backend errors.
  10. 3. execute_task aborts between points when the web reports 'cancelled'
  11. and reports the partial results with status 'cancelled'.
  12. Note: the module-level `requests` in task_executor may be None in this
  13. dev environment (requests not installed), so tests patch the module
  14. attribute itself rather than requests.get.
  15. Run: python scripts/test_executor_cancel.py (exit 0 = PASS)
  16. """
  17. import os
  18. import sys
  19. import tempfile
  20. import unittest
  21. from unittest.mock import MagicMock, patch
  22. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  23. import task_executor # noqa: E402
  24. class FakeResponse:
  25. def __init__(self, status_code=200, payload=None):
  26. self.status_code = status_code
  27. self._payload = payload or {}
  28. def json(self):
  29. return self._payload
  30. def make_executor(**kwargs):
  31. kwargs.setdefault("web_base_url", "http://web.test")
  32. kwargs.setdefault("enable_mock", True)
  33. kwargs.setdefault("task_dir", tempfile.mkdtemp())
  34. return task_executor.TaskExecutor(**kwargs)
  35. def make_task(n_points=5):
  36. return {
  37. "task_id": "t-cancel-1",
  38. "task_name": "cancel-test",
  39. "status": "dispatched",
  40. "parameters": [{"x": i} for i in range(n_points)],
  41. }
  42. def mock_requests(get_side_effect=None):
  43. """Build a mock `requests` module with a controllable get()."""
  44. m = MagicMock()
  45. if get_side_effect is not None:
  46. m.get.side_effect = get_side_effect
  47. return m
  48. class TestIsTaskCancelled(unittest.TestCase):
  49. def test_local_mode_never_cancelled(self):
  50. ex = make_executor()
  51. with patch.object(task_executor, "requests", None):
  52. self.assertFalse(ex._is_task_cancelled("t1"))
  53. def test_network_error_returns_false(self):
  54. ex = make_executor()
  55. m = mock_requests()
  56. m.get.side_effect = Exception("boom")
  57. with patch.object(task_executor, "requests", m):
  58. self.assertFalse(ex._is_task_cancelled("t1"))
  59. def test_cancelled_status_returns_true(self):
  60. ex = make_executor()
  61. m = mock_requests()
  62. m.get.return_value = FakeResponse(200, {"status": "cancelled"})
  63. with patch.object(task_executor, "requests", m):
  64. self.assertTrue(ex._is_task_cancelled("t1"))
  65. def test_running_status_returns_false(self):
  66. ex = make_executor()
  67. m = mock_requests()
  68. m.get.return_value = FakeResponse(200, {"status": "running"})
  69. with patch.object(task_executor, "requests", m):
  70. self.assertFalse(ex._is_task_cancelled("t1"))
  71. class TestExecuteTaskCancellation(unittest.TestCase):
  72. def test_aborts_mid_run_and_reports_cancelled(self):
  73. """Cancel after the first point: only 1 point runs, status=cancelled."""
  74. ex = make_executor()
  75. task = make_task(n_points=5)
  76. run_params = []
  77. orig_run = ex._run_simulation_point
  78. def spy_run(params, index):
  79. run_params.append(index)
  80. return orig_run(params, index)
  81. ex._run_simulation_point = spy_run
  82. # GET sequence: (1) _hydrate_task downloads the task payload, (2)
  83. # status check before point 0 -> running, (3) check before point 1 ->
  84. # cancelled -> exactly one point executes.
  85. m = mock_requests()
  86. m.get.side_effect = [
  87. FakeResponse(200, {}), # hydrate: no payload, keep local params
  88. FakeResponse(200, {"status": "running"}),
  89. FakeResponse(200, {"status": "cancelled"}),
  90. ]
  91. reported = {}
  92. def fake_report_results(task_id, results, metrics, logs, duration, status):
  93. reported["status"] = status
  94. reported["n_results"] = len(results)
  95. return True
  96. with patch.object(task_executor, "requests", m), \
  97. patch.object(ex, "report_results", side_effect=fake_report_results), \
  98. patch.object(ex, "report_progress", return_value=True):
  99. ex.execute_task(task)
  100. self.assertEqual(run_params, [0], "only the first point should run")
  101. self.assertEqual(reported["status"], "cancelled")
  102. self.assertEqual(reported["n_results"], 1)
  103. self.assertTrue(ex._task_cancelled)
  104. def test_not_cancelled_runs_all_points(self):
  105. ex = make_executor()
  106. task = make_task(n_points=3)
  107. reported = {}
  108. def fake_report_results(task_id, results, metrics, logs, duration, status):
  109. reported["status"] = status
  110. reported["n_results"] = len(results)
  111. return True
  112. m = mock_requests()
  113. m.get.return_value = FakeResponse(200, {"status": "running"})
  114. with patch.object(task_executor, "requests", m), \
  115. patch.object(ex, "report_results", side_effect=fake_report_results), \
  116. patch.object(ex, "report_progress", return_value=True):
  117. ex.execute_task(task)
  118. self.assertEqual(reported["status"], "completed")
  119. self.assertEqual(reported["n_results"], 3)
  120. self.assertFalse(ex._task_cancelled)
  121. if __name__ == "__main__":
  122. unittest.main(verbosity=2)