test_scheduler_scheduled_drying.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. """Tests for PrintScheduler scheduled-drying dispatch (#2638)."""
  2. from datetime import datetime, timedelta, timezone
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. from sqlalchemy import select
  6. from backend.app.models.scheduled_drying import ScheduledDrying
  7. from backend.app.services.print_scheduler import (
  8. SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS,
  9. SCHEDULED_DRYING_RETENTION_DAYS,
  10. PrintScheduler,
  11. )
  12. def _utcnow_naive() -> datetime:
  13. return datetime.now(timezone.utc).replace(tzinfo=None)
  14. # Above the X1C drying minimum, so the shared preflight lets dispatch through.
  15. DRYING_CAPABLE_FIRMWARE = "01.09.00.00"
  16. def _mock_state(ams_id=0, dry_time=0, dry_sf_reason=None, firmware=DRYING_CAPABLE_FIRMWARE):
  17. state = MagicMock()
  18. state.firmware_version = firmware
  19. state.raw_data = {"ams": [{"id": ams_id, "dry_time": dry_time, "dry_sf_reason": dry_sf_reason or []}]}
  20. return state
  21. async def _make_row(db_session, printer_factory, **kwargs):
  22. printer = await printer_factory()
  23. defaults = {"printer_id": printer.id, "ams_id": 0, "temp": 65, "duration_hours": 8}
  24. defaults.update(kwargs)
  25. row = ScheduledDrying(**defaults)
  26. db_session.add(row)
  27. await db_session.commit()
  28. await db_session.refresh(row)
  29. return row
  30. @pytest.fixture
  31. def scheduler():
  32. return PrintScheduler()
  33. @pytest.mark.asyncio
  34. async def test_future_start_after_not_dispatched(scheduler, db_session, printer_factory):
  35. row = await _make_row(db_session, printer_factory, start_after=_utcnow_naive() + timedelta(hours=2))
  36. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  37. await scheduler._check_scheduled_dryings(db_session)
  38. mock_pm.send_drying_command.assert_not_called()
  39. await db_session.refresh(row)
  40. assert row.status == "pending"
  41. @pytest.mark.asyncio
  42. async def test_due_row_dispatches_and_goes_running(scheduler, db_session, printer_factory):
  43. row = await _make_row(
  44. db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1), filament="PETG"
  45. )
  46. with (
  47. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  48. patch.object(scheduler, "_is_printer_idle", return_value=True),
  49. ):
  50. mock_pm.get_status.return_value = _mock_state()
  51. mock_pm.send_drying_command.return_value = True
  52. await scheduler._check_scheduled_dryings(db_session)
  53. mock_pm.send_drying_command.assert_called_once_with(
  54. row.printer_id, 0, 65, 8, mode=1, filament="PETG", rotate_tray=False
  55. )
  56. await db_session.refresh(row)
  57. assert row.status == "running"
  58. assert row.started_at is not None
  59. assert scheduler._drying_in_progress.get(row.printer_id)
  60. @pytest.mark.asyncio
  61. async def test_null_start_after_dispatches_immediately(scheduler, db_session, printer_factory):
  62. row = await _make_row(db_session, printer_factory, start_after=None)
  63. with (
  64. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  65. patch.object(scheduler, "_is_printer_idle", return_value=True),
  66. ):
  67. mock_pm.get_status.return_value = _mock_state()
  68. mock_pm.send_drying_command.return_value = True
  69. await scheduler._check_scheduled_dryings(db_session)
  70. await db_session.refresh(row)
  71. assert row.status == "running"
  72. @pytest.mark.asyncio
  73. async def test_busy_printer_stays_pending_with_reason(scheduler, db_session, printer_factory):
  74. row = await _make_row(db_session, printer_factory, start_after=None)
  75. with (
  76. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  77. patch.object(scheduler, "_is_printer_idle", return_value=False),
  78. ):
  79. mock_pm.get_status.return_value = _mock_state()
  80. await scheduler._check_scheduled_dryings(db_session)
  81. mock_pm.send_drying_command.assert_not_called()
  82. await db_session.refresh(row)
  83. assert row.status == "pending"
  84. assert row.waiting_reason == "printer_busy"
  85. @pytest.mark.asyncio
  86. async def test_offline_printer_stays_pending(scheduler, db_session, printer_factory):
  87. row = await _make_row(db_session, printer_factory, start_after=None)
  88. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  89. mock_pm.get_status.return_value = None
  90. await scheduler._check_scheduled_dryings(db_session)
  91. await db_session.refresh(row)
  92. assert row.status == "pending"
  93. assert row.waiting_reason == "printer_offline"
  94. @pytest.mark.asyncio
  95. async def test_ams_blocked_stays_pending(scheduler, db_session, printer_factory):
  96. row = await _make_row(db_session, printer_factory, start_after=None)
  97. with (
  98. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  99. patch.object(scheduler, "_is_printer_idle", return_value=True),
  100. ):
  101. mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[2])
  102. await scheduler._check_scheduled_dryings(db_session)
  103. mock_pm.send_drying_command.assert_not_called()
  104. await db_session.refresh(row)
  105. assert row.status == "pending"
  106. assert row.waiting_reason == "ams_blocked"
  107. @pytest.mark.asyncio
  108. async def test_retract_block_gets_its_own_waiting_reason(scheduler, db_session, printer_factory):
  109. """Code 3 is user-actionable (retract the filament), so it says so rather
  110. than bucketing into the generic blocked message.
  111. """
  112. row = await _make_row(db_session, printer_factory, start_after=None)
  113. with (
  114. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  115. patch.object(scheduler, "_is_printer_idle", return_value=True),
  116. ):
  117. mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[3])
  118. await scheduler._check_scheduled_dryings(db_session)
  119. mock_pm.send_drying_command.assert_not_called()
  120. await db_session.refresh(row)
  121. assert row.status == "pending"
  122. assert row.waiting_reason == "ams_retract_filament"
  123. @pytest.mark.asyncio
  124. async def test_power_block_outranks_retract(scheduler, db_session, printer_factory):
  125. """Both blocking at once: power is the one that has to be fixed first."""
  126. row = await _make_row(db_session, printer_factory, start_after=None)
  127. with (
  128. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  129. patch.object(scheduler, "_is_printer_idle", return_value=True),
  130. ):
  131. mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[3, 8])
  132. await scheduler._check_scheduled_dryings(db_session)
  133. await db_session.refresh(row)
  134. assert row.waiting_reason == "ams_power_required"
  135. @pytest.mark.asyncio
  136. @pytest.mark.parametrize("code", [1, 8])
  137. async def test_power_block_gets_its_own_waiting_reason(scheduler, db_session, printer_factory, code):
  138. """A run the user has to unblock says so, rather than waiting silently."""
  139. row = await _make_row(db_session, printer_factory, start_after=None)
  140. with (
  141. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  142. patch.object(scheduler, "_is_printer_idle", return_value=True),
  143. ):
  144. mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[code])
  145. await scheduler._check_scheduled_dryings(db_session)
  146. mock_pm.send_drying_command.assert_not_called()
  147. await db_session.refresh(row)
  148. assert row.status == "pending"
  149. assert row.waiting_reason == "ams_power_required"
  150. @pytest.mark.asyncio
  151. async def test_screen_only_model_fails_instead_of_dispatching(scheduler, db_session, printer_factory):
  152. """A P1S acks the publish and ignores it; dispatching would silently self-cancel."""
  153. printer = await printer_factory(model="P1S")
  154. row = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=65, duration_hours=8)
  155. db_session.add(row)
  156. await db_session.commit()
  157. with (
  158. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  159. patch.object(scheduler, "_is_printer_idle", return_value=True),
  160. ):
  161. mock_pm.get_status.return_value = _mock_state()
  162. await scheduler._check_scheduled_dryings(db_session)
  163. mock_pm.send_drying_command.assert_not_called()
  164. await db_session.refresh(row)
  165. assert row.status == "failed"
  166. assert row.error_message
  167. assert row.completed_at is not None
  168. @pytest.mark.asyncio
  169. async def test_firmware_below_minimum_fails(scheduler, db_session, printer_factory):
  170. row = await _make_row(db_session, printer_factory, start_after=None)
  171. with (
  172. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  173. patch.object(scheduler, "_is_printer_idle", return_value=True),
  174. ):
  175. mock_pm.get_status.return_value = _mock_state(firmware="01.05.00.00")
  176. await scheduler._check_scheduled_dryings(db_session)
  177. mock_pm.send_drying_command.assert_not_called()
  178. await db_session.refresh(row)
  179. assert row.status == "failed"
  180. assert row.error_message
  181. assert row.completed_at is not None
  182. @pytest.mark.asyncio
  183. async def test_empty_filament_backfills_from_loaded_tray(scheduler, db_session, printer_factory):
  184. """Matches the immediate endpoint, which sends the loaded type rather than PLA."""
  185. row = await _make_row(db_session, printer_factory, start_after=None, filament="")
  186. state = _mock_state()
  187. state.raw_data["ams"][0]["tray"] = [{"tray_type": ""}, {"tray_type": "PETG"}]
  188. with (
  189. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  190. patch.object(scheduler, "_is_printer_idle", return_value=True),
  191. ):
  192. mock_pm.get_status.return_value = state
  193. mock_pm.send_drying_command.return_value = True
  194. await scheduler._check_scheduled_dryings(db_session)
  195. assert mock_pm.send_drying_command.call_args.kwargs["filament"] == "PETG"
  196. await db_session.refresh(row)
  197. assert row.filament == "PETG"
  198. @pytest.mark.asyncio
  199. async def test_empty_filament_falls_back_to_pla(scheduler, db_session, printer_factory):
  200. await _make_row(db_session, printer_factory, start_after=None, filament="")
  201. with (
  202. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  203. patch.object(scheduler, "_is_printer_idle", return_value=True),
  204. ):
  205. mock_pm.get_status.return_value = _mock_state()
  206. mock_pm.send_drying_command.return_value = True
  207. await scheduler._check_scheduled_dryings(db_session)
  208. assert mock_pm.send_drying_command.call_args.kwargs["filament"] == "PLA"
  209. @pytest.mark.asyncio
  210. async def test_finished_rows_pruned_after_retention(scheduler, db_session, printer_factory):
  211. printer = await printer_factory()
  212. stale = ScheduledDrying(
  213. printer_id=printer.id,
  214. ams_id=0,
  215. temp=65,
  216. duration_hours=8,
  217. status="completed",
  218. completed_at=_utcnow_naive() - timedelta(days=SCHEDULED_DRYING_RETENTION_DAYS + 1),
  219. )
  220. recent = ScheduledDrying(
  221. printer_id=printer.id,
  222. ams_id=0,
  223. temp=65,
  224. duration_hours=8,
  225. status="cancelled",
  226. completed_at=_utcnow_naive() - timedelta(hours=1),
  227. )
  228. db_session.add_all([stale, recent])
  229. await db_session.commit()
  230. with patch("backend.app.services.print_scheduler.printer_manager"):
  231. await scheduler._check_scheduled_dryings(db_session)
  232. remaining = (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
  233. assert stale.id not in remaining
  234. assert recent.id in remaining
  235. @pytest.mark.asyncio
  236. async def test_prune_does_not_run_on_every_pass(scheduler, db_session, printer_factory):
  237. """The prune is throttled, because issuing the DELETE is what starts a
  238. write transaction and this method runs every 3s while the queue dispatches.
  239. Rows only become prunable a week after they finish, so nothing is lost by
  240. waiting an hour to reap them."""
  241. printer = await printer_factory()
  242. async def _stale_row() -> ScheduledDrying:
  243. row = ScheduledDrying(
  244. printer_id=printer.id,
  245. ams_id=0,
  246. temp=65,
  247. duration_hours=8,
  248. status="completed",
  249. completed_at=_utcnow_naive() - timedelta(days=SCHEDULED_DRYING_RETENTION_DAYS + 1),
  250. )
  251. db_session.add(row)
  252. await db_session.commit()
  253. await db_session.refresh(row)
  254. return row
  255. with patch("backend.app.services.print_scheduler.printer_manager"):
  256. # First pass after a restart always prunes, so rows left behind by the
  257. # process that died are still reaped.
  258. first = await _stale_row()
  259. await scheduler._check_scheduled_dryings(db_session)
  260. assert first.id not in (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
  261. # A second pass moments later leaves an equally stale row alone.
  262. second = await _stale_row()
  263. await scheduler._check_scheduled_dryings(db_session)
  264. assert second.id in (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
  265. # ...and reaps it once the interval has elapsed.
  266. scheduler._last_scheduled_drying_prune -= SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS
  267. await scheduler._check_scheduled_dryings(db_session)
  268. assert second.id not in (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
  269. @pytest.mark.asyncio
  270. async def test_a_finished_run_releases_the_printer_without_auto_drying(scheduler, db_session, printer_factory):
  271. """_drying_in_progress must not outlive the run that set it.
  272. Auto-drying's _sync_drying_state() prunes that map, but it sits behind the
  273. enabled check, so on a default install (both auto-drying modes off) it never
  274. runs. Nothing else drops the entry unless a print is dispatched to the same
  275. printer, and a nightly off-peak dry with no printing in between is exactly
  276. the case this feature is for: the second night's run would sit on
  277. "already_drying" forever, and with queue_drying_block on the printer would
  278. stop taking prints as well.
  279. """
  280. row = await _make_row(
  281. db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1), duration_hours=1
  282. )
  283. printer_id = row.printer_id
  284. with (
  285. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  286. patch.object(scheduler, "_is_printer_idle", return_value=True),
  287. ):
  288. mock_pm.get_status.return_value = _mock_state()
  289. mock_pm.send_drying_command.return_value = True
  290. await scheduler._check_scheduled_dryings(db_session)
  291. await db_session.refresh(row)
  292. assert row.status == "running"
  293. assert printer_id in scheduler._drying_in_progress
  294. # The firmware has stopped reporting a dry_time, well past the duration.
  295. row.started_at = _utcnow_naive() - timedelta(hours=2)
  296. await db_session.commit()
  297. with (
  298. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  299. patch.object(scheduler, "_is_printer_idle", return_value=True),
  300. ):
  301. mock_pm.get_status.return_value = _mock_state(dry_time=0)
  302. await scheduler._check_scheduled_dryings(db_session)
  303. await db_session.refresh(row)
  304. assert row.status == "completed"
  305. assert printer_id not in scheduler._drying_in_progress
  306. # And the next night's run still dispatches.
  307. tomorrow = ScheduledDrying(
  308. printer_id=printer_id,
  309. ams_id=0,
  310. temp=65,
  311. duration_hours=8,
  312. start_after=_utcnow_naive() - timedelta(minutes=1),
  313. )
  314. db_session.add(tomorrow)
  315. await db_session.commit()
  316. with (
  317. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  318. patch.object(scheduler, "_is_printer_idle", return_value=True),
  319. ):
  320. mock_pm.get_status.return_value = _mock_state()
  321. mock_pm.send_drying_command.return_value = True
  322. await scheduler._check_scheduled_dryings(db_session)
  323. await db_session.refresh(tomorrow)
  324. assert tomorrow.status == "running"
  325. assert tomorrow.waiting_reason is None
  326. @pytest.mark.asyncio
  327. async def test_a_route_cancel_releases_the_printer(scheduler, db_session, printer_factory):
  328. """The DELETE route flips the row to cancelled in the database and sends the
  329. stop, but knows nothing about the scheduler's in-memory map. The next pass
  330. has to notice the run is gone and release the printer, or it stays marked as
  331. drying until a restart."""
  332. row = await _make_row(db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1))
  333. printer_id = row.printer_id
  334. with (
  335. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  336. patch.object(scheduler, "_is_printer_idle", return_value=True),
  337. ):
  338. mock_pm.get_status.return_value = _mock_state()
  339. mock_pm.send_drying_command.return_value = True
  340. await scheduler._check_scheduled_dryings(db_session)
  341. assert printer_id in scheduler._drying_in_progress
  342. # What DELETE /scheduled-dryings/{id} leaves behind.
  343. row.status = "cancelled"
  344. row.completed_at = _utcnow_naive()
  345. await db_session.commit()
  346. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  347. mock_pm.get_status.return_value = _mock_state()
  348. await scheduler._check_scheduled_dryings(db_session)
  349. assert printer_id not in scheduler._drying_in_progress
  350. assert printer_id not in scheduler._scheduled_drying_printer_ids
  351. @pytest.mark.asyncio
  352. async def test_running_completes_after_duration(scheduler, db_session, printer_factory):
  353. row = await _make_row(
  354. db_session,
  355. printer_factory,
  356. status="running",
  357. duration_hours=1,
  358. started_at=_utcnow_naive() - timedelta(minutes=58), # >= 90% of 1h
  359. )
  360. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  361. mock_pm.get_status.return_value = _mock_state(dry_time=0)
  362. await scheduler._check_scheduled_dryings(db_session)
  363. await db_session.refresh(row)
  364. assert row.status == "completed"
  365. assert row.completed_at is not None
  366. @pytest.mark.asyncio
  367. async def test_running_interrupted_by_print_requeues(scheduler, db_session, printer_factory):
  368. row = await _make_row(
  369. db_session,
  370. printer_factory,
  371. status="running",
  372. duration_hours=8,
  373. started_at=_utcnow_naive() - timedelta(minutes=30), # well past grace, far from done
  374. )
  375. with (
  376. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  377. patch.object(scheduler, "_is_printer_idle", return_value=False),
  378. ):
  379. mock_pm.get_status.return_value = _mock_state(dry_time=0)
  380. await scheduler._check_scheduled_dryings(db_session)
  381. await db_session.refresh(row)
  382. assert row.status == "pending"
  383. assert row.started_at is None
  384. assert row.waiting_reason == "interrupted"
  385. @pytest.mark.asyncio
  386. async def test_running_stopped_while_idle_cancels(scheduler, db_session, printer_factory):
  387. """A stop on an idle printer is deliberate; the row must not resurrect."""
  388. row = await _make_row(
  389. db_session,
  390. printer_factory,
  391. status="running",
  392. duration_hours=8,
  393. started_at=_utcnow_naive() - timedelta(minutes=30),
  394. )
  395. with (
  396. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  397. patch.object(scheduler, "_is_printer_idle", return_value=True),
  398. ):
  399. mock_pm.get_status.return_value = _mock_state(dry_time=0)
  400. await scheduler._check_scheduled_dryings(db_session)
  401. await db_session.refresh(row)
  402. assert row.status == "cancelled"
  403. assert row.completed_at is not None
  404. @pytest.mark.asyncio
  405. async def test_running_within_grace_untouched(scheduler, db_session, printer_factory):
  406. row = await _make_row(
  407. db_session,
  408. printer_factory,
  409. status="running",
  410. duration_hours=8,
  411. started_at=_utcnow_naive() - timedelta(seconds=30), # inside 120 s grace
  412. )
  413. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  414. mock_pm.get_status.return_value = _mock_state(dry_time=0)
  415. await scheduler._check_scheduled_dryings(db_session)
  416. await db_session.refresh(row)
  417. assert row.status == "running"
  418. @pytest.mark.asyncio
  419. async def test_scheduled_drying_survives_auto_drying_stop_all(scheduler, db_session, printer_factory):
  420. """Regression (#2638): a running scheduled drying must not be stopped or
  421. untracked by _check_auto_drying's stop-all branch, even in the default
  422. config where both auto-drying toggles are off. Before the fix, the two
  423. features co-owned _drying_in_progress and auto-drying would stop/pop any
  424. printer it didn't start drying on itself.
  425. """
  426. row = await _make_row(db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1))
  427. with (
  428. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  429. patch.object(scheduler, "_is_printer_idle", return_value=True),
  430. ):
  431. mock_pm.get_status.return_value = _mock_state()
  432. mock_pm.send_drying_command.return_value = True
  433. await scheduler._check_scheduled_dryings(db_session)
  434. await db_session.refresh(row)
  435. assert row.status == "running"
  436. assert scheduler._drying_in_progress.get(row.printer_id)
  437. assert row.printer_id in scheduler._scheduled_drying_printer_ids
  438. mock_pm.reset_mock()
  439. with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)):
  440. # Default config: queue_drying_enabled and ambient_drying_enabled both off.
  441. await scheduler._check_auto_drying(db_session, [], set())
  442. mock_pm.send_drying_command.assert_not_called()
  443. await db_session.refresh(row)
  444. assert row.status == "running"
  445. assert scheduler._drying_in_progress.get(row.printer_id)
  446. assert row.printer_id in scheduler._scheduled_drying_printer_ids
  447. @pytest.mark.asyncio
  448. async def test_second_pending_row_for_same_printer_does_not_dispatch(scheduler, db_session, printer_factory):
  449. """Regression (#2638): two pending rows for the same printer must not both
  450. dispatch in the same tick; the second should see the first's dispatch and
  451. stay pending.
  452. """
  453. printer = await printer_factory()
  454. past = _utcnow_naive() - timedelta(minutes=1)
  455. row1 = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=65, duration_hours=8, start_after=past)
  456. row2 = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=60, duration_hours=6, start_after=past)
  457. db_session.add_all([row1, row2])
  458. await db_session.commit()
  459. await db_session.refresh(row1)
  460. await db_session.refresh(row2)
  461. with (
  462. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  463. patch.object(scheduler, "_is_printer_idle", return_value=True),
  464. ):
  465. mock_pm.get_status.return_value = _mock_state()
  466. mock_pm.send_drying_command.return_value = True
  467. await scheduler._check_scheduled_dryings(db_session)
  468. mock_pm.send_drying_command.assert_called_once()
  469. await db_session.refresh(row1)
  470. await db_session.refresh(row2)
  471. # Ordered dispatch: same start_after, so the row created first wins.
  472. assert row1.status == "running"
  473. assert row2.status == "pending"
  474. assert row2.waiting_reason == "already_drying"
  475. @pytest.mark.asyncio
  476. async def test_earliest_start_after_dispatches_first(scheduler, db_session, printer_factory):
  477. """Two rows due on one printer: the earlier schedule starts, not an arbitrary one."""
  478. printer = await printer_factory()
  479. now = _utcnow_naive()
  480. later = ScheduledDrying(
  481. printer_id=printer.id, ams_id=0, temp=65, duration_hours=8, start_after=now - timedelta(minutes=1)
  482. )
  483. sooner = ScheduledDrying(
  484. printer_id=printer.id, ams_id=0, temp=60, duration_hours=6, start_after=now - timedelta(hours=3)
  485. )
  486. # Inserted later-first so row order alone cannot produce the right answer.
  487. db_session.add_all([later, sooner])
  488. await db_session.commit()
  489. await db_session.refresh(later)
  490. await db_session.refresh(sooner)
  491. with (
  492. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  493. patch.object(scheduler, "_is_printer_idle", return_value=True),
  494. ):
  495. mock_pm.get_status.return_value = _mock_state()
  496. mock_pm.send_drying_command.return_value = True
  497. await scheduler._check_scheduled_dryings(db_session)
  498. mock_pm.send_drying_command.assert_called_once_with(printer.id, 0, 60, 6, mode=1, filament="PLA", rotate_tray=False)
  499. await db_session.refresh(later)
  500. await db_session.refresh(sooner)
  501. assert sooner.status == "running"
  502. assert later.status == "pending"
  503. @pytest.mark.asyncio
  504. async def test_malformed_ams_id_does_not_throw_while_running(scheduler, db_session, printer_factory):
  505. """_update_running_scheduled_drying runs inside check_queue: a throw here
  506. would cost the whole pass, print dispatch included, on every tick.
  507. """
  508. row = await _make_row(
  509. db_session,
  510. printer_factory,
  511. status="running",
  512. started_at=_utcnow_naive() - timedelta(minutes=30),
  513. )
  514. state = MagicMock()
  515. state.firmware_version = DRYING_CAPABLE_FIRMWARE
  516. state.raw_data = {"ams": [{"id": "not-a-number", "dry_time": 120}]}
  517. with (
  518. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  519. patch.object(scheduler, "_is_printer_idle", return_value=False),
  520. ):
  521. mock_pm.get_status.return_value = state
  522. await scheduler._check_scheduled_dryings(db_session)
  523. # No matching unit means no dry_time; the printer is busy, so it re-queues.
  524. await db_session.refresh(row)
  525. assert row.status == "pending"
  526. assert row.waiting_reason == "interrupted"