test_previous_success_per_printer.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """'Only if the previous print succeeded' is about the printer a job lands on."""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. from unittest.mock import AsyncMock, patch
  7. import pytest
  8. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  9. import backend.app.models # noqa: F401
  10. from backend.app.core.database import Base
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.print_queue import PrintQueueItem
  13. from backend.app.models.printer import Printer
  14. from backend.app.services.print_scheduler import MODEL_GATE_WAIT_REASON, PrintScheduler
  15. from backend.tests.unit.test_scheduler_busy_reasons_3018 import _scheduler
  16. @pytest.fixture
  17. async def farm(tmp_path):
  18. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  19. async with engine.begin() as conn:
  20. await conn.run_sync(Base.metadata.create_all)
  21. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  22. base_dir = tmp_path / "farm"
  23. (base_dir / "archives").mkdir(parents=True)
  24. (base_dir / "archives" / "job.3mf").write_bytes(b"archive payload")
  25. async with session_maker() as db:
  26. printers = [
  27. Printer(name=name, serial_number=name, ip_address=f"10.0.0.{i}", access_code="code", model="X1C")
  28. for i, name in enumerate(("A", "B"), start=1)
  29. ]
  30. db.add_all(printers)
  31. await db.flush()
  32. archive = PrintArchive(
  33. printer_id=printers[0].id,
  34. filename="job.3mf",
  35. file_path="archives/job.3mf",
  36. file_size=15,
  37. print_time_seconds=120,
  38. status="completed",
  39. sliced_for_model="X1C",
  40. )
  41. db.add(archive)
  42. await db.flush()
  43. ids = SimpleNamespace(a=printers[0].id, b=printers[1].id, archive=archive.id)
  44. await db.commit()
  45. try:
  46. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
  47. finally:
  48. await engine.dispose()
  49. async def _last_print(farm, printer_id: int, status: str) -> None:
  50. async with farm.session_maker() as db:
  51. db.add(
  52. PrintQueueItem(
  53. printer_id=printer_id,
  54. archive_id=farm.ids.archive,
  55. status=status,
  56. position=0,
  57. completed_at=datetime(2026, 10, 1, tzinfo=timezone.utc),
  58. )
  59. )
  60. await db.commit()
  61. async def _model_job(farm) -> int:
  62. async with farm.session_maker() as db:
  63. item = PrintQueueItem(
  64. target_model="X1C",
  65. archive_id=farm.ids.archive,
  66. status="pending",
  67. position=1,
  68. require_previous_success=True,
  69. )
  70. db.add(item)
  71. await db.commit()
  72. return item.id
  73. async def _run(farm, item_id: int, waiting=None) -> PrintQueueItem:
  74. async def matcher(db, model, exclude, *args, **kwargs):
  75. for printer_id in (farm.ids.a, farm.ids.b):
  76. if printer_id not in exclude:
  77. return printer_id, None
  78. return None, "All matching printers are busy"
  79. async with _scheduler(farm, idle=True) as scheduler:
  80. with (
  81. patch.object(scheduler, "_find_idle_printer_for_model", matcher),
  82. patch(
  83. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  84. waiting or AsyncMock(),
  85. ),
  86. ):
  87. await scheduler.check_queue()
  88. async with farm.session_maker() as db:
  89. return await db.get(PrintQueueItem, item_id)
  90. async def test_a_printer_whose_last_print_failed_is_passed_over(farm):
  91. await _last_print(farm, farm.ids.a, "failed")
  92. item_id = await _model_job(farm)
  93. item = await _run(farm, item_id)
  94. assert item.status != "skipped"
  95. assert item.printer_id == farm.ids.b
  96. async def test_the_job_waits_when_every_printer_failed_last(farm):
  97. await _last_print(farm, farm.ids.a, "failed")
  98. await _last_print(farm, farm.ids.b, "aborted")
  99. item_id = await _model_job(farm)
  100. item = await _run(farm, item_id)
  101. assert item.status == "pending"
  102. assert item.printer_id is None
  103. assert item.waiting_reason == MODEL_GATE_WAIT_REASON
  104. async def test_the_check_reads_the_printer_it_is_given(db_session, printer_factory):
  105. a, b = await printer_factory(), await printer_factory()
  106. db_session.add(PrintQueueItem(printer_id=a.id, status="failed", completed_at=datetime.now(timezone.utc)))
  107. job = PrintQueueItem(target_model="X1C", status="pending", require_previous_success=True)
  108. db_session.add(job)
  109. await db_session.commit()
  110. scheduler = PrintScheduler()
  111. assert await scheduler._check_previous_success(db_session, job, printer_id=a.id) is False
  112. assert await scheduler._check_previous_success(db_session, job, printer_id=b.id) is True
  113. async def test_a_row_ended_without_a_time_is_not_the_latest(db_session, printer_factory):
  114. printer = await printer_factory()
  115. db_session.add(PrintQueueItem(printer_id=printer.id, status="failed", completed_at=datetime.now(timezone.utc)))
  116. await db_session.flush()
  117. db_session.add(PrintQueueItem(printer_id=printer.id, status="cancelled", completed_at=None))
  118. job = PrintQueueItem(printer_id=printer.id, status="pending", require_previous_success=True)
  119. db_session.add(job)
  120. await db_session.commit()
  121. assert await PrintScheduler()._check_previous_success(db_session, job) is False
  122. async def test_cancelling_an_order_stamps_when_its_jobs_ended(
  123. async_client, db_session, printer_factory, archive_factory
  124. ):
  125. from backend.tests.integration.test_print_batch_orders import _create_order, _queue_item
  126. printer = await printer_factory()
  127. archive = await archive_factory(printer.id)
  128. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  129. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  130. response = await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
  131. assert response.status_code == 200, response.text
  132. db_session.expire_all()
  133. row = await db_session.get(PrintQueueItem, item["id"])
  134. assert row.status == "cancelled"
  135. assert row.completed_at is not None
  136. async def test_a_job_that_waited_for_a_busy_printer_says_when_it_now_needs_you(farm):
  137. await _last_print(farm, farm.ids.a, "failed")
  138. await _last_print(farm, farm.ids.b, "failed")
  139. item_id = await _model_job(farm)
  140. async with farm.session_maker() as db:
  141. (await db.get(PrintQueueItem, item_id)).waiting_reason = "Busy: A, B"
  142. await db.commit()
  143. waiting = AsyncMock()
  144. await _run(farm, item_id, waiting)
  145. waiting.assert_awaited_once()
  146. assert waiting.await_args.kwargs["waiting_reason"] == MODEL_GATE_WAIT_REASON
  147. async def test_resuming_a_model_clears_the_gate_on_its_printers(async_client, db_session, printer_factory):
  148. x1c_a = await printer_factory(model="X1C")
  149. x1c_b = await printer_factory(model="X1C")
  150. p1s = await printer_factory(model="P1S")
  151. ids = {"a": x1c_a.id, "b": x1c_b.id, "p": p1s.id}
  152. for printer_id in ids.values():
  153. db_session.add(PrintQueueItem(printer_id=printer_id, status="failed", completed_at=datetime.now(timezone.utc)))
  154. await db_session.commit()
  155. response = await async_client.post("/api/v1/queue/model/X1C/resume")
  156. assert response.status_code == 200, response.text
  157. assert response.json() == {"acknowledged": 2, "printers": 2}
  158. db_session.expire_all()
  159. from sqlalchemy import select
  160. rows = (await db_session.execute(select(PrintQueueItem))).scalars().all()
  161. acknowledged = {row.printer_id: row.gate_acknowledged for row in rows}
  162. assert acknowledged == {ids["a"]: True, ids["b"]: True, ids["p"]: False}
  163. async def test_resuming_a_model_keeps_to_the_callers_printers(async_client, db_session, printer_factory):
  164. from sqlalchemy import select
  165. from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
  166. ours, theirs = await printer_factory(model="X1C"), await printer_factory(model="X1C")
  167. ours_id, theirs_id = ours.id, theirs.id
  168. for printer_id in (ours_id, theirs_id):
  169. db_session.add(PrintQueueItem(printer_id=printer_id, status="failed", completed_at=datetime.now(timezone.utc)))
  170. await db_session.commit()
  171. token = await _admin_token(async_client)
  172. perms = await _group(async_client, token, "perms", permissions=["printers:read", "queue:read", "queue:update_all"])
  173. team = await _group(async_client, token, "team", printer_ids=[ours_id])
  174. member = _auth((await _user(async_client, token, "member", [perms, team]))[0])
  175. response = await async_client.post("/api/v1/queue/model/X1C/resume", headers=member)
  176. assert response.json() == {"acknowledged": 1, "printers": 1}
  177. db_session.expire_all()
  178. rows = (await db_session.execute(select(PrintQueueItem))).scalars().all()
  179. assert {row.printer_id: row.gate_acknowledged for row in rows} == {ours_id: True, theirs_id: False}
  180. def test_the_queue_page_knows_the_wait_reason():
  181. """The queue page offers Resume for jobs waiting with exactly this reason."""
  182. page = Path(__file__).resolve().parents[3] / "frontend" / "src" / "pages" / "QueuePage.tsx"
  183. assert f"'{MODEL_GATE_WAIT_REASON}'" in page.read_text()