test_reconcile_stale_active_prints.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. """Tests for the connected-edge reconciliation that recovers from missed
  2. PRINT COMPLETE events (#1542 follow-up).
  3. Background: the PRINT COMPLETE MQTT callback is purely reactive to a single
  4. state transition (RUNNING → IDLE / FINISH / FAILED). When the printer
  5. finishes during an MQTT disconnect window — typical on the A1 line with
  6. unstable MQTT keepalives — Bambuddy never observes the transition. If a
  7. smart plug then cuts power between completion and the next reconnect, the
  8. firmware auto-replays whatever's still on the SD card and produces a ghost
  9. print on next power-up. Reporter (#1542 second case) saw this hit 4 out of
  10. 4 of his A1s.
  11. These tests cover:
  12. * `_is_active_archive_stale` — the pure decision function for whether an
  13. archive in `status="printing"` should be reconciled given the printer's
  14. current state.
  15. * `reconcile_stale_active_prints` — the orchestrator that queries the DB,
  16. runs the decision function, and synthesises `on_print_complete` for
  17. each stale archive.
  18. """
  19. from types import SimpleNamespace
  20. from unittest.mock import AsyncMock, MagicMock, patch
  21. import pytest
  22. from backend.app.main import _is_active_archive_stale
  23. def _state(state: str, *, subtask_id: str = "", subtask_name: str = "", connected: bool = True) -> SimpleNamespace:
  24. """Minimal PrinterState stub for the pure decision function."""
  25. return SimpleNamespace(
  26. state=state,
  27. subtask_id=subtask_id,
  28. subtask_name=subtask_name,
  29. connected=connected,
  30. raw_data={},
  31. )
  32. def _archive(
  33. subtask_id: str | None = "ABC123", filename: str = "ghost.3mf", print_name: str = "ghost"
  34. ) -> SimpleNamespace:
  35. """Minimal PrintArchive stub — only the fields the decision function reads."""
  36. return SimpleNamespace(
  37. id=42,
  38. subtask_id=subtask_id,
  39. filename=filename,
  40. print_name=print_name,
  41. )
  42. class TestIsActiveArchiveStale:
  43. """Decision function — covers all three stale triggers + the
  44. intentionally-conservative no-op cases."""
  45. # Trigger 1: printer is in a terminal state.
  46. @pytest.mark.parametrize("terminal_state", ["IDLE", "FINISH", "FAILED", "idle", "finish", "failed"])
  47. def test_terminal_state_marks_stale(self, terminal_state):
  48. archive = _archive(subtask_id="ABC123")
  49. state = _state(terminal_state, subtask_id="ABC123", subtask_name="ghost")
  50. is_stale, reason = _is_active_archive_stale(archive, state)
  51. assert is_stale is True
  52. assert terminal_state.upper() in reason
  53. # Trigger 2: printer is running a different subtask_id.
  54. def test_subtask_id_changed_marks_stale(self):
  55. archive = _archive(subtask_id="OLD_ID")
  56. state = _state("RUNNING", subtask_id="NEW_ID", subtask_name="something")
  57. is_stale, reason = _is_active_archive_stale(archive, state)
  58. assert is_stale is True
  59. assert "subtask_id" in reason
  60. assert "OLD_ID" in reason
  61. assert "NEW_ID" in reason
  62. # Trigger 3: printer is running but doesn't know what it's running.
  63. def test_empty_subtask_name_marks_stale(self):
  64. archive = _archive(subtask_id="ABC123")
  65. state = _state("RUNNING", subtask_id="", subtask_name="")
  66. is_stale, reason = _is_active_archive_stale(archive, state)
  67. assert is_stale is True
  68. assert "empty" in reason.lower() or "subtask_name" in reason
  69. # Healthy case: same subtask_id, running.
  70. def test_matching_running_print_not_stale(self):
  71. archive = _archive(subtask_id="ABC123")
  72. state = _state("RUNNING", subtask_id="ABC123", subtask_name="ghost")
  73. is_stale, _ = _is_active_archive_stale(archive, state)
  74. assert is_stale is False
  75. # PAUSE is not a stale signal — the print is paused, not ended.
  76. def test_paused_print_with_matching_subtask_not_stale(self):
  77. archive = _archive(subtask_id="ABC123")
  78. state = _state("PAUSE", subtask_id="ABC123", subtask_name="ghost")
  79. is_stale, _ = _is_active_archive_stale(archive, state)
  80. assert is_stale is False
  81. # PREPARE / SLICING are not stale either — pre-print phases.
  82. @pytest.mark.parametrize("pre_running_state", ["PREPARE", "SLICING"])
  83. def test_pre_running_states_with_matching_subtask_not_stale(self, pre_running_state):
  84. archive = _archive(subtask_id="ABC123")
  85. state = _state(pre_running_state, subtask_id="ABC123", subtask_name="ghost")
  86. is_stale, _ = _is_active_archive_stale(archive, state)
  87. assert is_stale is False
  88. # Missing subtask_id on the archive side: don't have evidence either
  89. # way, fall through to the empty-subtask_name check.
  90. def test_archive_with_no_subtask_id_falls_to_subtask_name_check(self):
  91. archive = _archive(subtask_id=None)
  92. state = _state("RUNNING", subtask_id="ANYTHING", subtask_name="something")
  93. # Subtask_name is populated → not stale, no false positive.
  94. is_stale, _ = _is_active_archive_stale(archive, state)
  95. assert is_stale is False
  96. # Missing subtask_id on both sides: still triggers the empty-subtask_name
  97. # branch if the printer doesn't know what it's running.
  98. def test_both_subtask_ids_missing_running_with_empty_name_stale(self):
  99. archive = _archive(subtask_id=None)
  100. state = _state("RUNNING", subtask_id="", subtask_name="")
  101. is_stale, _ = _is_active_archive_stale(archive, state)
  102. assert is_stale is True
  103. # IDLE wins over PRINT-STATE checks — the terminal-state branch fires
  104. # first regardless of what the subtask fields look like.
  105. def test_idle_state_overrides_matching_subtask(self):
  106. archive = _archive(subtask_id="ABC123")
  107. state = _state("IDLE", subtask_id="ABC123", subtask_name="ghost")
  108. is_stale, reason = _is_active_archive_stale(archive, state)
  109. assert is_stale is True
  110. assert "IDLE" in reason
  111. # #1679: defensive pre-push guard. Even if reconcile gets called against
  112. # a PrinterState that's still on construction defaults (state="unknown"
  113. # / empty / None, subtask_name=""), the function must NOT report stale —
  114. # otherwise the reactive PRINT COMPLETE later creates a duplicate
  115. # archive and filament gets double-counted. The on_printer_status_change
  116. # caller is the primary fix (gates the reconcile spawn on real state),
  117. # but this guard is belt-and-braces for any future caller.
  118. @pytest.mark.parametrize("degenerate_state", ["unknown", "UNKNOWN", "Unknown", "", None])
  119. def test_pre_push_state_returns_not_stale_even_with_empty_subtask(self, degenerate_state):
  120. archive = _archive(subtask_id="ABC123")
  121. state = _state(degenerate_state, subtask_id="", subtask_name="")
  122. is_stale, _ = _is_active_archive_stale(archive, state)
  123. assert is_stale is False, (
  124. f"state.state={degenerate_state!r} means MQTT hasn't pushed real data yet; "
  125. "treating an in-flight archive as stale here causes the #1679 "
  126. "duplicate-archive + filament-double-count regression"
  127. )
  128. class TestReconcileStaleActivePrints:
  129. """Orchestrator-level tests — mock the printer manager + DB session so
  130. we can drive the decision flow end-to-end without standing up real
  131. fixtures.
  132. These cover:
  133. * No printer status (disconnected) → no-op, no on_print_complete fired.
  134. * No active archives → no-op.
  135. * Stale archive → synthesised on_print_complete called with status
  136. ``"aborted"`` and the `_reconciled: True` marker so downstream code
  137. can distinguish synthetic from real completions.
  138. * Non-stale archive → on_print_complete NOT called (no false positive
  139. on a healthy in-flight print).
  140. * Exception inside on_print_complete must NOT block reconciliation
  141. for subsequent archives or crash the caller.
  142. """
  143. @pytest.mark.asyncio
  144. async def test_no_status_skips_reconciliation(self):
  145. from backend.app.main import reconcile_stale_active_prints
  146. with patch("backend.app.main.printer_manager") as mock_pm:
  147. mock_pm.get_status.return_value = None
  148. count = await reconcile_stale_active_prints(printer_id=1)
  149. assert count == 0
  150. @pytest.mark.asyncio
  151. async def test_disconnected_status_skips_reconciliation(self):
  152. from backend.app.main import reconcile_stale_active_prints
  153. with patch("backend.app.main.printer_manager") as mock_pm:
  154. mock_pm.get_status.return_value = _state("RUNNING", connected=False)
  155. count = await reconcile_stale_active_prints(printer_id=1)
  156. # Disconnected state would be making decisions against cached state —
  157. # the connected-edge handler in on_printer_status_change is the only
  158. # place that should drive reconciliation.
  159. assert count == 0
  160. @pytest.mark.asyncio
  161. async def test_no_active_archives_returns_zero(self):
  162. from backend.app.main import reconcile_stale_active_prints
  163. with patch("backend.app.main.printer_manager") as mock_pm:
  164. mock_pm.get_status.return_value = _state("IDLE")
  165. with patch("backend.app.main.async_session") as mock_session:
  166. session_ctx = AsyncMock()
  167. session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [])))
  168. mock_session.return_value.__aenter__.return_value = session_ctx
  169. count = await reconcile_stale_active_prints(printer_id=1)
  170. assert count == 0
  171. @pytest.mark.asyncio
  172. async def test_stale_archive_synthesises_aborted_completion(self):
  173. from backend.app.main import reconcile_stale_active_prints
  174. stale = _archive(subtask_id="OLD_ID", filename="ghost.3mf", print_name="ghost")
  175. with patch("backend.app.main.printer_manager") as mock_pm:
  176. mock_pm.get_status.return_value = _state("IDLE", subtask_id="", subtask_name="")
  177. with patch("backend.app.main.async_session") as mock_session:
  178. session_ctx = AsyncMock()
  179. session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [stale])))
  180. mock_session.return_value.__aenter__.return_value = session_ctx
  181. with patch("backend.app.main.on_print_complete", new=AsyncMock()) as mock_complete:
  182. count = await reconcile_stale_active_prints(printer_id=1)
  183. assert count == 1
  184. mock_complete.assert_awaited_once()
  185. # Verify the synthesised payload shape.
  186. args, kwargs = mock_complete.call_args
  187. assert args[0] == 1
  188. payload = args[1]
  189. assert payload["status"] == "aborted"
  190. assert payload["filename"] == "ghost.3mf"
  191. assert payload["_reconciled"] is True
  192. @pytest.mark.asyncio
  193. async def test_non_stale_archive_does_not_synthesise(self):
  194. from backend.app.main import reconcile_stale_active_prints
  195. healthy = _archive(subtask_id="ABC123")
  196. with patch("backend.app.main.printer_manager") as mock_pm:
  197. mock_pm.get_status.return_value = _state("RUNNING", subtask_id="ABC123", subtask_name="ghost")
  198. with patch("backend.app.main.async_session") as mock_session:
  199. session_ctx = AsyncMock()
  200. session_ctx.execute = AsyncMock(
  201. return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [healthy]))
  202. )
  203. mock_session.return_value.__aenter__.return_value = session_ctx
  204. with patch("backend.app.main.on_print_complete", new=AsyncMock()) as mock_complete:
  205. count = await reconcile_stale_active_prints(printer_id=1)
  206. assert count == 0
  207. mock_complete.assert_not_called()
  208. @pytest.mark.asyncio
  209. async def test_on_print_complete_failure_does_not_block_rest(self):
  210. """An exception during one archive's synthesis must not abort
  211. reconciliation for the other archives — and must not propagate to
  212. the caller (the connected-edge handler is a hot path)."""
  213. from backend.app.main import reconcile_stale_active_prints
  214. a1 = _archive(subtask_id="A", filename="a.3mf")
  215. a1.id = 1
  216. a2 = _archive(subtask_id="B", filename="b.3mf")
  217. a2.id = 2
  218. with patch("backend.app.main.printer_manager") as mock_pm:
  219. mock_pm.get_status.return_value = _state("IDLE")
  220. with patch("backend.app.main.async_session") as mock_session:
  221. session_ctx = AsyncMock()
  222. session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [a1, a2])))
  223. mock_session.return_value.__aenter__.return_value = session_ctx
  224. # First call raises, second call must still happen.
  225. mock_complete = AsyncMock(side_effect=[RuntimeError("boom"), None])
  226. with patch("backend.app.main.on_print_complete", new=mock_complete):
  227. count = await reconcile_stale_active_prints(printer_id=1)
  228. # Only the second archive is recorded as reconciled (first raised).
  229. assert count == 1
  230. assert mock_complete.await_count == 2