test_printer_offline_notification.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. """Tests for the connected → disconnected edge that fires the
  2. `on_printer_offline` notification (#1752).
  3. The provider toggle, schema, and dispatcher already existed; what was missing
  4. was a caller that fires `notification_service.on_printer_offline` when a
  5. printer goes offline. These tests pin both layers:
  6. * `_maybe_notify_printer_offline` — the debounced background task. Must
  7. fire when the printer is still offline at the end of the window, and
  8. must NOT fire if the printer reconnected during the window.
  9. * Edge detection inside `on_printer_status_change` — schedules the task
  10. only on the True → False transition, cancels any pending task on
  11. reconnect, and stays silent on startup (no prior connected state).
  12. """
  13. import asyncio
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app import main as main_module
  18. def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
  19. """Minimal PrinterState stub. `state="IDLE"` keeps the reconcile-edge
  20. branch quiescent (it only fires on `connected=True` with a non-unknown
  21. state-string, which we exercise separately) but otherwise lets the
  22. handler thread through without doing extra DB / WS work."""
  23. return SimpleNamespace(
  24. connected=connected,
  25. state=state,
  26. progress=0,
  27. layer_num=0,
  28. temperatures={},
  29. raw_data={},
  30. stg_cur=0,
  31. cooling_fan_speed=0,
  32. big_fan1_speed=0,
  33. big_fan2_speed=0,
  34. chamber_light="",
  35. active_extruder=0,
  36. tray_now=0,
  37. door_open=False,
  38. subtask_name="",
  39. ams_filament_backup=None,
  40. )
  41. @pytest.fixture(autouse=True)
  42. def _reset_edge_state():
  43. """Clear the module-level edge dicts between tests so one test's
  44. True-edge doesn't leak into the next."""
  45. main_module._printer_last_connected.clear()
  46. for task in list(main_module._printer_offline_notify_tasks.values()):
  47. if not task.done():
  48. task.cancel()
  49. main_module._printer_offline_notify_tasks.clear()
  50. main_module._printer_reconciled_since_connect.clear()
  51. main_module._last_status_broadcast.clear()
  52. yield
  53. main_module._printer_last_connected.clear()
  54. for task in list(main_module._printer_offline_notify_tasks.values()):
  55. if not task.done():
  56. task.cancel()
  57. main_module._printer_offline_notify_tasks.clear()
  58. class TestMaybeNotifyPrinterOffline:
  59. """The debounced background task — fires notification at the end of the
  60. window only if the printer is still offline."""
  61. @pytest.mark.asyncio
  62. async def test_fires_notification_when_still_offline_after_debounce(self):
  63. printer = SimpleNamespace(id=1, name="Workshop")
  64. scalar = MagicMock()
  65. scalar.scalar_one_or_none.return_value = printer
  66. db = AsyncMock()
  67. db.execute = AsyncMock(return_value=scalar)
  68. session_cm = MagicMock()
  69. session_cm.__aenter__ = AsyncMock(return_value=db)
  70. session_cm.__aexit__ = AsyncMock(return_value=False)
  71. with (
  72. patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
  73. patch("backend.app.main.printer_manager") as mock_pm,
  74. patch("backend.app.main.async_session", return_value=session_cm),
  75. patch("backend.app.main.notification_service") as mock_notif,
  76. ):
  77. mock_pm.is_connected.return_value = False
  78. mock_notif.on_printer_offline = AsyncMock()
  79. await main_module._maybe_notify_printer_offline(printer_id=1)
  80. mock_notif.on_printer_offline.assert_awaited_once_with(1, "Workshop", db)
  81. @pytest.mark.asyncio
  82. async def test_does_not_fire_when_printer_reconnected_during_debounce(self):
  83. with (
  84. patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
  85. patch("backend.app.main.printer_manager") as mock_pm,
  86. patch("backend.app.main.notification_service") as mock_notif,
  87. ):
  88. mock_pm.is_connected.return_value = True
  89. mock_notif.on_printer_offline = AsyncMock()
  90. await main_module._maybe_notify_printer_offline(printer_id=1)
  91. mock_notif.on_printer_offline.assert_not_awaited()
  92. @pytest.mark.asyncio
  93. async def test_does_not_fire_when_printer_missing_from_db(self):
  94. scalar = MagicMock()
  95. scalar.scalar_one_or_none.return_value = None
  96. db = AsyncMock()
  97. db.execute = AsyncMock(return_value=scalar)
  98. session_cm = MagicMock()
  99. session_cm.__aenter__ = AsyncMock(return_value=db)
  100. session_cm.__aexit__ = AsyncMock(return_value=False)
  101. with (
  102. patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
  103. patch("backend.app.main.printer_manager") as mock_pm,
  104. patch("backend.app.main.async_session", return_value=session_cm),
  105. patch("backend.app.main.notification_service") as mock_notif,
  106. ):
  107. mock_pm.is_connected.return_value = False
  108. mock_notif.on_printer_offline = AsyncMock()
  109. await main_module._maybe_notify_printer_offline(printer_id=1)
  110. mock_notif.on_printer_offline.assert_not_awaited()
  111. @pytest.mark.asyncio
  112. async def test_clears_task_entry_after_run(self):
  113. with (
  114. patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
  115. patch("backend.app.main.printer_manager") as mock_pm,
  116. ):
  117. mock_pm.is_connected.return_value = True # No notification path
  118. main_module._printer_offline_notify_tasks[1] = MagicMock()
  119. await main_module._maybe_notify_printer_offline(printer_id=1)
  120. assert 1 not in main_module._printer_offline_notify_tasks
  121. class TestOfflineEdgeDetection:
  122. """Edge detection inside `on_printer_status_change` — only the
  123. True → False transition schedules a task. Reconnects cancel pending
  124. tasks. Startup-with-disconnected does not fire."""
  125. @staticmethod
  126. def _patch_handler_deps():
  127. """Patch out the heavy side-effects of `on_printer_status_change`
  128. (MQTT relay, WebSocket broadcast, state serializer) so we can focus
  129. on edge state."""
  130. ws_mgr = MagicMock()
  131. ws_mgr.send_printer_status = AsyncMock()
  132. relay = MagicMock()
  133. relay.on_printer_status = AsyncMock()
  134. pm = MagicMock()
  135. pm.get_printer.return_value = None # Skip the relay payload branch.
  136. pm.get_model.return_value = ""
  137. return ws_mgr, relay, pm
  138. @pytest.mark.asyncio
  139. async def test_first_call_connected_does_not_schedule(self):
  140. ws_mgr, relay, pm = self._patch_handler_deps()
  141. with (
  142. patch("backend.app.main.ws_manager", ws_mgr),
  143. patch("backend.app.main.mqtt_relay", relay),
  144. patch("backend.app.main.printer_manager", pm),
  145. patch("backend.app.main.spawn_background_task"),
  146. patch("backend.app.main.printer_state_to_dict", return_value={}),
  147. ):
  148. await main_module.on_printer_status_change(1, _state(connected=True))
  149. assert 1 not in main_module._printer_offline_notify_tasks
  150. assert main_module._printer_last_connected[1] is True
  151. @pytest.mark.asyncio
  152. async def test_first_call_disconnected_does_not_schedule(self):
  153. """Startup with an already-offline printer must not fire — there's
  154. no prior True observation, so we have no edge to trigger on."""
  155. ws_mgr, relay, pm = self._patch_handler_deps()
  156. with (
  157. patch("backend.app.main.ws_manager", ws_mgr),
  158. patch("backend.app.main.mqtt_relay", relay),
  159. patch("backend.app.main.printer_manager", pm),
  160. patch("backend.app.main.spawn_background_task"),
  161. patch("backend.app.main.printer_state_to_dict", return_value={}),
  162. ):
  163. await main_module.on_printer_status_change(1, _state(connected=False))
  164. assert 1 not in main_module._printer_offline_notify_tasks
  165. assert main_module._printer_last_connected[1] is False
  166. @pytest.mark.asyncio
  167. async def test_connected_to_disconnected_schedules_task(self):
  168. ws_mgr, relay, pm = self._patch_handler_deps()
  169. with (
  170. patch("backend.app.main.ws_manager", ws_mgr),
  171. patch("backend.app.main.mqtt_relay", relay),
  172. patch("backend.app.main.printer_manager", pm),
  173. patch("backend.app.main.spawn_background_task"),
  174. patch("backend.app.main._maybe_notify_printer_offline", new=AsyncMock()),
  175. patch("backend.app.main.printer_state_to_dict", return_value={}),
  176. ):
  177. await main_module.on_printer_status_change(1, _state(connected=True))
  178. await main_module.on_printer_status_change(1, _state(connected=False))
  179. task = main_module._printer_offline_notify_tasks.get(1)
  180. assert task is not None
  181. task.cancel()
  182. @pytest.mark.asyncio
  183. async def test_reconnect_cancels_pending_task(self):
  184. ws_mgr, relay, pm = self._patch_handler_deps()
  185. with (
  186. patch("backend.app.main.ws_manager", ws_mgr),
  187. patch("backend.app.main.mqtt_relay", relay),
  188. patch("backend.app.main.printer_manager", pm),
  189. patch("backend.app.main.spawn_background_task"),
  190. patch("backend.app.main._maybe_notify_printer_offline", new=AsyncMock()),
  191. patch("backend.app.main.printer_state_to_dict", return_value={}),
  192. ):
  193. await main_module.on_printer_status_change(1, _state(connected=True))
  194. await main_module.on_printer_status_change(1, _state(connected=False))
  195. scheduled = main_module._printer_offline_notify_tasks.get(1)
  196. assert scheduled is not None
  197. await main_module.on_printer_status_change(1, _state(connected=True))
  198. # Yield so the cancellation propagates through the event loop.
  199. await asyncio.sleep(0)
  200. assert 1 not in main_module._printer_offline_notify_tasks
  201. assert scheduled.cancelled() or scheduled.done()
  202. @pytest.mark.asyncio
  203. async def test_repeated_disconnected_does_not_reschedule(self):
  204. """A second False observation while a task is already pending must
  205. not replace the in-flight task — otherwise the debounce clock
  206. resets on every status callback and the notification never fires."""
  207. ws_mgr, relay, pm = self._patch_handler_deps()
  208. with (
  209. patch("backend.app.main.ws_manager", ws_mgr),
  210. patch("backend.app.main.mqtt_relay", relay),
  211. patch("backend.app.main.printer_manager", pm),
  212. patch("backend.app.main.spawn_background_task"),
  213. patch("backend.app.main._maybe_notify_printer_offline", new=AsyncMock()),
  214. patch("backend.app.main.printer_state_to_dict", return_value={}),
  215. ):
  216. await main_module.on_printer_status_change(1, _state(connected=True))
  217. await main_module.on_printer_status_change(1, _state(connected=False))
  218. first_task = main_module._printer_offline_notify_tasks.get(1)
  219. await main_module.on_printer_status_change(1, _state(connected=False))
  220. second_task = main_module._printer_offline_notify_tasks.get(1)
  221. assert first_task is second_task
  222. if first_task is not None:
  223. first_task.cancel()