test_scheduler_clear_plate.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. """Tests for the clear plate queue flow in the print scheduler."""
  2. import logging
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. from backend.app.services.print_scheduler import PrintScheduler
  6. from backend.app.services.printer_manager import PrinterManager
  7. class TestPrinterManagerPlateCleared:
  8. """Test the plate-cleared flag management in PrinterManager."""
  9. @pytest.fixture
  10. def manager(self):
  11. return PrinterManager()
  12. def test_plate_cleared_initially_false(self, manager):
  13. """No printers should have plate cleared by default."""
  14. assert not manager.is_awaiting_plate_clear(1)
  15. assert not manager.is_awaiting_plate_clear(999)
  16. def test_set_plate_cleared(self, manager):
  17. """Setting plate cleared should make is_awaiting_plate_clear return True."""
  18. manager.set_awaiting_plate_clear(1, True)
  19. assert manager.is_awaiting_plate_clear(1)
  20. assert not manager.is_awaiting_plate_clear(2)
  21. def test_consume_plate_cleared(self, manager):
  22. """Consuming plate cleared should reset the flag."""
  23. manager.set_awaiting_plate_clear(1, True)
  24. assert manager.is_awaiting_plate_clear(1)
  25. manager.set_awaiting_plate_clear(1, False)
  26. assert not manager.is_awaiting_plate_clear(1)
  27. def test_consume_plate_cleared_idempotent(self, manager):
  28. """Consuming when not set should not raise."""
  29. manager.set_awaiting_plate_clear(1, False) # Should not raise
  30. assert not manager.is_awaiting_plate_clear(1)
  31. def test_set_plate_cleared_multiple_printers(self, manager):
  32. """Plate cleared should be tracked per printer."""
  33. manager.set_awaiting_plate_clear(1, True)
  34. manager.set_awaiting_plate_clear(3, True)
  35. assert manager.is_awaiting_plate_clear(1)
  36. assert not manager.is_awaiting_plate_clear(2)
  37. assert manager.is_awaiting_plate_clear(3)
  38. def test_consume_only_affects_target_printer(self, manager):
  39. """Consuming plate cleared for one printer should not affect others."""
  40. manager.set_awaiting_plate_clear(1, True)
  41. manager.set_awaiting_plate_clear(2, True)
  42. manager.set_awaiting_plate_clear(1, False)
  43. assert not manager.is_awaiting_plate_clear(1)
  44. assert manager.is_awaiting_plate_clear(2)
  45. class TestAwaitingPlateClearPersistence:
  46. """Verify the awaiting-plate-clear flag round-trips through the DB (#961)."""
  47. @pytest.mark.asyncio
  48. async def test_load_rehydrates_in_memory_set_from_db(self):
  49. """Printers flagged in DB must re-appear in the in-memory set on startup."""
  50. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  51. # Ensure all models are imported so Base.metadata includes them
  52. import backend.app.models # noqa: F401
  53. from backend.app.core.database import Base
  54. from backend.app.models.printer import Printer
  55. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  56. async with engine.begin() as conn:
  57. await conn.run_sync(Base.metadata.create_all)
  58. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  59. # Seed: two printers, one flagged awaiting, one not
  60. async with session_maker() as db:
  61. db.add_all(
  62. [
  63. Printer(
  64. id=1,
  65. name="P1",
  66. serial_number="S1",
  67. ip_address="1.1.1.1",
  68. access_code="x",
  69. awaiting_plate_clear=True,
  70. ),
  71. Printer(
  72. id=2,
  73. name="P2",
  74. serial_number="S2",
  75. ip_address="2.2.2.2",
  76. access_code="y",
  77. awaiting_plate_clear=False,
  78. ),
  79. ]
  80. )
  81. await db.commit()
  82. # Point the manager's session factory at our in-memory DB and load
  83. manager = PrinterManager()
  84. with patch("backend.app.core.database.async_session", session_maker):
  85. await manager.load_awaiting_plate_clear_from_db()
  86. assert manager.is_awaiting_plate_clear(1) is True
  87. assert manager.is_awaiting_plate_clear(2) is False
  88. await engine.dispose()
  89. @pytest.mark.asyncio
  90. async def test_persist_writes_flag_to_db(self):
  91. """set_awaiting_plate_clear + _persist writes the flag to the DB row."""
  92. from sqlalchemy import select
  93. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  94. import backend.app.models # noqa: F401
  95. from backend.app.core.database import Base
  96. from backend.app.models.printer import Printer
  97. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  98. async with engine.begin() as conn:
  99. await conn.run_sync(Base.metadata.create_all)
  100. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  101. async with session_maker() as db:
  102. db.add(
  103. Printer(
  104. id=1,
  105. name="P1",
  106. serial_number="S1",
  107. ip_address="1.1.1.1",
  108. access_code="x",
  109. awaiting_plate_clear=False,
  110. )
  111. )
  112. await db.commit()
  113. manager = PrinterManager()
  114. with patch("backend.app.core.database.async_session", session_maker):
  115. await manager._persist_awaiting_plate_clear(1, True)
  116. async with session_maker() as db:
  117. row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
  118. assert row.awaiting_plate_clear is True
  119. with patch("backend.app.core.database.async_session", session_maker):
  120. await manager._persist_awaiting_plate_clear(1, False)
  121. async with session_maker() as db:
  122. row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
  123. assert row.awaiting_plate_clear is False
  124. await engine.dispose()
  125. @pytest.mark.asyncio
  126. async def test_persist_missing_printer_does_not_raise(self):
  127. """Persisting for a non-existent printer should be a silent no-op."""
  128. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  129. import backend.app.models # noqa: F401
  130. from backend.app.core.database import Base
  131. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  132. async with engine.begin() as conn:
  133. await conn.run_sync(Base.metadata.create_all)
  134. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  135. manager = PrinterManager()
  136. with patch("backend.app.core.database.async_session", session_maker):
  137. # Should not raise even though printer 999 does not exist
  138. await manager._persist_awaiting_plate_clear(999, True)
  139. await engine.dispose()
  140. class TestSchedulerIdleCheckWithPlateCleared:
  141. """Test _is_printer_idle interactions with the awaiting-plate-clear flag (#961)."""
  142. @pytest.fixture
  143. def scheduler(self):
  144. return PrintScheduler()
  145. @patch("backend.app.services.print_scheduler.printer_manager")
  146. def test_idle_state_is_idle(self, mock_pm, scheduler):
  147. """IDLE state with no awaiting flag → idle."""
  148. mock_pm.is_connected.return_value = True
  149. mock_pm.get_status.return_value = MagicMock(state="IDLE")
  150. mock_pm.is_awaiting_plate_clear.return_value = False
  151. assert scheduler._is_printer_idle(1) is True
  152. @patch("backend.app.services.print_scheduler.printer_manager")
  153. def test_running_state_not_idle(self, mock_pm, scheduler):
  154. """RUNNING state is never idle."""
  155. mock_pm.is_connected.return_value = True
  156. mock_pm.get_status.return_value = MagicMock(state="RUNNING")
  157. mock_pm.is_awaiting_plate_clear.return_value = False
  158. assert scheduler._is_printer_idle(1) is False
  159. @patch("backend.app.services.print_scheduler.printer_manager")
  160. def test_finish_state_not_idle_when_awaiting(self, mock_pm, scheduler):
  161. """FINISH + awaiting plate-clear ack → NOT idle."""
  162. mock_pm.is_connected.return_value = True
  163. mock_pm.get_status.return_value = MagicMock(state="FINISH")
  164. mock_pm.is_awaiting_plate_clear.return_value = True
  165. assert scheduler._is_printer_idle(1) is False
  166. @patch("backend.app.services.print_scheduler.printer_manager")
  167. def test_finish_state_idle_when_acknowledged(self, mock_pm, scheduler):
  168. """FINISH with flag cleared → idle."""
  169. mock_pm.is_connected.return_value = True
  170. mock_pm.get_status.return_value = MagicMock(state="FINISH")
  171. mock_pm.is_awaiting_plate_clear.return_value = False
  172. assert scheduler._is_printer_idle(1) is True
  173. @patch("backend.app.services.print_scheduler.printer_manager")
  174. def test_failed_state_not_idle_when_awaiting(self, mock_pm, scheduler):
  175. """FAILED + awaiting → NOT idle."""
  176. mock_pm.is_connected.return_value = True
  177. mock_pm.get_status.return_value = MagicMock(state="FAILED")
  178. mock_pm.is_awaiting_plate_clear.return_value = True
  179. assert scheduler._is_printer_idle(1) is False
  180. @patch("backend.app.services.print_scheduler.printer_manager")
  181. def test_failed_state_idle_when_acknowledged(self, mock_pm, scheduler):
  182. """FAILED with flag cleared → idle."""
  183. mock_pm.is_connected.return_value = True
  184. mock_pm.get_status.return_value = MagicMock(state="FAILED")
  185. mock_pm.is_awaiting_plate_clear.return_value = False
  186. assert scheduler._is_printer_idle(1) is True
  187. @patch("backend.app.services.print_scheduler.printer_manager")
  188. def test_idle_state_not_idle_when_awaiting_survives_power_cycle(self, mock_pm, scheduler):
  189. """Regression for #961: after Auto Off power-cycles the printer it boots into IDLE
  190. with no memory of the previous finish. The persisted awaiting flag must still gate
  191. the queue — IDLE + awaiting → NOT idle.
  192. """
  193. mock_pm.is_connected.return_value = True
  194. mock_pm.get_status.return_value = MagicMock(state="IDLE")
  195. mock_pm.is_awaiting_plate_clear.return_value = True
  196. assert scheduler._is_printer_idle(1) is False
  197. @patch("backend.app.services.print_scheduler.printer_manager")
  198. def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
  199. mock_pm.is_connected.return_value = False
  200. assert scheduler._is_printer_idle(1) is False
  201. @patch("backend.app.services.print_scheduler.printer_manager")
  202. def test_no_status_not_idle(self, mock_pm, scheduler):
  203. mock_pm.is_connected.return_value = True
  204. mock_pm.get_status.return_value = None
  205. assert scheduler._is_printer_idle(1) is False
  206. @patch("backend.app.services.print_scheduler.printer_manager")
  207. def test_finish_state_idle_when_require_plate_clear_disabled(self, mock_pm, scheduler):
  208. """FINISH is idle when require_plate_clear=False, regardless of awaiting flag."""
  209. mock_pm.is_connected.return_value = True
  210. mock_pm.get_status.return_value = MagicMock(state="FINISH")
  211. mock_pm.is_awaiting_plate_clear.return_value = True
  212. assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
  213. @patch("backend.app.services.print_scheduler.printer_manager")
  214. def test_failed_state_idle_when_require_plate_clear_disabled(self, mock_pm, scheduler):
  215. mock_pm.is_connected.return_value = True
  216. mock_pm.get_status.return_value = MagicMock(state="FAILED")
  217. mock_pm.is_awaiting_plate_clear.return_value = True
  218. assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
  219. @patch("backend.app.services.print_scheduler.printer_manager")
  220. def test_running_state_not_idle_even_when_require_plate_clear_disabled(self, mock_pm, scheduler):
  221. mock_pm.is_connected.return_value = True
  222. mock_pm.get_status.return_value = MagicMock(state="RUNNING")
  223. mock_pm.is_awaiting_plate_clear.return_value = False
  224. assert scheduler._is_printer_idle(1, require_plate_clear=False) is False
  225. @patch("backend.app.services.print_scheduler.printer_manager")
  226. def test_idle_state_unaffected_by_require_plate_clear(self, mock_pm, scheduler):
  227. mock_pm.is_connected.return_value = True
  228. mock_pm.get_status.return_value = MagicMock(state="IDLE")
  229. mock_pm.is_awaiting_plate_clear.return_value = False
  230. assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
  231. class TestSchedulerQueueCheckLogging:
  232. """Test queue check logging when pending items are found (#374)."""
  233. @pytest.fixture
  234. def scheduler(self):
  235. return PrintScheduler()
  236. @pytest.mark.asyncio
  237. @patch("backend.app.services.print_scheduler.printer_manager")
  238. async def test_check_queue_logs_pending_items(self, mock_pm, scheduler, caplog):
  239. """Verify pending items are logged when found in check_queue."""
  240. mock_item = MagicMock()
  241. mock_item.id = 42
  242. mock_item.printer_id = 1
  243. mock_item.archive_id = 100
  244. mock_item.library_file_id = None
  245. mock_item.scheduled_time = None
  246. mock_item.manual_start = False
  247. mock_item.target_model = None
  248. mock_pm.is_connected.return_value = True
  249. mock_pm.get_status.return_value = MagicMock(state="RUNNING")
  250. mock_result = MagicMock()
  251. mock_result.scalars.return_value.all.return_value = [mock_item]
  252. with (
  253. patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx,
  254. caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"),
  255. ):
  256. mock_db = AsyncMock()
  257. mock_db.execute = AsyncMock(return_value=mock_result)
  258. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  259. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  260. await scheduler.check_queue()
  261. queue_logs = [r for r in caplog.records if "Queue check" in r.message]
  262. assert len(queue_logs) == 1
  263. assert "1 pending items" in queue_logs[0].message
  264. assert "42" in queue_logs[0].message # item ID
  265. @pytest.mark.asyncio
  266. async def test_check_queue_no_log_when_empty(self, scheduler, caplog):
  267. """Verify no queue log when no pending items found."""
  268. mock_result = MagicMock()
  269. mock_result.scalars.return_value.all.return_value = []
  270. with (
  271. patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx,
  272. caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"),
  273. ):
  274. mock_db = AsyncMock()
  275. mock_db.execute = AsyncMock(return_value=mock_result)
  276. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  277. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  278. await scheduler.check_queue()
  279. queue_logs = [r for r in caplog.records if "Queue check" in r.message]
  280. assert len(queue_logs) == 0