test_model_path_unmatched_filament.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. """An "any model" job is only given a printer the slot matcher can map (#3280).
  2. Choosing the printer asked cheaper questions than dispatch does: is each
  3. filament type loaded somewhere, is each forced colour loaded somewhere. On a
  4. dual-nozzle printer the matcher only uses a spool on the slot's own nozzle, so
  5. a printer with the filament on the other side passed the choice, the matcher
  6. left the slot unmatched, and the unmatched-filament hold (#2799) staged the job
  7. and gave the printer back. Start then found no printer to check, the next pass
  8. chose the same printer, and the job was held again, with no Print Anyway
  9. offered in between.
  10. Now the choice asks the matcher itself and passes over a printer it cannot
  11. map. When no idle printer can, the job stays pending with the filament it
  12. needs in its reason and is picked up once one is loaded.
  13. """
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.services.print_scheduler import PrintScheduler
  18. from backend.tests.unit.test_scheduler_model_filament_short_3137 import (
  19. _add_item,
  20. _get_item,
  21. _run,
  22. queue_db, # noqa: F401 - fixture
  23. )
  24. NEEDS = ["PLA #FFFFFF (left nozzle)"]
  25. def _unmatched_on(unmatched: set[int]) -> AsyncMock:
  26. """``_unmatched_on`` stand-in: the printers in ``unmatched`` cannot map the job."""
  27. async def check(db, item, printer_id):
  28. return list(NEEDS) if printer_id in unmatched else []
  29. return AsyncMock(side_effect=check)
  30. class TestChoosingAPrinter:
  31. async def _find(self, ctx, unmatched: set[int], **kwargs):
  32. scheduler = PrintScheduler()
  33. async def unmatched_on(pid):
  34. return list(NEEDS) if pid in unmatched else []
  35. with (
  36. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  37. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  38. ):
  39. async with ctx.session_maker() as db:
  40. return await scheduler._find_idle_printer_for_model(
  41. db, "X1C", set(), unmatched_on=unmatched_on, **kwargs
  42. )
  43. @pytest.mark.asyncio
  44. async def test_passes_over_a_printer_the_matcher_cannot_map(self, queue_db):
  45. assert await self._find(queue_db, {1}) == (2, None)
  46. @pytest.mark.asyncio
  47. async def test_no_printer_when_none_can_map_it(self, queue_db):
  48. printer_id, reason = await self._find(queue_db, {1, 2, 3})
  49. assert printer_id is None
  50. assert "PLA #FFFFFF (left nozzle)" in reason
  51. @pytest.mark.asyncio
  52. async def test_colour_ranking_skips_a_printer_the_matcher_cannot_map(self, queue_db):
  53. """Preference overrides rank printers by colour; the best one still has
  54. to be mappable."""
  55. overrides = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]
  56. scheduler = PrintScheduler()
  57. async def unmatched_on(pid):
  58. return list(NEEDS) if pid == 2 else []
  59. with (
  60. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  61. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  62. patch.object(
  63. scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, o: 2 if pid == 2 else 1)
  64. ),
  65. ):
  66. async with queue_db.session_maker() as db:
  67. found = await scheduler._find_idle_printer_for_model(
  68. db, "X1C", set(), filament_overrides=overrides, unmatched_on=unmatched_on
  69. )
  70. assert found == (1, None)
  71. class TestQueuePass:
  72. @pytest.mark.asyncio
  73. async def test_goes_to_a_printer_that_can_map_it(self, queue_db):
  74. item_id = await _add_item(queue_db)
  75. launched = MagicMock()
  76. scheduler = PrintScheduler()
  77. with patch.object(scheduler, "_unmatched_on", _unmatched_on({1})):
  78. await _run(queue_db, scheduler, short=set(), launched=launched)
  79. launched.assert_called_once()
  80. item = await _get_item(queue_db, item_id)
  81. assert item.printer_id == 2
  82. assert item.manual_start is False
  83. @pytest.mark.asyncio
  84. async def test_waits_unassigned_while_no_printer_can_map_it(self, queue_db):
  85. item_id = await _add_item(queue_db)
  86. launched = MagicMock()
  87. scheduler = PrintScheduler()
  88. with patch.object(scheduler, "_unmatched_on", _unmatched_on({1, 2, 3})):
  89. await _run(queue_db, scheduler, short=set(), launched=launched)
  90. launched.assert_not_called()
  91. item = await _get_item(queue_db, item_id)
  92. assert item.printer_id is None
  93. assert item.manual_start is False
  94. assert "PLA #FFFFFF (left nozzle)" in item.waiting_reason
  95. @pytest.mark.asyncio
  96. async def test_a_job_held_for_low_filament_is_not_moved_to_a_printer_that_cannot_map_it(self, queue_db):
  97. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  98. launched = MagicMock()
  99. scheduler = PrintScheduler()
  100. with patch.object(scheduler, "_unmatched_on", _unmatched_on({2})):
  101. await _run(queue_db, scheduler, short={1}, launched=launched)
  102. assert (await _get_item(queue_db, item_id)).printer_id == 3
  103. class TestUnmatchedOn:
  104. """The check itself answers what the unmatched-filament hold would."""
  105. REQ = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 1}]
  106. @staticmethod
  107. def _item(**fields):
  108. values = {
  109. "id": 7,
  110. "skip_filament_check": False,
  111. "filament_overrides": None,
  112. "archive_id": None,
  113. "library_file_id": 3,
  114. "plate_id": 1,
  115. }
  116. values.update(fields)
  117. return SimpleNamespace(**values)
  118. def _scheduler(self, mapping, requirements=REQ, loaded=True):
  119. scheduler = PrintScheduler()
  120. if isinstance(mapping, Exception):
  121. scheduler._compute_ams_mapping_for_printer = AsyncMock(side_effect=mapping)
  122. else:
  123. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=mapping)
  124. scheduler._get_filament_requirements = AsyncMock(
  125. return_value=[dict(r) for r in requirements] if requirements else None
  126. )
  127. scheduler._build_loaded_filaments = MagicMock(
  128. return_value=[{"global_tray_id": 0, "type": "PLA", "color": "#FFFFFF"}] if loaded else []
  129. )
  130. return scheduler
  131. @pytest.mark.asyncio
  132. async def test_an_unmatched_slot_is_named(self):
  133. scheduler = self._scheduler([-1])
  134. with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
  135. assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == NEEDS
  136. @pytest.mark.asyncio
  137. async def test_a_mapped_slot_is_fine(self):
  138. scheduler = self._scheduler([0])
  139. with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
  140. assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == []
  141. @pytest.mark.asyncio
  142. async def test_print_anyway_is_never_unmatched(self):
  143. scheduler = self._scheduler([-1])
  144. assert await scheduler._unmatched_on(AsyncMock(), self._item(skip_filament_check=True), 4) == []
  145. @pytest.mark.asyncio
  146. async def test_no_mapping_counts_only_when_the_printer_reported_trays(self):
  147. """The hold's own rule (#2589): an empty tray list is not knowing yet."""
  148. with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
  149. assert await self._scheduler(None)._unmatched_on(AsyncMock(), self._item(), 4) == NEEDS
  150. assert await self._scheduler(None, loaded=False)._unmatched_on(AsyncMock(), self._item(), 4) == []
  151. @pytest.mark.asyncio
  152. async def test_a_failing_match_does_not_pass_the_printer_over(self):
  153. scheduler = self._scheduler(RuntimeError("boom"))
  154. with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
  155. assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == []
  156. @pytest.mark.asyncio
  157. async def test_an_unreadable_file_is_judged_on_its_forced_colours(self):
  158. forced = '[{"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "force_color_match": true}]'
  159. scheduler = self._scheduler([-1], requirements=None)
  160. with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
  161. assert await scheduler._unmatched_on(AsyncMock(), self._item(filament_overrides=forced), 4) == [
  162. "PLA #A4AAAC"
  163. ]
  164. class TestDualNozzleEndToEnd:
  165. """The reported shape with the real matcher: white PLA on the right nozzle,
  166. a slot that prints on the left. Type checks pass; the matcher refuses."""
  167. REQ = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": ""}]
  168. @staticmethod
  169. def _trays(extruder_id):
  170. return [
  171. {"global_tray_id": 0, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "extruder_id": extruder_id},
  172. ]
  173. def _scheduler(self, extruder_id):
  174. scheduler = PrintScheduler()
  175. scheduler._build_loaded_filaments = MagicMock(return_value=self._trays(extruder_id))
  176. scheduler._get_filament_requirements = AsyncMock(side_effect=lambda db, item: [dict(r) for r in self.REQ])
  177. scheduler._get_bool_setting = AsyncMock(return_value=False)
  178. scheduler._get_job_name = AsyncMock(return_value="bracket")
  179. scheduler._get_printer = AsyncMock(return_value=MagicMock(model="H2D"))
  180. return scheduler
  181. @staticmethod
  182. def _item():
  183. item = MagicMock()
  184. item.id = 9
  185. item.printer_id = 4
  186. item.archive_id = None
  187. item.library_file_id = 3
  188. item.plate_id = 1
  189. item.ams_mapping = None
  190. item.skip_filament_check = False
  191. item.filament_overrides = None
  192. item.manual_start = False
  193. item.waiting_reason = None
  194. return item
  195. @pytest.mark.parametrize(("extruder_id", "held"), [(0, True), (1, False)])
  196. @pytest.mark.asyncio
  197. async def test_the_choice_and_the_hold_agree(self, extruder_id, held):
  198. scheduler = self._scheduler(extruder_id)
  199. item = self._item()
  200. db = AsyncMock()
  201. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  202. pm.get_status.return_value = MagicMock(fila_switch=None, ams_filament_backup=None)
  203. unmatched = await scheduler._unmatched_on(db, item, 4)
  204. await scheduler._ensure_ams_mapping(db, 4, item)
  205. blocked = await scheduler._block_on_unmatched_filament(db, item)
  206. assert bool(unmatched) is held
  207. assert blocked is held
  208. if held:
  209. assert unmatched == ["PLA #FFFFFF (left nozzle)"]
  210. @pytest.mark.asyncio
  211. async def test_asked_once_per_pass_for_the_same_job_and_printer(self):
  212. scheduler = self._scheduler(0)
  213. item = self._item()
  214. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[-1])
  215. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  216. pm.get_status.return_value = MagicMock(fila_switch=None, ams_filament_backup=None)
  217. first = await scheduler._unmatched_on(AsyncMock(), item, 4)
  218. second = await scheduler._unmatched_on(AsyncMock(), item, 4)
  219. scheduler._unmatched_memo.clear()
  220. await scheduler._unmatched_on(AsyncMock(), item, 4)
  221. assert first == second == ["PLA #FFFFFF (left nozzle)"]
  222. assert scheduler._compute_ams_mapping_for_printer.await_count == 2
  223. class TestTheMemoLastsOnePass:
  224. @pytest.mark.asyncio
  225. async def test_a_new_pass_asks_again(self, queue_db):
  226. """Spools change between passes; an answer from the last one is stale."""
  227. scheduler = PrintScheduler()
  228. scheduler._unmatched_memo[("stale",)] = list(NEEDS)
  229. await _run(queue_db, scheduler, short=set(), launched=MagicMock())
  230. assert ("stale",) not in scheduler._unmatched_memo