test_internal_printer_jobs.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. """The printer's own calibration runs leave no archive and send no notification.
  2. Auto pressure-advance calibration -- the K-profile line the printer lays down
  3. before a print when flow dynamics calibration is on -- reports over MQTT through
  4. the same print-start event a real print uses, as the subtask name
  5. ``auto_pa_line_calib_mode`` with no ``/usr/`` path attached. The only guard
  6. Bambuddy had tested ``filename.startswith("/usr/")``, so the calibration sailed
  7. past it, found no 3MF anywhere on the printer (there is none to find), and left
  8. a no-3MF archive named after itself in the user's history.
  9. The same name is already known to the completion guard: #2829's capture of
  10. queue item 649 has ``auto_pa_line_calib_mode`` arriving as the subtask name of a
  11. completion that had to be refused against a running job.
  12. The manual flow-dynamics run reaches Bambuddy the same way under two further
  13. names -- ``pa_line_calib_mode`` and ``pa_pattern_calib_mode``, the line and the
  14. pattern shape, both without the ``auto_`` prefix -- so the automatic entry never
  15. covered either of them.
  16. """
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from backend.app.utils.print_jobs import is_internal_printer_job
  20. from backend.tests._fixtures.background_tasks import MAIN_TARGET, discarding_spawn_patch
  21. class TestTheCalibrationIsRecognised:
  22. def test_the_pressure_advance_line_by_subtask_name(self):
  23. """How it actually arrives: a bare subtask name, no filename at all."""
  24. assert is_internal_printer_job("", "auto_pa_line_calib_mode")
  25. def test_the_pressure_advance_line_by_filename(self):
  26. """Both fields are tested, because which one carries it is not fixed."""
  27. assert is_internal_printer_job("auto_pa_line_calib_mode", None)
  28. def test_the_manual_pressure_advance_pattern_by_subtask_name(self):
  29. """The same calibration run by hand rather than before a print. It
  30. prints a pattern where the automatic one prints a line, and carries its
  31. own name with no ``auto_`` prefix -- so the automatic entry never
  32. covered it and it left the same no-3MF archive."""
  33. assert is_internal_printer_job("", "pa_pattern_calib_mode")
  34. def test_the_manual_pressure_advance_pattern_by_filename(self):
  35. assert is_internal_printer_job("pa_pattern_calib_mode", None)
  36. def test_the_manual_pressure_advance_line_by_subtask_name(self):
  37. """Manual flow dynamics has a line shape as well as a pattern one, and
  38. it reports as ``pa_line_calib_mode`` -- the automatic name without its
  39. ``auto_`` prefix, which is close enough to the automatic entry to look
  40. covered and is not."""
  41. assert is_internal_printer_job("", "pa_line_calib_mode")
  42. def test_the_manual_pressure_advance_line_by_filename(self):
  43. assert is_internal_printer_job("pa_line_calib_mode", None)
  44. def test_the_levelling_run_by_its_system_path(self):
  45. assert is_internal_printer_job("/usr/etc/print/auto_cali_for_user.gcode", "auto_cali_for_user")
  46. def test_the_levelling_run_by_name_alone(self):
  47. """The /usr/ path is not guaranteed, so the name is listed too."""
  48. assert is_internal_printer_job(None, "auto_cali_for_user")
  49. @pytest.mark.parametrize(
  50. "reported",
  51. [
  52. "auto_pa_line_calib_mode",
  53. "auto_pa_line_calib_mode.gcode",
  54. "auto_pa_line_calib_mode.3mf",
  55. "auto_pa_line_calib_mode.gcode.3mf",
  56. "AUTO_PA_LINE_CALIB_MODE",
  57. "/data/auto_pa_line_calib_mode.gcode.3mf",
  58. "pa_pattern_calib_mode",
  59. "pa_pattern_calib_mode.gcode.3mf",
  60. "PA_Pattern_Calib_Mode",
  61. "/data/pa_pattern_calib_mode.gcode",
  62. "pa_line_calib_mode",
  63. "pa_line_calib_mode.gcode.3mf",
  64. "PA_Line_Calib_Mode",
  65. "/data/pa_line_calib_mode.gcode",
  66. ],
  67. )
  68. def test_however_the_name_is_dressed_up(self, reported):
  69. """Path, suffix and case all vary between the fields and firmwares."""
  70. assert is_internal_printer_job(reported, None)
  71. def test_anything_under_usr_counts(self):
  72. """Nothing a user can print lives on the read-only system partition."""
  73. assert is_internal_printer_job("/usr/bin/firmware_test.gcode", "test")
  74. class TestItLeavesRealPrintsAlone:
  75. """The failure that matters: swallowing somebody's actual print."""
  76. def test_an_ordinary_print(self):
  77. assert not is_internal_printer_job("Benchy.gcode.3mf", "Benchy")
  78. def test_nothing_reported_at_all(self):
  79. assert not is_internal_printer_job(None, None)
  80. assert not is_internal_printer_job("", "")
  81. @pytest.mark.parametrize(
  82. "reported",
  83. [
  84. "auto_pa_line_calib_mode_v2.3mf",
  85. "my_auto_pa_line_calib_mode.3mf",
  86. "auto_cali_for_user_test.gcode.3mf",
  87. "pa_pattern_calib_mode_v2.3mf",
  88. "my_pa_pattern_calib_mode.3mf",
  89. "pa_line_calib_mode_v2.3mf",
  90. "my_pa_line_calib_mode.3mf",
  91. # The stem the four calibration names share, which is why the set
  92. # holds literals instead of a ``pa_`` rule.
  93. "pa_bracket.3mf",
  94. ],
  95. )
  96. def test_a_users_file_that_merely_contains_the_name(self, reported):
  97. """Exact match after normalising, so no prefix or substring rule can
  98. eat a file somebody deliberately named after the calibration."""
  99. assert not is_internal_printer_job(reported, None)
  100. def test_a_calibration_cube(self):
  101. """The obvious false positive for any rule built on the word 'calib'."""
  102. assert not is_internal_printer_job("Calibration_Cube.gcode.3mf", "Calibration Cube")
  103. def _mocked_print_start():
  104. """Patch set for driving on_print_start without a printer or database."""
  105. return (
  106. patch("backend.app.main.async_session"),
  107. patch("backend.app.main.notification_service"),
  108. patch("backend.app.main.smart_plug_manager"),
  109. patch("backend.app.main.ws_manager"),
  110. patch("backend.app.main.printer_manager"),
  111. patch("backend.app.main.mqtt_relay"),
  112. )
  113. class TestPrintStartSkipsTheCalibration:
  114. @pytest.mark.asyncio
  115. async def test_no_archive_and_no_notification(self, capture_logs):
  116. sess, notif, plug, ws, pm, relay = _mocked_print_start()
  117. with sess as mock_session_maker, notif as mock_notif, plug as mock_plug, ws as mock_ws, pm as mock_pm, relay:
  118. mock_notif.on_print_start = AsyncMock()
  119. mock_plug.on_print_start = AsyncMock()
  120. mock_ws.send_print_start = AsyncMock()
  121. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  122. mock_printer = MagicMock()
  123. mock_printer.auto_archive = True
  124. mock_printer.id = 1
  125. mock_session = AsyncMock()
  126. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  127. mock_session.__aexit__ = AsyncMock()
  128. mock_session.scalar = AsyncMock(return_value=None) # no queue job of Bambuddy's printing
  129. mock_session.execute = AsyncMock(
  130. return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
  131. )
  132. mock_session_maker.return_value = mock_session
  133. with patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock) as mock_notify:
  134. from backend.app.main import on_print_start
  135. # No filename: exactly what the printer reports for this run,
  136. # and the reason the old /usr/ prefix test never fired.
  137. await on_print_start(1, {"filename": "", "subtask_name": "auto_pa_line_calib_mode"})
  138. mock_notify.assert_not_called()
  139. skipped = [r for r in capture_logs.records if "internal printer job" in str(r.message)]
  140. assert skipped, "Should log that the calibration run was skipped"
  141. @pytest.mark.asyncio
  142. async def test_archiving_off_does_not_notify_it_either(self):
  143. """The auto-archive "off" path sends a print-start notification of its
  144. own, so the calibration check has to come first."""
  145. sess, notif, plug, ws, pm, relay = _mocked_print_start()
  146. with sess as mock_session_maker, notif as mock_notif, plug as mock_plug, ws as mock_ws, pm as mock_pm, relay:
  147. mock_notif.on_print_start = AsyncMock()
  148. mock_plug.on_print_start = AsyncMock()
  149. mock_ws.send_print_start = AsyncMock()
  150. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  151. mock_printer = MagicMock(auto_archive=False, id=1)
  152. mock_session = AsyncMock()
  153. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  154. mock_session.__aexit__ = AsyncMock()
  155. mock_session.scalar = AsyncMock(return_value=None) # no queue job of Bambuddy's printing
  156. mock_session.execute = AsyncMock(
  157. return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
  158. )
  159. mock_session_maker.return_value = mock_session
  160. with patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock) as mock_notify:
  161. from backend.app.main import on_print_start
  162. await on_print_start(1, {"filename": "", "subtask_name": "auto_pa_line_calib_mode"})
  163. mock_notify.assert_not_called()
  164. class TestPrintCompleteStaysQuiet:
  165. @pytest.mark.asyncio
  166. async def test_no_orphan_notification_when_the_calibration_finishes(self):
  167. """With no archive to close, the completion would otherwise fall into
  168. the no-archive notification path -- which attributes an unmatched
  169. completion to any queue item this printer finished in the last five
  170. minutes. For a calibration running alongside a real print that means
  171. telling its owner their print is done, early and twice.
  172. """
  173. with (
  174. patch("backend.app.main.async_session") as mock_session_maker,
  175. patch("backend.app.main.ws_manager") as mock_ws,
  176. patch("backend.app.main.printer_manager") as mock_pm,
  177. patch("backend.app.main.mqtt_relay") as mock_relay,
  178. discarding_spawn_patch(MAIN_TARGET) as mock_spawn,
  179. patch("backend.app.main.clear_3mf_cache"),
  180. ):
  181. mock_ws.send_print_complete = AsyncMock()
  182. mock_relay.on_print_complete = AsyncMock()
  183. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  184. mock_pm.get_current_print_user = MagicMock(return_value=None)
  185. mock_pm.clear_current_print_user = MagicMock()
  186. mock_pm.set_awaiting_plate_clear = MagicMock()
  187. mock_session = AsyncMock()
  188. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  189. mock_session.__aexit__ = AsyncMock()
  190. mock_session.execute = AsyncMock(
  191. return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None), scalars=MagicMock())
  192. )
  193. mock_session_maker.return_value = mock_session
  194. from backend.app.main import on_print_complete
  195. await on_print_complete(
  196. 1,
  197. {"filename": "", "subtask_name": "auto_pa_line_calib_mode", "status": "completed"},
  198. )
  199. spawned = [c for c in mock_spawn.call_args_list if "notify-no-archive" in str(c)]
  200. assert not spawned, "No completion notification should be spawned for a calibration run"
  201. @pytest.mark.asyncio
  202. async def test_a_real_orphan_print_still_notifies(self):
  203. """The no-archive path exists for prints started outside Bambuddy. The
  204. guard must not take those down with it.
  205. """
  206. with (
  207. patch("backend.app.main.async_session") as mock_session_maker,
  208. patch("backend.app.main.ws_manager") as mock_ws,
  209. patch("backend.app.main.printer_manager") as mock_pm,
  210. patch("backend.app.main.mqtt_relay") as mock_relay,
  211. discarding_spawn_patch(MAIN_TARGET) as mock_spawn,
  212. patch("backend.app.main.clear_3mf_cache"),
  213. ):
  214. mock_ws.send_print_complete = AsyncMock()
  215. mock_relay.on_print_complete = AsyncMock()
  216. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  217. mock_pm.get_current_print_user = MagicMock(return_value=None)
  218. mock_pm.clear_current_print_user = MagicMock()
  219. mock_pm.set_awaiting_plate_clear = MagicMock()
  220. mock_session = AsyncMock()
  221. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  222. mock_session.__aexit__ = AsyncMock()
  223. mock_session.execute = AsyncMock(
  224. return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None), scalars=MagicMock())
  225. )
  226. mock_session_maker.return_value = mock_session
  227. from backend.app.main import on_print_complete
  228. await on_print_complete(
  229. 1,
  230. {"filename": "", "subtask_name": "Benchy", "status": "completed"},
  231. )
  232. spawned = [c for c in mock_spawn.call_args_list if "notify-no-archive" in str(c)]
  233. assert spawned, "An unmatched real print must still notify"
  234. def test_the_a1_built_in_calibration_reported_as_a_bare_name_is_internal():
  235. """A1/P1S report it without the /usr/ path (support bundle, A1 01.08)."""
  236. from backend.app.utils.print_jobs import is_internal_printer_job
  237. assert is_internal_printer_job("auto_cali_for_user_param.gcode", "auto_cali_for_user_param.gcode")