test_scheduler_model_job_drying_c5.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """Drying applies to "Any <model>" jobs too (SP-C1).
  2. The drying hold ("drying blocks the queue"), the stop before a print starts and
  3. queue-only drying all lived in the branch for jobs pinned to one printer. A job
  4. for "Any X1C" went straight to the matcher: it could start on an X1C whose AMS
  5. was mid-dry with the hold switched on, the dry cycle was never stopped for it,
  6. and queue-only drying never pre-dried anything for it.
  7. Now a drying printer is passed over for a model job while the hold is on (the
  8. job takes another printer of the model, or waits), the printer a model job
  9. lands on has its drying stopped like a pinned job's, and a model job counts for
  10. every printer of its model its creator may use when drying decides what to dry.
  11. """
  12. from types import SimpleNamespace
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from backend.app.core.printer_scope import PrinterScope
  16. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  17. from backend.app.models.printer import Printer
  18. from backend.app.services.print_scheduler import PrintScheduler
  19. from backend.tests.unit.test_scheduler_model_filament_short_3137 import queue_db # noqa: F401 - fixture
  20. from backend.tests.unit.test_scheduler_pinned_waiting_reason_3074 import (
  21. _add_item,
  22. _item,
  23. _run,
  24. _set,
  25. ctx, # noqa: F401 - fixture
  26. )
  27. async def _add_printer(ctx, printer_id: int, model: str = "X1C") -> None:
  28. async with ctx.session_maker() as db:
  29. db.add(
  30. Printer(
  31. id=printer_id,
  32. name=f"{model}-0{printer_id}",
  33. serial_number=f"{model}000{printer_id}",
  34. ip_address=f"10.0.0.{printer_id}",
  35. access_code="x",
  36. model=model,
  37. is_active=True,
  38. )
  39. )
  40. await db.commit()
  41. def _dispatched_to(launched) -> dict[int, int]:
  42. return launched.call_args[0][1] if launched.called else {}
  43. class TestTheHoldCoversModelJobs:
  44. @pytest.mark.asyncio
  45. async def test_a_drying_printer_is_passed_over_for_another_of_the_model(self, ctx):
  46. await _set(ctx, "queue_drying_block", "true")
  47. await _add_printer(ctx, 2)
  48. item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
  49. scheduler = PrintScheduler()
  50. scheduler._drying_in_progress[1] = 1.0
  51. launched = await _run(ctx, scheduler, idle=True)
  52. assert _dispatched_to(launched).get(item_id) == 2
  53. @pytest.mark.asyncio
  54. async def test_it_waits_while_every_printer_of_the_model_dries(self, ctx):
  55. await _set(ctx, "queue_drying_block", "true")
  56. item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
  57. scheduler = PrintScheduler()
  58. scheduler._drying_in_progress[1] = 1.0
  59. launched = await _run(ctx, scheduler, idle=True)
  60. launched.assert_not_called()
  61. item = await _item(ctx, item_id)
  62. assert item.printer_id is None
  63. # Busy-shaped: the cycle ends on its own, nothing to alert about. Named
  64. # like a pinned job's, so the wait reads as drying, not as printing.
  65. assert item.waiting_reason == "Busy: X1C-01 (drying)"
  66. @pytest.mark.asyncio
  67. async def test_a_parked_timer_does_not_hold_it(self, ctx):
  68. await _set(ctx, "queue_drying_block", "true")
  69. item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
  70. scheduler = PrintScheduler()
  71. scheduler._drying_in_progress[1] = 1.0
  72. with (
  73. patch.object(PrintScheduler, "_drying_is_only_parked", staticmethod(lambda printer_id: True)),
  74. patch.object(scheduler, "_stop_drying", AsyncMock()),
  75. patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
  76. ):
  77. launched = await _run(ctx, scheduler, idle=True)
  78. assert _dispatched_to(launched).get(item_id) == 1
  79. @pytest.mark.asyncio
  80. async def test_without_the_hold_it_may_take_a_drying_printer(self, ctx):
  81. item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
  82. scheduler = PrintScheduler()
  83. scheduler._drying_in_progress[1] = 1.0
  84. with (
  85. patch.object(scheduler, "_stop_drying", AsyncMock()),
  86. patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
  87. ):
  88. launched = await _run(ctx, scheduler, idle=True)
  89. assert _dispatched_to(launched).get(item_id) == 1
  90. class TestDryingStopsForTheModelJobsPrint:
  91. @pytest.mark.asyncio
  92. async def test_the_printer_it_lands_on_stops_drying(self, ctx):
  93. await _add_item(ctx, printer_id=None, target_model="X1C")
  94. scheduler = PrintScheduler()
  95. scheduler._drying_in_progress[1] = 1.0
  96. stop = AsyncMock()
  97. with (
  98. patch.object(scheduler, "_stop_drying", stop),
  99. patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=False)),
  100. ):
  101. launched = await _run(ctx, scheduler, idle=True)
  102. launched.assert_called_once()
  103. stop.assert_awaited_once_with(1)
  104. @pytest.mark.asyncio
  105. async def test_drying_that_may_continue_through_the_print_is_left_alone(self, ctx):
  106. await _add_item(ctx, printer_id=None, target_model="X1C")
  107. scheduler = PrintScheduler()
  108. scheduler._drying_in_progress[1] = 1.0
  109. stop = AsyncMock()
  110. with (
  111. patch.object(scheduler, "_stop_drying", stop),
  112. patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
  113. ):
  114. await _run(ctx, scheduler, idle=True)
  115. stop.assert_not_awaited()
  116. async def _items(ctx) -> list[PrintQueueItem]:
  117. from sqlalchemy import select
  118. from sqlalchemy.orm import selectinload
  119. async with ctx.session_maker() as db:
  120. query = select(PrintQueueItem).options(
  121. selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
  122. selectinload(PrintQueueItem.archive),
  123. selectinload(PrintQueueItem.library_file),
  124. )
  125. return list((await db.execute(query)).scalars().all())
  126. async def _considered_for_drying(ctx, scheduler, *, scope=None) -> set[int]:
  127. """The printers auto-drying looked at past the queue gates: each one it
  128. reaches is asked whether it is idle (answered no, so nothing starts)."""
  129. reached: set[int] = set()
  130. def idle(pid, require_plate_clear=False):
  131. reached.add(pid)
  132. return False
  133. state = SimpleNamespace(state="IDLE", firmware_version="01.09.00.00", raw_data={"ams": []})
  134. pm = MagicMock()
  135. pm.get_status.return_value = state
  136. pm.get_model.return_value = "X1C"
  137. pm.is_connected.return_value = True
  138. items = await _items(ctx)
  139. async with ctx.session_maker() as db:
  140. with (
  141. patch("backend.app.services.print_scheduler.printer_manager", pm),
  142. patch.object(scheduler, "_is_printer_idle", idle),
  143. patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=scope is not None)),
  144. patch(
  145. "backend.app.services.print_scheduler.resolve_user_id_printer_scope",
  146. AsyncMock(return_value=scope),
  147. ),
  148. ):
  149. await scheduler._check_auto_drying(db, items, set())
  150. return reached
  151. class TestQueueOnlyDryingCountsModelJobs:
  152. @pytest.mark.asyncio
  153. async def test_a_scheduled_model_job_pre_dries_every_printer_of_its_model(self, ctx):
  154. from datetime import datetime, timedelta
  155. await _set(ctx, "queue_drying_enabled", "true")
  156. await _add_printer(ctx, 2)
  157. await _add_printer(ctx, 3, model="P1S")
  158. item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
  159. async with ctx.session_maker() as db:
  160. item = await db.get(PrintQueueItem, item_id)
  161. item.scheduled_time = datetime.utcnow() + timedelta(hours=2)
  162. await db.commit()
  163. assert await _considered_for_drying(ctx, PrintScheduler()) == {1, 2}
  164. @pytest.mark.asyncio
  165. async def test_only_the_printers_its_creator_may_use(self, ctx):
  166. from datetime import datetime, timedelta
  167. await _set(ctx, "queue_drying_enabled", "true")
  168. await _add_printer(ctx, 2)
  169. item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
  170. async with ctx.session_maker() as db:
  171. item = await db.get(PrintQueueItem, item_id)
  172. item.scheduled_time = datetime.utcnow() + timedelta(hours=2)
  173. item.created_by_id = 42
  174. await db.commit()
  175. reached = await _considered_for_drying(ctx, PrintScheduler(), scope=PrinterScope(frozenset({2})))
  176. assert reached == {2}
  177. @pytest.mark.asyncio
  178. async def test_a_cross_model_job_counts_every_model_it_was_sliced_for(self, ctx):
  179. from datetime import datetime, timedelta
  180. from backend.app.models.library import LibraryFile
  181. await _set(ctx, "queue_drying_enabled", "true")
  182. await _add_printer(ctx, 3, model="P1S")
  183. await _add_printer(ctx, 4, model="A1")
  184. item_id = await _add_item(ctx, printer_id=None, target_model=None)
  185. async with ctx.session_maker() as db:
  186. item = await db.get(PrintQueueItem, item_id)
  187. item.scheduled_time = datetime.utcnow() + timedelta(hours=2)
  188. for position, model in enumerate(("X1C", "P1S")):
  189. lib = LibraryFile(
  190. filename=f"{model}.gcode.3mf", file_path=f"/library/{model}.gcode.3mf", file_size=1, file_type="3mf"
  191. )
  192. db.add(lib)
  193. await db.flush()
  194. db.add(
  195. PrintQueueVariant(
  196. queue_item_id=item_id, position=position, library_file_id=lib.id, target_model=model
  197. )
  198. )
  199. await db.commit()
  200. assert await _considered_for_drying(ctx, PrintScheduler()) == {1, 3}
  201. @pytest.mark.asyncio
  202. async def test_an_unscheduled_model_job_pre_dries_nothing(self, ctx):
  203. await _set(ctx, "queue_drying_enabled", "true")
  204. await _add_item(ctx, printer_id=None, target_model="X1C")
  205. assert await _considered_for_drying(ctx, PrintScheduler()) == set()
  206. @pytest.mark.asyncio
  207. async def test_the_hold_keeps_drying_from_starting_where_a_model_job_waits(self, ctx):
  208. """As for a pinned job: with drying set to block the queue, a printer a
  209. pending job could take does not start a new cycle."""
  210. await _set(ctx, "ambient_drying_enabled", "true")
  211. await _set(ctx, "queue_drying_block", "true")
  212. await _add_printer(ctx, 3, model="P1S")
  213. await _add_item(ctx, printer_id=None, target_model="X1C")
  214. assert await _considered_for_drying(ctx, PrintScheduler()) == {3}
  215. class TestALowFilamentHoldIsNotMovedOntoADryingPrinter:
  216. """The #3137 release moves a held model job to another printer of the
  217. model: not to one that drying holds, where it would wait the whole cycle."""
  218. @pytest.mark.asyncio
  219. async def test_it_goes_to_the_printer_that_is_not_drying(self, queue_db):
  220. from backend.app.models.settings import Settings
  221. from backend.tests.unit.test_scheduler_model_filament_short_3137 import (
  222. _add_item as add_held,
  223. _get_item,
  224. _run as run_held,
  225. )
  226. async with queue_db.session_maker() as db:
  227. db.add(Settings(key="queue_drying_block", value="true"))
  228. await db.commit()
  229. item_id = await add_held(queue_db, printer_id=1, manual_start=True, filament_short=True)
  230. scheduler = PrintScheduler()
  231. scheduler._drying_in_progress[2] = 1.0
  232. launched = MagicMock()
  233. with (
  234. patch.object(scheduler, "_stop_drying", AsyncMock()),
  235. patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
  236. ):
  237. await run_held(queue_db, scheduler, short={1}, launched=launched)
  238. assert (await _get_item(queue_db, item_id)).printer_id == 3