test_scheduler_drying_plate_hold_2801.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. """Auto-drying versus the plate-clear hold, from check_queue (#2801).
  2. A printer in FINISH whose plate has not been acknowledged, with something
  3. pending in its queue, stopped and restarted AMS drying once per scheduler tick
  4. for as long as the plate stayed unacknowledged -- roughly 2000 state changes
  5. over ten days on the reporter's P2S. Drying never ran long enough to do
  6. anything, and manual cycles on other AMS units of the same printer were killed
  7. with it.
  8. Two ideas were tangled together. Plate-clear answers "is the bed ready for the
  9. next job"; it is not a statement about whether the AMS may heat. And the
  10. "print takes priority" stop was reached only when the print was NOT going to
  11. start, so it spent the drying cycle for nothing.
  12. These tests drive the real check_queue so the wiring is covered end to end,
  13. not just the predicates.
  14. """
  15. from contextlib import ExitStack
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  20. import backend.app.models # noqa: F401 - populate Base.metadata
  21. from backend.app.core.database import Base
  22. from backend.app.models.library import LibraryFile
  23. from backend.app.models.print_queue import PrintQueueItem
  24. from backend.app.models.printer import Printer
  25. from backend.app.services.print_scheduler import PrintScheduler
  26. @pytest.fixture
  27. async def queue_db():
  28. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  29. async with engine.begin() as conn:
  30. await conn.run_sync(Base.metadata.create_all)
  31. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  32. async with session_maker() as db:
  33. db.add(
  34. Printer(
  35. id=1,
  36. name="P2S-1",
  37. serial_number="P2S0001",
  38. ip_address="10.0.0.1",
  39. access_code="x",
  40. model="P2S",
  41. is_active=True,
  42. )
  43. )
  44. await db.commit()
  45. try:
  46. yield SimpleNamespace(session_maker=session_maker)
  47. finally:
  48. await engine.dispose()
  49. async def _add_item(ctx):
  50. async with ctx.session_maker() as db:
  51. lib = LibraryFile(
  52. filename="job.gcode.3mf",
  53. file_path="/library/job.gcode.3mf",
  54. file_size=10,
  55. file_type="gcode.3mf",
  56. file_metadata={"sliced_for_model": "P2S"},
  57. )
  58. db.add(lib)
  59. await db.flush()
  60. db.add(
  61. PrintQueueItem(
  62. status="pending",
  63. position=1,
  64. printer_id=1,
  65. library_file_id=lib.id,
  66. )
  67. )
  68. await db.commit()
  69. async def _run(ctx, scheduler, *, idle, stop_drying, drying=None, deficit=False, launched=None):
  70. """One check_queue pass with the plate hold expressed through _is_printer_idle."""
  71. patches = [
  72. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  73. patch("backend.app.core.database.async_session", ctx.session_maker),
  74. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  75. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  76. patch(
  77. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  78. AsyncMock(return_value={}),
  79. ),
  80. patch(
  81. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  82. AsyncMock(),
  83. ),
  84. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
  85. patch.object(scheduler, "_check_auto_drying", drying or AsyncMock()),
  86. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  87. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=deficit)),
  88. patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
  89. patch.object(scheduler, "_stop_drying", stop_drying),
  90. ]
  91. with ExitStack() as stack:
  92. for p in patches:
  93. stack.enter_context(p)
  94. return await scheduler.check_queue()
  95. @pytest.mark.asyncio
  96. async def test_drying_is_not_stopped_for_a_print_that_cannot_start(queue_db):
  97. """The reported loop. Plate unacknowledged, so nothing dispatches -- and
  98. stopping the cycle could not have changed that, because drying is not one
  99. of the things _is_printer_idle looks at."""
  100. await _add_item(queue_db)
  101. scheduler = PrintScheduler()
  102. scheduler._drying_in_progress[1] = 1.0
  103. stop = AsyncMock()
  104. await _run(queue_db, scheduler, idle=False, stop_drying=stop)
  105. stop.assert_not_awaited()
  106. @pytest.mark.asyncio
  107. async def test_a_plate_held_printer_is_not_offered_to_drying_as_printing(queue_db):
  108. """It lands in busy_printers so the queue leaves it alone, but auto-drying
  109. is handed the narrow set and must not see it there -- otherwise it takes
  110. the mid-print path, which caps the temperature and skips the idle gate."""
  111. await _add_item(queue_db)
  112. scheduler = PrintScheduler()
  113. drying = AsyncMock()
  114. await _run(queue_db, scheduler, idle=False, stop_drying=AsyncMock(), drying=drying)
  115. dispatching = drying.await_args[0][2]
  116. assert 1 not in dispatching
  117. @pytest.mark.asyncio
  118. async def test_print_takes_priority_still_stops_drying_when_it_can_dispatch(queue_db):
  119. """The setting keeps its meaning: on hardware that cannot dry through a
  120. print, a dispatch that is actually going to happen stops the cycle."""
  121. await _add_item(queue_db)
  122. scheduler = PrintScheduler()
  123. scheduler._drying_in_progress[1] = 1.0
  124. stop = AsyncMock()
  125. with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)):
  126. await _run(queue_db, scheduler, idle=True, stop_drying=stop)
  127. stop.assert_awaited_once_with(1)
  128. @pytest.mark.asyncio
  129. async def test_block_mode_holds_the_print_and_keeps_the_cycle(queue_db):
  130. """queue_drying_block on: the print waits, and the cycle is never touched.
  131. The setting previously had no observable effect on dispatch -- both
  132. branches skipped the item anyway, and all it really decided was whether
  133. drying got needlessly killed. Now it does what it says.
  134. """
  135. await _add_item(queue_db)
  136. scheduler = PrintScheduler()
  137. scheduler._drying_in_progress[1] = 1.0
  138. stop = AsyncMock()
  139. launched = MagicMock()
  140. with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)):
  141. await _run(queue_db, scheduler, idle=True, stop_drying=stop, launched=launched)
  142. stop.assert_not_awaited()
  143. assert not launched.called
  144. @pytest.mark.asyncio
  145. async def test_drying_survives_an_item_that_is_skipped_after_the_idle_check(queue_db):
  146. """The idle check is not the last thing that can stop a dispatch.
  147. A failed previous print, an unmappable item, a filament deficit or a
  148. contested library row all skip the item further down the loop. Deciding on
  149. drying before those is the same defect in a smaller costume: the cycle goes
  150. and the print still does not happen.
  151. """
  152. await _add_item(queue_db)
  153. scheduler = PrintScheduler()
  154. scheduler._drying_in_progress[1] = 1.0
  155. stop = AsyncMock()
  156. with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)):
  157. # Deficit gate holds the item back, after the printer passed as idle.
  158. await _run(queue_db, scheduler, idle=True, stop_drying=stop, deficit=True)
  159. stop.assert_not_awaited()
  160. @pytest.mark.asyncio
  161. async def test_capable_hardware_keeps_drying_through_the_print(queue_db):
  162. """#2758's finding stands: where the printer dries happily while printing
  163. and the user has allowed it, the cycle is left running."""
  164. await _add_item(queue_db)
  165. scheduler = PrintScheduler()
  166. scheduler._drying_in_progress[1] = 1.0
  167. stop = AsyncMock()
  168. with (
  169. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  170. patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
  171. ):
  172. await _run(queue_db, scheduler, idle=True, stop_drying=stop)
  173. stop.assert_not_awaited()