test_failed_start_releases_printer.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. """A queued print that never starts leaves no claim on the printer's next print.
  2. Dispatching a queue item tells the rest of Bambuddy that the printer's next
  3. print is the item's: the item's owner is recorded as the print's user, and an
  4. archive print is registered as expected. When that print never starts, both
  5. must go, or a print started on the printer afterwards is taken for it.
  6. """
  7. from __future__ import annotations
  8. from types import SimpleNamespace
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.models.print_queue import PrintQueueItem
  12. from backend.app.services.print_scheduler import DISPATCH_MAX_ATTEMPTS, PrintScheduler, scheduler
  13. from backend.app.services.printer_manager import printer_manager
  14. PRINTER = 42
  15. ITEM = 1
  16. @pytest.fixture
  17. async def db_session():
  18. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  19. import backend.app.models # noqa: F401
  20. from backend.app.core.database import Base
  21. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  22. async with engine.begin() as conn:
  23. await conn.run_sync(Base.metadata.create_all)
  24. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  25. async with session_maker() as db:
  26. db.add(PrintQueueItem(id=ITEM, printer_id=PRINTER, archive_id=99, status="printing"))
  27. await db.commit()
  28. try:
  29. yield session_maker
  30. finally:
  31. await engine.dispose()
  32. @pytest.fixture(autouse=True)
  33. def clean_state():
  34. from backend.app import main
  35. tables = ("_expected_prints", "_expected_print_creators", "_expected_print_registered_at")
  36. saved = {name: dict(getattr(main, name)) for name in tables}
  37. for name in tables:
  38. getattr(main, name).clear()
  39. printer_manager.clear_current_print_user(PRINTER)
  40. scheduler._sent_unstarted_print.clear()
  41. yield main
  42. printer_manager.clear_current_print_user(PRINTER)
  43. scheduler._sent_unstarted_print.clear()
  44. for name in tables:
  45. getattr(main, name).clear()
  46. getattr(main, name).update(saved[name])
  47. def _sent(main, item_id: int = ITEM, *, sched=scheduler):
  48. """What a dispatch whose print command went out leaves behind."""
  49. main.register_expected_print(PRINTER, "part.3mf", 99, created_by_id=7)
  50. printer_manager.set_current_print_user(PRINTER, 7, "anna", queue_item_id=item_id)
  51. sched._sent_unstarted_print[item_id] = (PRINTER, "part.3mf", 99)
  52. def _claimed(main) -> bool:
  53. return printer_manager.get_current_print_user(PRINTER) is not None or bool(main._expected_prints)
  54. async def _watch(db_session, status):
  55. with (
  56. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
  57. patch("backend.app.services.print_scheduler.printer_manager.get_client", MagicMock(return_value=MagicMock())),
  58. patch("backend.app.services.print_scheduler.async_session", db_session),
  59. patch("backend.app.core.database.async_session", db_session),
  60. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  61. ):
  62. await PrintScheduler._watchdog_print_start(
  63. queue_item_id=ITEM,
  64. printer_id=PRINTER,
  65. pre_state="IDLE",
  66. pre_subtask_id="OLD",
  67. pre_gcode_file="/old.3mf",
  68. timeout=0.2,
  69. phase_b_timeout=0.2,
  70. poll_interval=0.05,
  71. )
  72. async def _set_printing(db_session):
  73. async with db_session() as db:
  74. (await db.get(PrintQueueItem, ITEM)).status = "printing"
  75. await db.commit()
  76. @pytest.mark.asyncio
  77. async def test_giving_up_on_a_print_releases_the_printer(db_session, clean_state):
  78. for _ in range(DISPATCH_MAX_ATTEMPTS):
  79. await _set_printing(db_session)
  80. _sent(clean_state)
  81. await _watch(db_session, SimpleNamespace(state="IDLE", subtask_id="NEW", gcode_file="/new.3mf"))
  82. async with db_session() as db:
  83. assert (await db.get(PrintQueueItem, ITEM)).status == "failed"
  84. assert not _claimed(clean_state)
  85. @pytest.mark.asyncio
  86. async def test_a_rejected_print_command_releases_the_printer(db_session, clean_state):
  87. from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
  88. _sent(clean_state)
  89. rejected = SimpleNamespace(
  90. state="IDLE",
  91. subtask_id="OLD",
  92. gcode_file="/new.3mf",
  93. hms_errors=[SimpleNamespace(full_code=HMS_MQTT_VERIFY_FAILED)],
  94. )
  95. await _watch(db_session, rejected)
  96. assert not _claimed(clean_state)
  97. @pytest.mark.asyncio
  98. async def test_a_print_that_starts_keeps_its_claim(db_session, clean_state):
  99. _sent(clean_state)
  100. await _watch(db_session, SimpleNamespace(state="RUNNING", subtask_id="NEW", gcode_file="/new.3mf"))
  101. assert printer_manager.get_current_print_user(PRINTER)["user_id"] == 7
  102. assert clean_state._expected_prints
  103. assert ITEM not in scheduler._sent_unstarted_print
  104. def test_a_print_that_started_late_keeps_its_claim(clean_state):
  105. _sent(clean_state)
  106. with patch(
  107. "backend.app.services.print_scheduler.printer_manager.get_status",
  108. MagicMock(return_value=SimpleNamespace(state="RUNNING")),
  109. ):
  110. scheduler._forget_unstarted_print(ITEM, unless_printing=True)
  111. assert _claimed(clean_state)
  112. def test_another_item_keeps_the_printer(clean_state):
  113. _sent(clean_state)
  114. printer_manager.set_current_print_user(PRINTER, 8, "ben", queue_item_id=2)
  115. scheduler._forget_unstarted_print(ITEM, unless_printing=False)
  116. assert printer_manager.get_current_print_user(PRINTER)["user_id"] == 8
  117. def test_cancelling_an_item_after_a_failed_start_releases_the_printer(clean_state):
  118. _sent(clean_state)
  119. scheduler.notify_dispatch_cancelled(ITEM)
  120. assert not _claimed(clean_state)
  121. @pytest.mark.asyncio
  122. async def test_a_dispatch_that_never_sends_releases_the_print_user(clean_state):
  123. sched = PrintScheduler()
  124. async def fake_start_print(db, item):
  125. # What `_start_print` has done by the time the cancel wins the race.
  126. printer_manager.set_current_print_user(PRINTER, 7, "anna", queue_item_id=597)
  127. sched._unconfirmed_print_user[597] = PRINTER
  128. db = MagicMock()
  129. db.get = AsyncMock(return_value=MagicMock(id=597, printer_id=PRINTER))
  130. ctx = MagicMock()
  131. ctx.__aenter__ = AsyncMock(return_value=db)
  132. ctx.__aexit__ = AsyncMock(return_value=False)
  133. with (
  134. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  135. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  136. patch.object(sched, "_start_print", side_effect=fake_start_print),
  137. patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
  138. ):
  139. await sched._dispatch_one(597)
  140. assert printer_manager.get_current_print_user(PRINTER) is None
  141. assert sched._unconfirmed_print_user == {}
  142. @pytest.mark.asyncio
  143. async def test_a_dispatch_records_whose_print_it_claims(db_session, clean_state):
  144. from backend.app.models.printer import Printer
  145. from backend.app.models.user import User
  146. async with db_session() as db:
  147. db.add(User(id=7, username="anna", password_hash="x"))
  148. db.add(Printer(id=PRINTER, name="P", serial_number="00M09A000000042", ip_address="192.0.2.1", access_code="x"))
  149. item = await db.get(PrintQueueItem, ITEM)
  150. item.created_by_id = 7
  151. await db.commit()
  152. sched = PrintScheduler()
  153. await sched._propagate_owner_to_printer_manager(db, item)
  154. assert printer_manager.get_current_print_user(PRINTER)["queue_item_id"] == ITEM
  155. assert sched._unconfirmed_print_user == {ITEM: PRINTER}
  156. @pytest.mark.asyncio
  157. async def test_a_print_sent_back_for_another_try_vouches_only_for_its_own_file(db_session, clean_state):
  158. _sent(clean_state)
  159. await _watch(db_session, SimpleNamespace(state="IDLE", subtask_id="NEW", gcode_file="/new.3mf"))
  160. async with db_session() as db:
  161. assert (await db.get(PrintQueueItem, ITEM)).status == "pending"
  162. assert printer_manager.get_current_print_user(PRINTER) is None
  163. assert clean_state._expected_prints, "a late start of its own file must still match its archive"
  164. # Cancelling it then withdraws the rest.
  165. scheduler.notify_dispatch_cancelled(ITEM)
  166. assert not _claimed(clean_state)
  167. @pytest.mark.asyncio
  168. async def test_closing_a_stuck_item_releases_the_printer(db_session, clean_state):
  169. _sent(clean_state)
  170. sched = PrintScheduler()
  171. sched._sent_unstarted_print[ITEM] = scheduler._sent_unstarted_print[ITEM]
  172. sched._terminal_since[PRINTER] = -10_000.0
  173. with (
  174. patch("backend.app.services.print_scheduler.async_session", db_session),
  175. patch(
  176. "backend.app.services.print_scheduler.printer_manager.get_status",
  177. MagicMock(return_value=SimpleNamespace(state="IDLE", connected=True)),
  178. ),
  179. ):
  180. await sched._close_stranded_printing_items()
  181. async with db_session() as db:
  182. assert (await db.get(PrintQueueItem, ITEM)).status != "printing"
  183. assert not _claimed(clean_state)
  184. def test_deleting_a_printer_drops_its_sent_prints(clean_state):
  185. sched = PrintScheduler()
  186. sched._sent_unstarted_print[ITEM] = (PRINTER, "part.3mf", 99)
  187. sched._sent_unstarted_print[2] = (7, None, None)
  188. sched.forget_printer(PRINTER)
  189. assert sched._sent_unstarted_print == {2: (7, None, None)}