test_scheduler_model_mismatch.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """Cross-model dispatch gate (#2578).
  2. A queue row can carry a target_model that does not match the model its 3MF was
  3. sliced for (pre-fix UI wrote such rows silently; direct API writes could too).
  4. G-code is only interchangeable within an explicit family — everything else must
  5. be held back at the dispatch boundary, because model-based assignment has no
  6. human in the loop to catch it.
  7. Covers the pure helper (``is_gcode_compatible``) and the scheduler behaviour:
  8. a mismatched pending item is never offered a printer and gets an actionable
  9. ``waiting_reason`` instead of being dispatched.
  10. """
  11. from contextlib import ExitStack
  12. from types import SimpleNamespace
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  17. import backend.app.models # noqa: F401 - populate Base.metadata
  18. from backend.app.core.database import Base
  19. from backend.app.models.archive import PrintArchive
  20. from backend.app.models.library import LibraryFile
  21. from backend.app.models.print_queue import PrintQueueItem
  22. from backend.app.models.printer import Printer
  23. from backend.app.services.print_scheduler import PrintScheduler
  24. from backend.app.utils.printer_models import is_gcode_compatible
  25. # ---------------------------------------------------------------------------
  26. # is_gcode_compatible
  27. # ---------------------------------------------------------------------------
  28. def test_same_model_is_compatible():
  29. assert is_gcode_compatible("X1C", "X1C")
  30. assert is_gcode_compatible("H2D", "H2D")
  31. def test_x1_p1_family_is_interchangeable():
  32. # Intentional mixed-farm workflow: X1-sliced jobs on P1S/P1P and back.
  33. assert is_gcode_compatible("X1C", "P1S")
  34. assert is_gcode_compatible("X1C", "P1P")
  35. assert is_gcode_compatible("P1S", "X1E")
  36. assert is_gcode_compatible("X1", "P1P")
  37. def test_cross_family_is_blocked():
  38. # The reporter's case: X1C-sliced G-code targeted at an H2D.
  39. assert not is_gcode_compatible("X1C", "H2D")
  40. assert not is_gcode_compatible("P1S", "H2D")
  41. assert not is_gcode_compatible("X1C", "A1")
  42. assert not is_gcode_compatible("A1", "A1 Mini")
  43. assert not is_gcode_compatible("H2D", "H2S")
  44. assert not is_gcode_compatible("X1C", "P2S")
  45. def test_unknown_metadata_is_failsafe_compatible():
  46. # Legacy files without sliced_for_model can't be validated — never block.
  47. assert is_gcode_compatible(None, "H2D")
  48. assert is_gcode_compatible("X1C", None)
  49. assert is_gcode_compatible(None, None)
  50. assert is_gcode_compatible("", "H2D")
  51. def test_normalization_spaces_dashes_case():
  52. assert is_gcode_compatible("x1c", "X1C")
  53. assert is_gcode_compatible("A1 Mini", "A1-MINI")
  54. assert is_gcode_compatible("H2D Pro", "H2DPRO")
  55. def test_internal_codes_resolve_to_short_names():
  56. # slice_info printer_model_id codes compare equal to their short names.
  57. assert is_gcode_compatible("C11", "X1C")
  58. assert is_gcode_compatible("O1D", "H2D")
  59. assert is_gcode_compatible("C11", "P1S") # X1C → family with P1S
  60. assert not is_gcode_compatible("C11", "H2D")
  61. # ---------------------------------------------------------------------------
  62. # Scheduler: mismatched rows are held back, not dispatched
  63. # ---------------------------------------------------------------------------
  64. @pytest.fixture
  65. async def queue_db():
  66. """In-memory DB seeded with one idle H2D printer."""
  67. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  68. async with engine.begin() as conn:
  69. await conn.run_sync(Base.metadata.create_all)
  70. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  71. async with session_maker() as db:
  72. db.add(
  73. Printer(
  74. id=1,
  75. name="H2D-1",
  76. serial_number="H2D0001",
  77. ip_address="10.0.0.1",
  78. access_code="x",
  79. model="H2D",
  80. is_active=True,
  81. )
  82. )
  83. await db.commit()
  84. try:
  85. yield SimpleNamespace(session_maker=session_maker)
  86. finally:
  87. await engine.dispose()
  88. async def _add_model_item(ctx, *, target_model, sliced_for_model=None, library_meta=None):
  89. """Seed one pending model-based queue item, archive- or library-backed."""
  90. async with ctx.session_maker() as db:
  91. if library_meta is not None:
  92. source = LibraryFile(
  93. filename="job.3mf",
  94. file_path="/library/job.3mf",
  95. file_size=10,
  96. file_type="3mf",
  97. file_metadata=library_meta,
  98. )
  99. db.add(source)
  100. await db.flush()
  101. item = PrintQueueItem(library_file_id=source.id, target_model=target_model, status="pending", position=1)
  102. else:
  103. source = PrintArchive(
  104. filename="job.3mf",
  105. file_path="archives/job.3mf",
  106. file_size=10,
  107. status="completed",
  108. sliced_for_model=sliced_for_model,
  109. )
  110. db.add(source)
  111. await db.flush()
  112. item = PrintQueueItem(archive_id=source.id, target_model=target_model, status="pending", position=1)
  113. db.add(item)
  114. await db.commit()
  115. return item.id
  116. async def _run_check_queue(ctx, scheduler, finder, waiting_notification):
  117. patches = [
  118. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  119. patch("backend.app.core.database.async_session", ctx.session_maker),
  120. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  121. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  122. patch(
  123. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  124. waiting_notification,
  125. ),
  126. patch.object(scheduler, "_find_idle_printer_for_model", finder),
  127. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  128. ]
  129. with ExitStack() as stack:
  130. for p in patches:
  131. stack.enter_context(p)
  132. return await scheduler.check_queue()
  133. async def _get_item(ctx, item_id):
  134. async with ctx.session_maker() as db:
  135. return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  136. @pytest.mark.asyncio
  137. async def test_mismatched_item_is_held_and_never_offered_a_printer(queue_db):
  138. """target_model=H2D on an X1C-sliced archive: the matcher must not even run."""
  139. item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model="X1C")
  140. scheduler = PrintScheduler()
  141. finder = AsyncMock(return_value=(1, None)) # would happily offer the H2D
  142. waiting = AsyncMock()
  143. await _run_check_queue(queue_db, scheduler, finder, waiting)
  144. finder.assert_not_awaited()
  145. item = await _get_item(queue_db, item_id)
  146. assert item.status == "pending"
  147. assert item.printer_id is None
  148. assert "sliced for X1C" in item.waiting_reason
  149. # Actionable reason → the user is notified once on transition
  150. waiting.assert_awaited_once()
  151. @pytest.mark.asyncio
  152. async def test_mismatched_library_item_is_held(queue_db):
  153. """Same gate for library-file-backed rows (metadata JSON, not a column)."""
  154. item_id = await _add_model_item(queue_db, target_model="H2D", library_meta={"sliced_for_model": "P1S"})
  155. scheduler = PrintScheduler()
  156. finder = AsyncMock(return_value=(1, None))
  157. await _run_check_queue(queue_db, scheduler, finder, AsyncMock())
  158. finder.assert_not_awaited()
  159. item = await _get_item(queue_db, item_id)
  160. assert item.status == "pending"
  161. assert "sliced for P1S" in item.waiting_reason
  162. @pytest.mark.asyncio
  163. async def test_compatible_and_unknown_items_still_reach_the_matcher(queue_db):
  164. """Family-compatible (X1C→P1S would be, but here same-model) and
  165. metadata-less rows must keep flowing to the printer matcher."""
  166. item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model="H2D")
  167. scheduler = PrintScheduler()
  168. # Matcher returns no printer so the pass stops after the gate — all we
  169. # assert is that the gate let the item through.
  170. finder = AsyncMock(return_value=(None, "Busy: H2D-1 (Printing)"))
  171. await _run_check_queue(queue_db, scheduler, finder, AsyncMock())
  172. finder.assert_awaited_once()
  173. item = await _get_item(queue_db, item_id)
  174. assert item.status == "pending"
  175. assert item.waiting_reason == "Busy: H2D-1 (Printing)"
  176. @pytest.mark.asyncio
  177. async def test_legacy_item_without_metadata_reaches_the_matcher(queue_db):
  178. item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model=None)
  179. scheduler = PrintScheduler()
  180. finder = AsyncMock(return_value=(None, "Busy: H2D-1 (Printing)"))
  181. await _run_check_queue(queue_db, scheduler, finder, AsyncMock())
  182. finder.assert_awaited_once()
  183. item = await _get_item(queue_db, item_id)
  184. assert item.status == "pending"