test_printer_manager_status_broadcast.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Regression tests for ``PrinterManager._broadcast_status_change`` and
  2. its wiring from ``set_awaiting_plate_clear`` (#1128).
  3. The bug: ``awaiting_plate_clear`` is a Bambuddy-side flag, so toggling it
  4. doesn't produce an MQTT push from the printer. Before the fix,
  5. ``set_awaiting_plate_clear()`` mutated state and persisted to DB but never
  6. notified WebSocket subscribers. The plate-clear button on the printer card
  7. disappeared "immediately" only because of an optimistic React Query cache
  8. update on the click path; any other caller (admin script, second tab, an
  9. automation that hits ``POST /printers/{id}/clear-plate``) silently left
  10. the UI stale until the next coincidental status refresh.
  11. These tests pin the contract: every flip of the flag must schedule a
  12. ``printer_status`` broadcast, and the broadcast must carry the new flag
  13. value so subscribers see the right state without polling.
  14. """
  15. from __future__ import annotations
  16. import asyncio
  17. from types import SimpleNamespace
  18. from unittest.mock import AsyncMock, MagicMock, patch
  19. import pytest
  20. from backend.app.services.printer_manager import PrinterManager
  21. @pytest.fixture
  22. def manager():
  23. """Fresh manager per test; the awaiting-plate-clear set is per-instance."""
  24. return PrinterManager()
  25. def _close_unawaited(coro):
  26. """Side effect for mocked ``_schedule_async``.
  27. ``set_awaiting_plate_clear`` evaluates the coroutine expressions
  28. ``self._persist_awaiting_plate_clear(...)`` and
  29. ``self._broadcast_status_change(...)`` before passing them to
  30. ``_schedule_async``. When that target is patched, the coroutine objects
  31. leak — Python's ``__del__`` then emits ``coroutine was never awaited``
  32. during GC, and when GC runs late enough that warning hits the interpreter
  33. shutdown path with ``KeyError: '__import__'``. Closing the coroutine here
  34. prevents both. Returns ``None`` so the mock's call signature is unchanged.
  35. """
  36. if asyncio.iscoroutine(coro):
  37. coro.close()
  38. return None
  39. def _fake_state(**overrides):
  40. """Stand-in for a ``PrinterState``.
  41. The tests below patch ``printer_state_to_dict`` so the fake doesn't need
  42. to satisfy every attribute access — but the patch was observed to race on
  43. parallel CI runners (pytest-xdist), and when it didn't catch the call the
  44. real ``printer_state_to_dict`` ran against this fake and ``AttributeError``'d
  45. on ``.kprofiles``. The fake now carries every attribute the real function
  46. reads, so it remains correct even if the patch is somehow bypassed — the
  47. test no longer depends on a fragile monkeypatch landing in time.
  48. Iterables (``kprofiles``, ``printable_objects``, ``hms_errors``,
  49. ``temperatures``, etc.) default to empty so the function's loops are
  50. no-ops; scalars default to ``None`` so any "if state.x is None" guard
  51. falls through cleanly.
  52. """
  53. base = {
  54. # State the existing test bodies explicitly set / read
  55. "connected": True,
  56. "state": "FINISH",
  57. "raw_data": {},
  58. "progress": 100.0,
  59. # Iterables — must be iterable for the loops inside printer_state_to_dict
  60. "kprofiles": [],
  61. "printable_objects": [],
  62. "hms_errors": [],
  63. "temperatures": {},
  64. "nozzle_rack": [],
  65. # Nullable scalars — printer_state_to_dict tolerates None for these
  66. "active_extruder": None,
  67. "ams_status_main": None,
  68. "ams_status_sub": None,
  69. "big_fan1_speed": None,
  70. "big_fan2_speed": None,
  71. "chamber_light": None,
  72. "cooling_fan_speed": None,
  73. "current_print": None,
  74. "door_open": None,
  75. "firmware_version": None,
  76. "gcode_file": None,
  77. "heatbreak_fan_speed": None,
  78. "left_aux_fan_speed": None,
  79. "exhaust_fan_present": False,
  80. "layer_num": None,
  81. "remaining_time": None,
  82. "speed_level": None,
  83. "stg_cur": 0, # get_derived_status_name does ``0 <= state.stg_cur < 255``
  84. "subtask_name": None,
  85. "total_layers": None,
  86. "tray_now": None,
  87. "wifi_signal": None,
  88. "wired_network": None,
  89. "ams_filament_backup": None,
  90. # Filament Track Switch. None means "no accessory", which is what
  91. # printer_state_to_dict gates both of these on.
  92. "fila_switch": None,
  93. "ams_switch_inlet": {},
  94. "extruder_slots": {},
  95. }
  96. base.update(overrides)
  97. return SimpleNamespace(**base)
  98. def _scheduled_names(mock) -> list[str]:
  99. """Coroutine names passed to the patched ``_schedule_async``.
  100. Asserting on names rather than a bare call count keeps this file pinned to
  101. #1128's contract (persist + broadcast on every flag mutation) without
  102. breaking every time another emission is hung off the same setter — #2525
  103. added an edge-triggered MQTT/notification relay, which is covered by its
  104. own test module.
  105. """
  106. return [call.args[0].__qualname__.rsplit(".", 1)[-1] for call in mock.call_args_list]
  107. class TestSchedulingFromSetAwaitingPlateClear:
  108. """The hook from the public flag-mutation method into the broadcast."""
  109. def test_schedules_broadcast_when_loop_running(self, manager):
  110. """When a real event loop is attached, every call to
  111. ``set_awaiting_plate_clear`` must enqueue both the persistence
  112. coroutine and the broadcast coroutine. Both are needed: persist
  113. survives restarts, broadcast notifies live subscribers."""
  114. manager._loop = MagicMock()
  115. manager._loop.is_running.return_value = True
  116. with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
  117. manager.set_awaiting_plate_clear(7, True)
  118. # Persist + broadcast, in either order.
  119. names = _scheduled_names(scheduled)
  120. assert "_persist_awaiting_plate_clear" in names
  121. assert "_broadcast_status_change" in names
  122. def test_does_not_schedule_when_no_loop_attached(self, manager):
  123. """Sync unit-test path (no loop attached): nothing must be
  124. scheduled, otherwise Python emits 'coroutine was never awaited'
  125. runtime warnings and the test suite goes red on harmless flag
  126. twiddling."""
  127. manager._loop = None
  128. with patch.object(manager, "_schedule_async") as scheduled:
  129. manager.set_awaiting_plate_clear(7, True)
  130. scheduled.assert_not_called()
  131. def test_does_not_schedule_when_loop_not_running(self, manager):
  132. """A loop attached-but-stopped is the same situation as no loop —
  133. scheduling onto a dead loop would never fire."""
  134. manager._loop = MagicMock()
  135. manager._loop.is_running.return_value = False
  136. with patch.object(manager, "_schedule_async") as scheduled:
  137. manager.set_awaiting_plate_clear(7, True)
  138. scheduled.assert_not_called()
  139. def test_both_true_and_false_flips_schedule_broadcast(self, manager):
  140. """The bug only became visible on ``False`` flips (clear), but a
  141. regression that broadcasts only on ``True`` would re-introduce
  142. the original symptom for any future flag mutation that goes
  143. ``False → True`` outside the printer-card optimistic-update
  144. path. Make both directions a contract."""
  145. manager._loop = MagicMock()
  146. manager._loop.is_running.return_value = True
  147. with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
  148. manager.set_awaiting_plate_clear(7, True)
  149. scheduled.reset_mock()
  150. manager.set_awaiting_plate_clear(7, False)
  151. # The False flip persists and broadcasts just like the True flip did.
  152. names = _scheduled_names(scheduled)
  153. assert "_persist_awaiting_plate_clear" in names
  154. assert "_broadcast_status_change" in names
  155. class TestBroadcastStatusChange:
  156. """The broadcast coroutine itself."""
  157. @pytest.mark.asyncio
  158. async def test_emits_ws_update_when_state_present(self, manager):
  159. """Happy path: printer has a known status, broadcast goes out
  160. with the dict produced by ``printer_state_to_dict``.
  161. Note: we deliberately don't patch ``printer_state_to_dict`` here.
  162. The patch was observed to race on parallel xdist runners — when it
  163. didn't catch the call the real function ran, leaving the test
  164. comparing the patched return value against the real dict shape.
  165. Letting the real function run (against a complete ``_fake_state``)
  166. makes the test deterministic; we assert structural shape, not the
  167. exact ~36 keys, because pinning those couples the test to the
  168. evolving ``printer_state_to_dict`` body and adds zero value over
  169. what ``test_printer_manager.py`` already covers."""
  170. state = _fake_state()
  171. with (
  172. patch.object(manager, "get_status", return_value=state),
  173. patch.object(manager, "get_model", return_value="P1S"),
  174. patch(
  175. "backend.app.core.websocket.ws_manager.send_printer_status",
  176. new_callable=AsyncMock,
  177. ) as send_status,
  178. ):
  179. await manager._broadcast_status_change(7)
  180. send_status.assert_awaited_once()
  181. printer_id_arg, payload_arg = send_status.await_args.args
  182. assert printer_id_arg == 7
  183. assert isinstance(payload_arg, dict)
  184. # The ``awaiting_plate_clear`` key is the whole point of this broadcast
  185. # path (#1128). Any future restructuring that drops it from the dict
  186. # would silently break the UI; pin its presence.
  187. assert "awaiting_plate_clear" in payload_arg
  188. @pytest.mark.asyncio
  189. async def test_skips_when_status_unknown(self, manager):
  190. """Printer not connected / unknown ID → no point broadcasting a
  191. snapshot we don't have. A future reconnect will produce a fresh
  192. status push anyway, so we'd only be forcing a stale or bogus
  193. payload onto subscribers right now."""
  194. with (
  195. patch.object(manager, "get_status", return_value=None),
  196. patch(
  197. "backend.app.core.websocket.ws_manager.send_printer_status",
  198. new_callable=AsyncMock,
  199. ) as send_status,
  200. ):
  201. await manager._broadcast_status_change(999)
  202. send_status.assert_not_awaited()
  203. @pytest.mark.asyncio
  204. async def test_swallows_websocket_errors(self, manager):
  205. """The broadcast is a courtesy, not a correctness path — if the
  206. WS layer is down, the flag is already mutated in-memory and
  207. persisted. Letting an exception bubble out of
  208. ``_broadcast_status_change`` would surface as an
  209. ``Exception in scheduled callback`` traceback in the log AND
  210. prevent the persistence coroutine from completing if both were
  211. gathered together. Swallow + warn instead."""
  212. with (
  213. patch.object(manager, "get_status", return_value=_fake_state()),
  214. patch.object(manager, "get_model", return_value="P1S"),
  215. patch(
  216. "backend.app.core.websocket.ws_manager.send_printer_status",
  217. new_callable=AsyncMock,
  218. side_effect=RuntimeError("websocket layer unavailable"),
  219. ),
  220. ):
  221. # Must not raise.
  222. await manager._broadcast_status_change(7)
  223. class TestEndToEndUnderRunningLoop:
  224. """Verify the full flow under a real running event loop — schedule
  225. → broadcast → ws_manager.send_printer_status — without mocking
  226. ``_schedule_async``. Catches regressions where individual pieces
  227. pass but the wiring breaks (e.g. ``_schedule_async`` swallowing the
  228. broadcast coroutine)."""
  229. @pytest.mark.asyncio
  230. async def test_set_false_eventually_emits_broadcast(self, manager):
  231. """Reproduces the #1128 fix path end-to-end: set the flag to
  232. False under a live loop, give the scheduler a tick, the
  233. ws broadcast must have fired with the new payload."""
  234. loop = asyncio.get_running_loop()
  235. manager._loop = loop
  236. # Pretend the printer has been seen — without a state present
  237. # the broadcast short-circuits before reaching ws_manager.
  238. manager._awaiting_plate_clear.add(7)
  239. # _fake_state defaults awaiting_plate_clear=False via printer_state_to_dict's
  240. # is_awaiting_plate_clear(printer_id) lookup, which reads from
  241. # manager._awaiting_plate_clear (the in-memory set). Since we just
  242. # removed 7 from that set by calling set_awaiting_plate_clear(7, False),
  243. # the broadcast payload's awaiting_plate_clear field will be False.
  244. with (
  245. patch.object(manager, "get_status", return_value=_fake_state()),
  246. patch.object(manager, "get_model", return_value="P1S"),
  247. patch(
  248. "backend.app.core.websocket.ws_manager.send_printer_status",
  249. new_callable=AsyncMock,
  250. ) as send_status,
  251. # Persistence path opens a DB session; stub it out so this
  252. # stays a pure unit test.
  253. patch.object(manager, "_persist_awaiting_plate_clear", new_callable=AsyncMock),
  254. ):
  255. manager.set_awaiting_plate_clear(7, False)
  256. # Yield repeatedly so run_coroutine_threadsafe has a chance
  257. # to land its scheduled coroutine on this loop.
  258. for _ in range(10):
  259. await asyncio.sleep(0)
  260. send_status.assert_awaited()
  261. printer_id_arg, payload_arg = send_status.await_args.args
  262. assert printer_id_arg == 7
  263. assert payload_arg["awaiting_plate_clear"] is False