test_scheduler_scheduled_drying_check_queue.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. """Scheduled drying through the real check_queue (#2638).
  2. The dispatch logic is unit-tested by calling ``_check_scheduled_dryings``
  3. directly. This drives the whole queue pass instead, because that method is now
  4. called on every tick of the scheduler's hot path: what matters to an install
  5. that never schedules a dry is that the pass still completes and still dispatches
  6. prints, and what matters to one that does is that the two do not interfere.
  7. """
  8. from contextlib import ExitStack
  9. from datetime import datetime, timedelta, timezone
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from sqlalchemy import select
  14. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  15. import backend.app.models # noqa: F401 - populate Base.metadata
  16. from backend.app.core.database import Base
  17. from backend.app.models.library import LibraryFile
  18. from backend.app.models.print_queue import PrintQueueItem
  19. from backend.app.models.printer import Printer
  20. from backend.app.models.scheduled_drying import ScheduledDrying
  21. from backend.app.services.print_scheduler import PrintScheduler
  22. pytestmark = pytest.mark.unit
  23. def _utcnow_naive() -> datetime:
  24. return datetime.now(timezone.utc).replace(tzinfo=None)
  25. def _state(dry_time=0):
  26. state = MagicMock()
  27. state.firmware_version = "01.09.00.00"
  28. state.raw_data = {"ams": [{"id": 0, "dry_time": dry_time, "dry_sf_reason": []}]}
  29. return state
  30. @pytest.fixture
  31. async def queue_db():
  32. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  33. async with engine.begin() as conn:
  34. await conn.run_sync(Base.metadata.create_all)
  35. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  36. async with session_maker() as db:
  37. db.add(
  38. Printer(
  39. id=1,
  40. name="P2S-1",
  41. serial_number="P2S0001",
  42. ip_address="10.0.0.1",
  43. access_code="x",
  44. model="P2S",
  45. is_active=True,
  46. )
  47. )
  48. await db.commit()
  49. try:
  50. yield SimpleNamespace(session_maker=session_maker)
  51. finally:
  52. await engine.dispose()
  53. async def _add_print_item(ctx):
  54. async with ctx.session_maker() as db:
  55. lib = LibraryFile(
  56. filename="job.gcode.3mf",
  57. file_path="/library/job.gcode.3mf",
  58. file_size=10,
  59. file_type="gcode.3mf",
  60. file_metadata={"sliced_for_model": "P2S"},
  61. )
  62. db.add(lib)
  63. await db.flush()
  64. db.add(PrintQueueItem(status="pending", position=1, printer_id=1, library_file_id=lib.id))
  65. await db.commit()
  66. async def _add_drying_row(ctx, **kwargs):
  67. async with ctx.session_maker() as db:
  68. defaults = {
  69. "printer_id": 1,
  70. "ams_id": 0,
  71. "temp": 65,
  72. "duration_hours": 8,
  73. "start_after": _utcnow_naive() - timedelta(minutes=1),
  74. }
  75. defaults.update(kwargs)
  76. row = ScheduledDrying(**defaults)
  77. db.add(row)
  78. await db.commit()
  79. await db.refresh(row)
  80. return row
  81. async def _run(ctx, scheduler, *, state, launched=None):
  82. """One real check_queue pass with only the print-side collaborators mocked."""
  83. patches = [
  84. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  85. patch("backend.app.core.database.async_session", ctx.session_maker),
  86. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  87. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=state)),
  88. patch(
  89. "backend.app.services.print_scheduler.printer_manager.send_drying_command",
  90. MagicMock(return_value=True),
  91. ),
  92. patch(
  93. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  94. AsyncMock(return_value={}),
  95. ),
  96. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  97. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  98. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  99. patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
  100. ]
  101. with ExitStack() as stack:
  102. for p in patches:
  103. stack.enter_context(p)
  104. return await scheduler.check_queue()
  105. @pytest.mark.asyncio
  106. async def test_a_pass_with_no_scheduled_rows_still_dispatches_prints(queue_db):
  107. """The case every existing install is in: the feature is present and unused."""
  108. await _add_print_item(queue_db)
  109. scheduler = PrintScheduler()
  110. launched = MagicMock()
  111. await _run(queue_db, scheduler, state=_state(), launched=launched)
  112. launched.assert_called_once()
  113. assert launched.call_args[0][0] # at least one dispatch id
  114. @pytest.mark.asyncio
  115. async def test_a_due_row_dispatches_through_the_real_queue_pass(queue_db):
  116. row = await _add_drying_row(queue_db)
  117. scheduler = PrintScheduler()
  118. await _run(queue_db, scheduler, state=_state())
  119. async with queue_db.session_maker() as db:
  120. stored = (await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == row.id))).scalar_one()
  121. assert stored.status == "running"
  122. assert 1 in scheduler._drying_in_progress
  123. @pytest.mark.asyncio
  124. async def test_a_scheduled_row_does_not_stop_the_queue(queue_db):
  125. """A drying row and a print item in the same pass: the print still goes."""
  126. await _add_drying_row(queue_db)
  127. await _add_print_item(queue_db)
  128. scheduler = PrintScheduler()
  129. launched = MagicMock()
  130. await _run(queue_db, scheduler, state=_state(), launched=launched)
  131. launched.assert_called_once()
  132. assert launched.call_args[0][0]
  133. @pytest.mark.asyncio
  134. async def test_a_failed_row_does_not_stop_the_queue(queue_db):
  135. """An unsupported model fails the row at dispatch. That is the one path that
  136. writes an error mid-pass, so the print behind it must still dispatch."""
  137. async with queue_db.session_maker() as db:
  138. printer = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
  139. printer.model = "P1S" # drying is screen-only here
  140. await db.commit()
  141. await _add_drying_row(queue_db)
  142. await _add_print_item(queue_db)
  143. scheduler = PrintScheduler()
  144. launched = MagicMock()
  145. await _run(queue_db, scheduler, state=_state(), launched=launched)
  146. async with queue_db.session_maker() as db:
  147. stored = (await db.execute(select(ScheduledDrying))).scalars().one()
  148. assert stored.status == "failed"
  149. assert stored.error_message
  150. launched.assert_called_once()
  151. assert launched.call_args[0][0]