test_pause_eta_2984.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. """Time to the next slicer pause, and the heads-up before it (#2984)."""
  2. import asyncio
  3. import io
  4. import zipfile
  5. from types import SimpleNamespace
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from backend.app.models.archive import PrintArchive
  9. from backend.app.services import pause_eta
  10. from backend.app.services.bambu_mqtt import PrinterState
  11. from backend.app.utils.threemf_tools import (
  12. PausePoint,
  13. extract_pause_points_from_3mf,
  14. parse_gcode_pause_points,
  15. )
  16. # The shape Bambu Studio writes: the layer change (M73 L), then the pause,
  17. # with the slicer's time left (M73 P R) along the way.
  18. GCODE = """; HEADER_BLOCK_START
  19. M73 P0 R120
  20. ; layer num/total_layer_count: 1/30
  21. M73 L1
  22. M991 S0 P0 ;notify layer change
  23. G1 X10 Y10 E1.2
  24. M73 P10 R108
  25. ; layer num/total_layer_count: 2/30
  26. M73 L2
  27. G1 X20 Y10 E1.0
  28. M73 P35 R78
  29. ; layer num/total_layer_count: 3/30
  30. M73 L3
  31. M991 S0 P2 ;notify layer change
  32. ;PAUSE_PRINTING
  33. M400 U1
  34. G1 X30 Y10 E1.0
  35. M73 P60 R48
  36. M73 L4
  37. M400 ; wait for moves, not a pause
  38. M73 P80 R24
  39. M73 L5
  40. ;PAUSE_PRINTING
  41. M400 U1
  42. M400 U1 ; a second pause on the same layer
  43. G1 X40 E1.0
  44. """
  45. def _three_mf(members: dict[str, str]) -> bytes:
  46. buf = io.BytesIO()
  47. with zipfile.ZipFile(buf, "w") as zf:
  48. for name, content in members.items():
  49. zf.writestr(name, content)
  50. return buf.getvalue()
  51. class TestParse:
  52. def test_each_pause_has_its_layer_and_the_time_left_there(self):
  53. assert parse_gcode_pause_points(GCODE) == [
  54. PausePoint(layer=3, remaining_minutes=78),
  55. PausePoint(layer=5, remaining_minutes=24),
  56. ]
  57. def test_a_plain_m400_is_not_a_pause(self):
  58. assert parse_gcode_pause_points("M73 L2\nM73 P10 R50\nM400\nM400 S1\n") == []
  59. def test_no_pauses_in_a_plain_print(self):
  60. assert parse_gcode_pause_points("M73 P0 R10\nM73 L1\nG1 X1 E1\n") == []
  61. def test_time_left_is_unknown_without_m73_r(self):
  62. # A slicer that writes layers but no time left.
  63. assert parse_gcode_pause_points("M73 L7\nM400 U1\n") == [PausePoint(layer=7, remaining_minutes=None)]
  64. def test_lowercase_and_indented_commands(self):
  65. assert parse_gcode_pause_points(" m73 p5 r40\nm73 l2\n m400 u1\n") == [
  66. PausePoint(layer=2, remaining_minutes=40)
  67. ]
  68. def test_a_malformed_value_keeps_the_last_good_one(self):
  69. assert parse_gcode_pause_points("M73 P5 R40\nM73 L2\nM73 Lx Rabc\nM400 U1\n") == [
  70. PausePoint(layer=2, remaining_minutes=40)
  71. ]
  72. def test_stealth_mode_time_is_not_taken_for_normal(self):
  73. # M73 Q/S is the silent-mode estimate; R stays the normal one.
  74. assert parse_gcode_pause_points("M73 P5 R40\nM73 Q5 S80\nM73 L2\nM400 U1\n") == [
  75. PausePoint(layer=2, remaining_minutes=40)
  76. ]
  77. class TestExtract:
  78. def test_reads_the_plate_that_is_printing(self, tmp_path):
  79. path = tmp_path / "two.gcode.3mf"
  80. path.write_bytes(
  81. _three_mf(
  82. {
  83. "Metadata/plate_2.gcode": "M73 P1 R9\nM73 L4\nM400 U1\n",
  84. "Metadata/plate_1.gcode": GCODE,
  85. }
  86. )
  87. )
  88. assert [p.layer for p in extract_pause_points_from_3mf(path, 2)] == [4]
  89. assert [p.layer for p in extract_pause_points_from_3mf(path, 1)] == [3, 5]
  90. def test_not_another_plate_when_the_printed_one_is_missing(self, tmp_path):
  91. path = tmp_path / "two.gcode.3mf"
  92. path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE, "Metadata/plate_2.gcode": "M73 L4\nM400 U1\n"}))
  93. assert extract_pause_points_from_3mf(path, 3) == []
  94. def test_unreadable_file_has_no_pauses(self, tmp_path):
  95. path = tmp_path / "broken.3mf"
  96. path.write_bytes(b"not a zip")
  97. assert extract_pause_points_from_3mf(path, 1) == []
  98. def _running(layer: int, remaining: int, points=None) -> PrinterState:
  99. state = PrinterState()
  100. state.state = "RUNNING"
  101. state.layer_num = layer
  102. state.remaining_time = remaining
  103. state.total_layers = 30
  104. state.subtask_name = "Wifi_QR"
  105. state.pause_points = points if points is not None else [PausePoint(3, 78), PausePoint(5, 24)]
  106. return state
  107. class TestNextPause:
  108. def test_time_to_the_first_pause(self):
  109. assert pause_eta.next_pause(_running(layer=1, remaining=110)) == {"layer": 3, "minutes": 32}
  110. def test_moves_on_once_the_printer_is_on_the_pause_layer(self):
  111. # On the pause's layer the printer waits there, or has resumed past it.
  112. assert pause_eta.next_pause(_running(layer=3, remaining=78)) == {"layer": 5, "minutes": 54}
  113. def test_none_after_the_last_pause(self):
  114. assert pause_eta.next_pause(_running(layer=5, remaining=20)) is None
  115. def test_none_when_the_print_is_paused(self):
  116. state = _running(layer=1, remaining=110)
  117. state.state = "PAUSE"
  118. assert pause_eta.next_pause(state) is None
  119. def test_none_without_pauses(self):
  120. assert pause_eta.next_pause(_running(layer=1, remaining=110, points=[])) is None
  121. def test_layer_only_while_the_printer_reports_no_time_left(self):
  122. assert pause_eta.next_pause(_running(layer=0, remaining=0)) == {"layer": 3, "minutes": None}
  123. def test_layer_only_when_the_gcode_had_no_time(self):
  124. state = _running(layer=1, remaining=110, points=[PausePoint(3, None)])
  125. assert pause_eta.next_pause(state) == {"layer": 3, "minutes": None}
  126. def test_never_negative(self):
  127. # The printer a little ahead of the slicer's estimate.
  128. assert pause_eta.next_pause(_running(layer=2, remaining=70)) == {"layer": 3, "minutes": 0}
  129. def test_the_last_minute_before_a_pause(self):
  130. # The slicer writes time left in whole minutes: the last M73 R before
  131. # the pause can come a minute ahead of it.
  132. assert pause_eta.next_pause(_running(layer=2, remaining=78)) == {"layer": 3, "minutes": 0}
  133. @pytest.mark.parametrize("speed_level", [1, 3, 4])
  134. def test_layer_only_outside_standard_speed(self, speed_level):
  135. state = _running(layer=1, remaining=110)
  136. state.speed_level = speed_level
  137. assert pause_eta.next_pause(state) == {"layer": 3, "minutes": None}
  138. def test_standard_speed(self):
  139. state = _running(layer=1, remaining=110)
  140. state.speed_level = 2
  141. assert pause_eta.next_pause(state) == {"layer": 3, "minutes": 32}
  142. class TestStatusPayloads:
  143. def test_websocket_status_carries_it(self):
  144. from backend.app.services.printer_manager import printer_state_to_dict
  145. state = _running(layer=1, remaining=110)
  146. state.connected = True
  147. assert printer_state_to_dict(state)["next_pause"] == {"layer": 3, "minutes": 32}
  148. def test_websocket_status_none_without_pauses(self):
  149. from backend.app.services.printer_manager import printer_state_to_dict
  150. state = _running(layer=1, remaining=110, points=[])
  151. state.connected = True
  152. assert printer_state_to_dict(state)["next_pause"] is None
  153. class TestNewPrintClears:
  154. def test_the_layer_it_was_on_is_kept_for_a_resume(self):
  155. from backend.app.services.bambu_mqtt import BambuMQTTClient
  156. client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
  157. client.state.layer_num = 42
  158. client._previous_gcode_state = "PAUSE"
  159. client._process_message({"print": {"gcode_state": "RUNNING", "gcode_file": "same.3mf"}})
  160. assert client.layer_before_new_print == 42
  161. def test_pauses_and_warnings_of_the_last_print_are_dropped(self):
  162. from backend.app.services.bambu_mqtt import BambuMQTTClient
  163. client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
  164. client.state.pause_points = [PausePoint(3, 78)]
  165. client.state.pause_warned_layers = {3}
  166. client._previous_gcode_state = "IDLE"
  167. client._process_message({"print": {"gcode_state": "RUNNING", "gcode_file": "next.3mf"}})
  168. assert client.state.pause_points == []
  169. assert client.state.pause_warned_layers == set()
  170. @pytest.fixture(autouse=True)
  171. def _fresh_throttle():
  172. pause_eta._last_check.clear()
  173. yield
  174. pause_eta._last_check.clear()
  175. class TestObserve:
  176. def test_spawns_a_check_when_a_pause_is_near(self):
  177. with patch.object(pause_eta, "spawn_background_task") as spawn:
  178. pause_eta.observe(1, _running(layer=2, remaining=85))
  179. spawn.assert_called_once()
  180. spawn.call_args.args[0].close()
  181. def test_nothing_while_the_pause_is_far(self):
  182. with patch.object(pause_eta, "spawn_background_task") as spawn:
  183. pause_eta.observe(1, _running(layer=1, remaining=78 + pause_eta.MAX_WARNING_MINUTES + 1))
  184. spawn.assert_not_called()
  185. def test_nothing_for_a_pause_already_warned_about(self):
  186. state = _running(layer=2, remaining=85)
  187. state.pause_warned_layers.add(3)
  188. with patch.object(pause_eta, "spawn_background_task") as spawn:
  189. pause_eta.observe(1, state)
  190. spawn.assert_not_called()
  191. def test_nothing_while_the_time_is_unknown(self):
  192. with patch.object(pause_eta, "spawn_background_task") as spawn:
  193. pause_eta.observe(1, _running(layer=0, remaining=0))
  194. spawn.assert_not_called()
  195. def test_checks_at_most_once_per_interval(self):
  196. with patch.object(pause_eta, "spawn_background_task") as spawn:
  197. pause_eta.observe(1, _running(layer=2, remaining=85))
  198. pause_eta.observe(1, _running(layer=2, remaining=84))
  199. pause_eta.observe(2, _running(layer=2, remaining=84))
  200. assert spawn.call_count == 2 # once per printer
  201. for call in spawn.call_args_list:
  202. call.args[0].close()
  203. class _Session:
  204. def __init__(self, printer):
  205. self.get = AsyncMock(return_value=printer)
  206. async def __aenter__(self):
  207. return self
  208. async def __aexit__(self, *exc):
  209. return False
  210. def _warn(state, *, lead: int | None, printer=SimpleNamespace(name="X1C")):
  211. """Run the delayed check against ``state`` with the setting at ``lead``."""
  212. client = SimpleNamespace(state=state)
  213. notify = AsyncMock()
  214. with (
  215. patch("backend.app.services.printer_manager.printer_manager.get_client", return_value=client),
  216. patch("backend.app.core.database.async_session", return_value=_Session(printer)),
  217. patch(
  218. "backend.app.api.routes.settings.get_setting", AsyncMock(return_value=None if lead is None else str(lead))
  219. ),
  220. patch("backend.app.services.notification_service.notification_service.on_print_pause_upcoming", notify),
  221. ):
  222. asyncio.run(pause_eta._warn_if_due(1, 3))
  223. return notify
  224. class TestWarnIfDue:
  225. def test_sends_within_the_default_five_minutes(self):
  226. state = _running(layer=2, remaining=82)
  227. notify = _warn(state, lead=None)
  228. notify.assert_awaited_once()
  229. kwargs = notify.await_args.kwargs
  230. assert notify.await_args.args[:3] == (1, "X1C", "Wifi_QR")
  231. assert (kwargs["minutes"], kwargs["layer"], kwargs["total_layers"]) == (4, 3, 30)
  232. assert state.pause_warned_layers == {3}
  233. def test_waits_while_further_away_than_the_setting(self):
  234. state = _running(layer=2, remaining=90)
  235. notify = _warn(state, lead=10)
  236. notify.assert_not_awaited()
  237. assert state.pause_warned_layers == set()
  238. def test_a_longer_setting_sends_earlier(self):
  239. notify = _warn(_running(layer=2, remaining=90), lead=15)
  240. notify.assert_awaited_once()
  241. def test_once_per_pause(self):
  242. state = _running(layer=2, remaining=80)
  243. _warn(state, lead=5)
  244. notify = _warn(state, lead=5)
  245. notify.assert_not_awaited()
  246. def test_not_when_the_print_moved_past_it_meanwhile(self):
  247. notify = _warn(_running(layer=3, remaining=76), lead=120)
  248. notify.assert_not_awaited()
  249. def test_bad_setting_falls_back_to_the_default(self):
  250. notify = _warn(_running(layer=2, remaining=82), lead="soon") # type: ignore[arg-type]
  251. notify.assert_awaited_once()
  252. def test_setting_is_clamped_to_the_largest_heads_up(self):
  253. notify = _warn(_running(layer=1, remaining=78 + 200), lead=500)
  254. notify.assert_not_awaited()
  255. class TestNotificationService:
  256. def test_variables_for_the_template(self):
  257. from backend.app.services.notification_service import NotificationService
  258. service = NotificationService()
  259. db = AsyncMock()
  260. with (
  261. patch.object(service, "_get_providers_for_event", AsyncMock(return_value=[MagicMock()])) as providers,
  262. patch.object(service, "_build_message_from_template", AsyncMock(return_value=("t", "m"))) as build,
  263. patch.object(service, "_send_to_providers", AsyncMock()) as send,
  264. patch.object(service, "_format_eta", AsyncMock(return_value="16:05")) as eta,
  265. ):
  266. asyncio.run(
  267. service.on_print_pause_upcoming(
  268. 1, "X1C", "/data/Wifi_QR.gcode.3mf", minutes=0, layer=42, total_layers=120, db=db
  269. )
  270. )
  271. assert providers.await_args.args[1] == "on_print_pause_upcoming"
  272. assert build.await_args.args[1] == "print_pause_upcoming"
  273. variables = build.await_args.args[2]
  274. assert variables == {
  275. "printer": "X1C",
  276. "filename": "Wifi_QR",
  277. "time_to_pause": "1m",
  278. "eta": "16:05",
  279. "layer": "42",
  280. "total_layers": "120",
  281. }
  282. # A pause under a minute away is due now, not "Unknown".
  283. assert eta.await_args.args[0] > 0
  284. assert send.await_args.args[4] == "print_pause_upcoming"
  285. def test_nothing_without_providers(self):
  286. from backend.app.services.notification_service import NotificationService
  287. service = NotificationService()
  288. with (
  289. patch.object(service, "_get_providers_for_event", AsyncMock(return_value=[])),
  290. patch.object(service, "_send_to_providers", AsyncMock()) as send,
  291. ):
  292. asyncio.run(
  293. service.on_print_pause_upcoming(1, "X1C", "a.3mf", minutes=3, layer=2, total_layers=9, db=AsyncMock())
  294. )
  295. send.assert_not_awaited()
  296. def test_default_template_uses_only_its_variables(self):
  297. from backend.app.models.notification_template import DEFAULT_TEMPLATES
  298. from backend.app.schemas.notification_template import EVENT_VARIABLES, SAMPLE_DATA
  299. template = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_pause_upcoming")
  300. allowed = set(EVENT_VARIABLES["print_pause_upcoming"])
  301. used = set(__import__("re").findall(r"\{([a-z_]+)\}", template["title_template"] + template["body_template"]))
  302. assert used <= allowed
  303. assert set(SAMPLE_DATA["print_pause_upcoming"]) == allowed
  304. class TestLoadFromArchive:
  305. def _client(self):
  306. state = PrinterState()
  307. state.subtask_name = "Wifi_QR"
  308. return SimpleNamespace(state=state)
  309. def _run_load(self, client, path, *, replace_before_done=False, warned_layers=(), passed_through_layer=0):
  310. from backend.app import main
  311. spawned = []
  312. async def go():
  313. with (
  314. patch.object(main.printer_manager, "get_client", return_value=client),
  315. patch.object(main, "resolve_plate_id", return_value=1),
  316. patch.object(main, "spawn_background_task", side_effect=lambda coro, name=None: spawned.append(coro)),
  317. ):
  318. main._load_pauses_from_3mf(
  319. path, 1, warned_layers=warned_layers, passed_through_layer=passed_through_layer
  320. )
  321. if replace_before_done:
  322. client.state.pause_points = [] # a new print started meanwhile
  323. await spawned[0]
  324. asyncio.run(go())
  325. def test_loads_the_pauses(self, tmp_path):
  326. path = tmp_path / "p.gcode.3mf"
  327. path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
  328. client = self._client()
  329. self._run_load(client, path)
  330. assert [p.layer for p in client.state.pause_points] == [3, 5]
  331. def test_with_the_layers_already_warned_about(self, tmp_path):
  332. path = tmp_path / "p.gcode.3mf"
  333. path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
  334. client = self._client()
  335. self._run_load(client, path, warned_layers={3})
  336. assert client.state.pause_warned_layers == {3}
  337. def test_without_the_pauses_a_resumed_print_has_passed(self, tmp_path):
  338. path = tmp_path / "p.gcode.3mf"
  339. path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
  340. client = self._client()
  341. self._run_load(client, path, passed_through_layer=3)
  342. assert [p.layer for p in client.state.pause_points] == [5]
  343. def test_streams_a_large_plate(self, tmp_path):
  344. # Read line by line; the result is the same as for the whole text.
  345. body = "G1 X1 E0.1\n" * 200_000
  346. path = tmp_path / "big.gcode.3mf"
  347. path.write_bytes(_three_mf({"Metadata/plate_1.gcode": body + GCODE}))
  348. assert extract_pause_points_from_3mf(path, 1) == parse_gcode_pause_points(GCODE)
  349. def test_dropped_when_another_print_started_meanwhile(self, tmp_path):
  350. path = tmp_path / "p.gcode.3mf"
  351. path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
  352. client = self._client()
  353. self._run_load(client, path, replace_before_done=True)
  354. assert client.state.pause_points == []
  355. class TestLoadedWhereObjectsAre:
  356. """The pauses are read wherever a print's archive is bound at its start,
  357. and again after a restart, like the skip-objects list."""
  358. def test_print_start_reads_the_archive_s_pauses(self, tmp_path):
  359. from backend.tests.unit.test_reprint_updates_subtask_id import _clear_dicts, _drive, _make_archive # noqa: F401
  360. archive = _make_archive(archive_id=31, stored_subtask_id=None)
  361. with patch("backend.app.main._load_pauses_from_3mf") as load:
  362. asyncio.run(_drive(tmp_path, archive, mqtt_subtask_id="2103771517"))
  363. load.assert_called_once()
  364. assert load.call_args.args == (tmp_path / archive.file_path, 1)
  365. def _restore(self, *, objects, pauses, extra_data=None):
  366. import logging
  367. from backend.app import main
  368. archive = MagicMock(id=360, file_path="archive/7/job/job.3mf", extra_data=extra_data or {})
  369. client = MagicMock()
  370. client.state = MagicMock(printable_objects=objects, pause_points=pauses)
  371. db = AsyncMock()
  372. db.scalar = AsyncMock(return_value=archive)
  373. with (
  374. patch.object(main, "printer_manager") as pm,
  375. patch.object(main, "_load_objects_from_archive") as load_objects,
  376. patch.object(main, "_load_pauses_from_3mf") as load_pauses,
  377. ):
  378. pm.get_client.return_value = client
  379. asyncio.run(
  380. main._restore_printable_objects(7, MagicMock(subtask_id="330151809"), db, logging.getLogger("t"))
  381. )
  382. return load_objects, load_pauses
  383. def test_restart_reloads_the_pauses(self):
  384. from backend.app.core.config import settings as app_settings
  385. load_objects, load_pauses = self._restore(objects={}, pauses=[])
  386. load_objects.assert_called_once()
  387. assert load_pauses.call_args.args == (app_settings.base_dir / "archive/7/job/job.3mf", 7)
  388. assert load_pauses.call_args.kwargs == {"warned_layers": set()}
  389. def test_restart_keeps_the_pauses_already_warned_about(self):
  390. _objects, load_pauses = self._restore(
  391. objects={},
  392. pauses=[],
  393. extra_data={"pause_warned_layers": {"subtask_id": "330151809", "layers": [3]}},
  394. )
  395. assert load_pauses.call_args.kwargs == {"warned_layers": {3}}
  396. def test_restart_reloads_the_pauses_when_the_objects_are_loaded(self):
  397. load_objects, load_pauses = self._restore(objects={1: "cube"}, pauses=[])
  398. load_objects.assert_not_called()
  399. load_pauses.assert_called_once()
  400. def test_loaded_pauses_are_left_alone(self):
  401. load_objects, load_pauses = self._restore(objects={}, pauses=[PausePoint(3, 78)])
  402. load_objects.assert_called_once()
  403. load_pauses.assert_not_called()
  404. class TestStatusHook:
  405. def test_every_status_push_is_checked(self):
  406. from backend.app import main
  407. state = _running(layer=2, remaining=85)
  408. with patch.object(main.pause_eta, "observe") as observe:
  409. asyncio.run(main.on_printer_status_change(1, state))
  410. observe.assert_called_once_with(1, state)
  411. def test_a_failing_check_does_not_stop_the_status_handler(self):
  412. from backend.app import main
  413. state = _running(layer=2, remaining=85)
  414. with patch.object(main.pause_eta, "observe", side_effect=RuntimeError("boom")):
  415. # Must not raise: the rest of the handler sends the status out.
  416. asyncio.run(main.on_printer_status_change(1, state))
  417. class TestWarnedLayersOnTheArchive:
  418. def _state(self, subtask_id="4242"):
  419. state = _running(layer=2, remaining=82)
  420. state.subtask_id = subtask_id
  421. return state
  422. def test_read_back_for_the_same_run(self):
  423. archive = SimpleNamespace(extra_data={"pause_warned_layers": {"subtask_id": "4242", "layers": [3, 5]}})
  424. assert pause_eta.warned_layers_of(archive, self._state()) == {3, 5}
  425. def test_not_for_another_run_of_the_same_archive(self):
  426. archive = SimpleNamespace(extra_data={"pause_warned_layers": {"subtask_id": "1111", "layers": [3]}})
  427. assert pause_eta.warned_layers_of(archive, self._state()) == set()
  428. @pytest.mark.parametrize("subtask_id", ["", "0"])
  429. def test_not_for_a_print_without_a_run_id(self, subtask_id):
  430. archive = SimpleNamespace(extra_data={"pause_warned_layers": {"subtask_id": subtask_id, "layers": [3]}})
  431. assert pause_eta.warned_layers_of(archive, self._state(subtask_id)) == set()
  432. @pytest.mark.parametrize("saved", [None, [], "3", {"subtask_id": "4242", "layers": ["x", 3]}])
  433. def test_tolerates_what_it_did_not_write(self, saved):
  434. archive = SimpleNamespace(extra_data={"pause_warned_layers": saved})
  435. assert pause_eta.warned_layers_of(archive, self._state()) <= {3}
  436. def test_kept_when_the_heads_up_goes_out(self):
  437. state = self._state()
  438. archive = SimpleNamespace(extra_data={"other": 1})
  439. db = MagicMock(commit=AsyncMock())
  440. with patch("backend.app.main.running_print_archive", AsyncMock(return_value=archive)):
  441. state.pause_warned_layers.add(3)
  442. asyncio.run(pause_eta._remember_warned(db, 1, state))
  443. assert archive.extra_data == {"other": 1, "pause_warned_layers": {"subtask_id": "4242", "layers": [3]}}
  444. db.commit.assert_awaited_once()
  445. def test_warn_if_due_keeps_it(self):
  446. state = self._state()
  447. with patch.object(pause_eta, "_remember_warned", AsyncMock()) as remember:
  448. _warn(state, lead=5)
  449. remember.assert_awaited_once()
  450. def test_a_failure_to_keep_it_is_not_a_failure_to_warn(self):
  451. state = self._state()
  452. with patch.object(pause_eta, "_remember_warned", AsyncMock(side_effect=RuntimeError("db"))):
  453. notify = _warn(state, lead=5)
  454. notify.assert_awaited_once()
  455. assert state.pause_warned_layers == {3}
  456. class TestRecoveredFallbackArchive:
  457. """A print that started without its 3MF (FTPS cool-off, slow transfer)
  458. gets its pauses once the 3MF is recovered (#2957 / #3063)."""
  459. async def _recover(self, test_engine, tmp_path, status, *, price=True):
  460. from backend.app import main as main_module
  461. from backend.app.core.config import settings as app_settings
  462. from backend.tests.unit.test_fallback_archive_recovery_2957 import DISPATCH_FILENAME, _seed, _write_3mf
  463. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  464. async with maker() as db:
  465. archive = await db.get(PrintArchive, archive_id)
  466. archive.status = status
  467. await db.commit()
  468. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  469. with (
  470. patch.object(main_module, "async_session", maker),
  471. patch.object(main_module, "_schedule_archive_cost_estimate"),
  472. patch.object(main_module, "_load_pauses_from_3mf") as load,
  473. patch.object(app_settings, "base_dir", tmp_path),
  474. patch.object(app_settings, "archive_dir", tmp_path / "archive"),
  475. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  476. ):
  477. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source, price=price)
  478. async with maker() as db:
  479. archive = await db.get(PrintArchive, archive_id)
  480. return load, printer_id, archive
  481. @pytest.mark.asyncio
  482. async def test_while_it_prints(self, test_engine, tmp_path):
  483. load, printer_id, archive = await self._recover(test_engine, tmp_path, "printing")
  484. load.assert_called_once_with(tmp_path / archive.file_path, printer_id)
  485. @pytest.mark.asyncio
  486. async def test_not_once_it_has_ended(self, test_engine, tmp_path):
  487. load, _printer_id, _archive = await self._recover(test_engine, tmp_path, "failed")
  488. load.assert_not_called()
  489. @pytest.mark.asyncio
  490. async def test_not_from_its_own_completion(self, test_engine, tmp_path):
  491. load, _printer_id, _archive = await self._recover(test_engine, tmp_path, "printing", price=False)
  492. load.assert_not_called()
  493. def test_a_plate_that_cannot_be_told_does_not_break_the_print_start():
  494. from backend.app import main
  495. client = SimpleNamespace(state=PrinterState())
  496. with (
  497. patch.object(main.printer_manager, "get_client", return_value=client),
  498. patch.object(main, "resolve_plate_id", side_effect=TypeError("odd gcode_file")),
  499. patch.object(main, "spawn_background_task") as spawn,
  500. ):
  501. main._load_pauses_from_3mf("/nowhere.3mf", 1) # must not raise
  502. spawn.assert_not_called()
  503. def test_nothing_kept_without_the_print_s_archive():
  504. state = _running(layer=2, remaining=82)
  505. db = MagicMock(commit=AsyncMock())
  506. with patch("backend.app.main.running_print_archive", AsyncMock(return_value=None)):
  507. asyncio.run(pause_eta._remember_warned(db, 1, state))
  508. db.commit.assert_not_awaited()
  509. class TestResumedArchive:
  510. """A restart while the print was paused makes its resume a print start."""
  511. def test_keeps_what_was_warned_and_passed(self):
  512. from backend.app import main
  513. from backend.app.core.config import settings as app_settings
  514. state = _running(layer=0, remaining=78)
  515. state.subtask_id = "4242"
  516. client = SimpleNamespace(state=state, layer_before_new_print=3)
  517. archive = SimpleNamespace(
  518. file_path="archive/1/job/job.3mf",
  519. extra_data={"pause_warned_layers": {"subtask_id": "4242", "layers": [3]}},
  520. )
  521. with (
  522. patch.object(main.printer_manager, "get_client", return_value=client),
  523. patch.object(main, "_load_pauses_from_3mf") as load,
  524. ):
  525. main._load_pauses_from_resumed_archive(archive, 1)
  526. load.assert_called_once_with(
  527. app_settings.base_dir / "archive/1/job/job.3mf", 1, warned_layers={3}, passed_through_layer=3
  528. )
  529. def test_without_a_client(self):
  530. from backend.app import main
  531. archive = SimpleNamespace(file_path="archive/1/job/job.3mf", extra_data={})
  532. with (
  533. patch.object(main.printer_manager, "get_client", return_value=None),
  534. patch.object(main, "_load_pauses_from_3mf") as load,
  535. ):
  536. main._load_pauses_from_resumed_archive(archive, 1)
  537. assert load.call_args.kwargs == {"warned_layers": (), "passed_through_layer": 0}