test_printer_manager_status_broadcast.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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. "developer_mode": None,
  91. "nozzles": [],
  92. "ams_extruder_map": {},
  93. "print_ams_mapping": None,
  94. # Filament Track Switch. None means "no accessory", which is what
  95. # printer_state_to_dict gates both of these on.
  96. "fila_switch": None,
  97. "ams_switch_inlet": {},
  98. "extruder_slots": {},
  99. }
  100. base.update(overrides)
  101. return SimpleNamespace(**base)
  102. def _scheduled_names(mock) -> list[str]:
  103. """Coroutine names passed to the patched ``_schedule_async``.
  104. Asserting on names rather than a bare call count keeps this file pinned to
  105. #1128's contract (persist + broadcast on every flag mutation) without
  106. breaking every time another emission is hung off the same setter — #2525
  107. added an edge-triggered MQTT/notification relay, which is covered by its
  108. own test module.
  109. """
  110. return [call.args[0].__qualname__.rsplit(".", 1)[-1] for call in mock.call_args_list]
  111. class TestSchedulingFromSetAwaitingPlateClear:
  112. """The hook from the public flag-mutation method into the broadcast."""
  113. def test_schedules_broadcast_when_loop_running(self, manager):
  114. """When a real event loop is attached, every call to
  115. ``set_awaiting_plate_clear`` must enqueue both the persistence
  116. coroutine and the broadcast coroutine. Both are needed: persist
  117. survives restarts, broadcast notifies live subscribers."""
  118. manager._loop = MagicMock()
  119. manager._loop.is_running.return_value = True
  120. with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
  121. manager.set_awaiting_plate_clear(7, True)
  122. # Persist + broadcast, in either order.
  123. names = _scheduled_names(scheduled)
  124. assert "_persist_awaiting_plate_clear" in names
  125. assert "_broadcast_status_change" in names
  126. def test_does_not_schedule_when_no_loop_attached(self, manager):
  127. """Sync unit-test path (no loop attached): nothing must be
  128. scheduled, otherwise Python emits 'coroutine was never awaited'
  129. runtime warnings and the test suite goes red on harmless flag
  130. twiddling."""
  131. manager._loop = None
  132. with patch.object(manager, "_schedule_async") as scheduled:
  133. manager.set_awaiting_plate_clear(7, True)
  134. scheduled.assert_not_called()
  135. def test_does_not_schedule_when_loop_not_running(self, manager):
  136. """A loop attached-but-stopped is the same situation as no loop —
  137. scheduling onto a dead loop would never fire."""
  138. manager._loop = MagicMock()
  139. manager._loop.is_running.return_value = False
  140. with patch.object(manager, "_schedule_async") as scheduled:
  141. manager.set_awaiting_plate_clear(7, True)
  142. scheduled.assert_not_called()
  143. def test_both_true_and_false_flips_schedule_broadcast(self, manager):
  144. """The bug only became visible on ``False`` flips (clear), but a
  145. regression that broadcasts only on ``True`` would re-introduce
  146. the original symptom for any future flag mutation that goes
  147. ``False → True`` outside the printer-card optimistic-update
  148. path. Make both directions a contract."""
  149. manager._loop = MagicMock()
  150. manager._loop.is_running.return_value = True
  151. with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
  152. manager.set_awaiting_plate_clear(7, True)
  153. scheduled.reset_mock()
  154. manager.set_awaiting_plate_clear(7, False)
  155. # The False flip persists and broadcasts just like the True flip did.
  156. names = _scheduled_names(scheduled)
  157. assert "_persist_awaiting_plate_clear" in names
  158. assert "_broadcast_status_change" in names
  159. class TestBroadcastStatusChange:
  160. """The broadcast coroutine itself."""
  161. @pytest.mark.asyncio
  162. async def test_emits_ws_update_when_state_present(self, manager):
  163. """Happy path: printer has a known status, broadcast goes out
  164. with the dict produced by ``printer_state_to_dict``.
  165. Note: we deliberately don't patch ``printer_state_to_dict`` here.
  166. The patch was observed to race on parallel xdist runners — when it
  167. didn't catch the call the real function ran, leaving the test
  168. comparing the patched return value against the real dict shape.
  169. Letting the real function run (against a complete ``_fake_state``)
  170. makes the test deterministic; we assert structural shape, not the
  171. exact ~36 keys, because pinning those couples the test to the
  172. evolving ``printer_state_to_dict`` body and adds zero value over
  173. what ``test_printer_manager.py`` already covers."""
  174. state = _fake_state()
  175. with (
  176. patch.object(manager, "get_status", return_value=state),
  177. patch.object(manager, "get_model", return_value="P1S"),
  178. patch(
  179. "backend.app.core.websocket.ws_manager.send_printer_status",
  180. new_callable=AsyncMock,
  181. ) as send_status,
  182. ):
  183. await manager._broadcast_status_change(7)
  184. send_status.assert_awaited_once()
  185. printer_id_arg, payload_arg = send_status.await_args.args
  186. assert printer_id_arg == 7
  187. assert isinstance(payload_arg, dict)
  188. # The ``awaiting_plate_clear`` key is the whole point of this broadcast
  189. # path (#1128). Any future restructuring that drops it from the dict
  190. # would silently break the UI; pin its presence.
  191. assert "awaiting_plate_clear" in payload_arg
  192. @pytest.mark.asyncio
  193. async def test_skips_when_status_unknown(self, manager):
  194. """Printer not connected / unknown ID → no point broadcasting a
  195. snapshot we don't have. A future reconnect will produce a fresh
  196. status push anyway, so we'd only be forcing a stale or bogus
  197. payload onto subscribers right now."""
  198. with (
  199. patch.object(manager, "get_status", return_value=None),
  200. patch(
  201. "backend.app.core.websocket.ws_manager.send_printer_status",
  202. new_callable=AsyncMock,
  203. ) as send_status,
  204. ):
  205. await manager._broadcast_status_change(999)
  206. send_status.assert_not_awaited()
  207. @pytest.mark.asyncio
  208. async def test_swallows_websocket_errors(self, manager):
  209. """The broadcast is a courtesy, not a correctness path — if the
  210. WS layer is down, the flag is already mutated in-memory and
  211. persisted. Letting an exception bubble out of
  212. ``_broadcast_status_change`` would surface as an
  213. ``Exception in scheduled callback`` traceback in the log AND
  214. prevent the persistence coroutine from completing if both were
  215. gathered together. Swallow + warn instead."""
  216. with (
  217. patch.object(manager, "get_status", return_value=_fake_state()),
  218. patch.object(manager, "get_model", return_value="P1S"),
  219. patch(
  220. "backend.app.core.websocket.ws_manager.send_printer_status",
  221. new_callable=AsyncMock,
  222. side_effect=RuntimeError("websocket layer unavailable"),
  223. ),
  224. ):
  225. # Must not raise.
  226. await manager._broadcast_status_change(7)
  227. class TestEndToEndUnderRunningLoop:
  228. """Verify the full flow under a real running event loop — schedule
  229. → broadcast → ws_manager.send_printer_status — without mocking
  230. ``_schedule_async``. Catches regressions where individual pieces
  231. pass but the wiring breaks (e.g. ``_schedule_async`` swallowing the
  232. broadcast coroutine)."""
  233. @pytest.mark.asyncio
  234. async def test_set_false_eventually_emits_broadcast(self, manager):
  235. """Reproduces the #1128 fix path end-to-end: set the flag to
  236. False under a live loop, give the scheduler a tick, the
  237. ws broadcast must have fired with the new payload."""
  238. loop = asyncio.get_running_loop()
  239. manager._loop = loop
  240. # Pretend the printer has been seen — without a state present
  241. # the broadcast short-circuits before reaching ws_manager.
  242. manager._awaiting_plate_clear.add(7)
  243. # _fake_state defaults awaiting_plate_clear=False via printer_state_to_dict's
  244. # is_awaiting_plate_clear(printer_id) lookup, which reads from
  245. # manager._awaiting_plate_clear (the in-memory set). Since we just
  246. # removed 7 from that set by calling set_awaiting_plate_clear(7, False),
  247. # the broadcast payload's awaiting_plate_clear field will be False.
  248. with (
  249. patch.object(manager, "get_status", return_value=_fake_state()),
  250. patch.object(manager, "get_model", return_value="P1S"),
  251. patch(
  252. "backend.app.core.websocket.ws_manager.send_printer_status",
  253. new_callable=AsyncMock,
  254. ) as send_status,
  255. # Persistence path opens a DB session; stub it out so this
  256. # stays a pure unit test.
  257. patch.object(manager, "_persist_awaiting_plate_clear", new_callable=AsyncMock),
  258. # So does the MQTT/notification emit, through its DB fallback for
  259. # a printer with no client. Left real, its query is still running
  260. # on aiosqlite's thread when the test's loop closes, and warns
  261. # under whichever test runs next.
  262. patch.object(manager, "_emit_plate_clear_change", new_callable=AsyncMock),
  263. ):
  264. manager.set_awaiting_plate_clear(7, False)
  265. # Yield repeatedly so run_coroutine_threadsafe has a chance
  266. # to land its scheduled coroutine on this loop.
  267. for _ in range(10):
  268. await asyncio.sleep(0)
  269. send_status.assert_awaited()
  270. printer_id_arg, payload_arg = send_status.await_args.args
  271. assert printer_id_arg == 7
  272. assert payload_arg["awaiting_plate_clear"] is False