test_vp_queue_intake_d2.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. """What a VP in queue mode takes from the slicer onto the queue item.
  2. Nozzle offset calibration was never read, so every VP-queued print ran with
  3. "auto" whatever Bambu Studio or the default print options said. The slicer's
  4. nozzle positions were stored for an "Any <model>" VP too, and the dispatch
  5. sends a stored mapping as it is instead of resolving it against the rack of the
  6. printer it picked.
  7. """
  8. import json
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  12. @pytest.fixture(autouse=True)
  13. def _fake_file_manager_store():
  14. """Uploads go to the File Manager (library_store); its own tests cover it.
  15. Here it stands in with file id 1 and the default folder settings, so these
  16. tests see what the virtual printer does around it.
  17. """
  18. stored = MagicMock(id=1, filename="upload.3mf", folder_id=None)
  19. with (
  20. patch(
  21. "backend.app.services.virtual_printer.library_store.store_upload",
  22. AsyncMock(return_value=(stored, False)),
  23. ) as store,
  24. patch(
  25. "backend.app.services.virtual_printer.library_store.upload_settings",
  26. AsyncMock(return_value=(None, False)),
  27. ),
  28. ):
  29. yield store
  30. def _session(added: list, execute_results=None):
  31. db = AsyncMock()
  32. db.add = MagicMock(side_effect=lambda item: (added.append(item), setattr(item, "id", 100 + len(added)))[0])
  33. db.flush = AsyncMock()
  34. db.commit = AsyncMock()
  35. if execute_results is not None:
  36. db.execute = AsyncMock(side_effect=execute_results)
  37. ctx = AsyncMock()
  38. ctx.__aenter__ = AsyncMock(return_value=db)
  39. ctx.__aexit__ = AsyncMock(return_value=False)
  40. return MagicMock(return_value=ctx), db
  41. def _vp(tmp_path, factory, **kwargs):
  42. return VirtualPrinterInstance(
  43. vp_id=77,
  44. name="Intake",
  45. mode="queue",
  46. model="O1C2",
  47. access_code="12345678",
  48. serial_suffix="391800077",
  49. base_dir=tmp_path,
  50. session_factory=factory,
  51. **kwargs,
  52. )
  53. async def _enqueue(inst, tmp_path, slicer_opts: dict | None, settings: dict | None = None, announce=None):
  54. file_path = tmp_path / "test.3mf"
  55. file_path.write_bytes(b"fake3mf")
  56. if slicer_opts is not None:
  57. await inst.on_print_command(file_path.name, {"command": "project_file", **slicer_opts})
  58. values = settings or {}
  59. async def _setting(_db, key):
  60. return values.get(key)
  61. with (
  62. patch("backend.app.api.routes.settings.get_setting", side_effect=_setting),
  63. patch("backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05),
  64. # The File Manager store has its own tests; here (and for the other
  65. # modules that borrow this helper) it stands in with file id 1.
  66. patch(
  67. "backend.app.services.virtual_printer.library_store.store_upload",
  68. AsyncMock(return_value=(MagicMock(id=1, filename="upload.3mf", folder_id=None), False)),
  69. ),
  70. patch(
  71. "backend.app.services.virtual_printer.library_store.upload_settings",
  72. AsyncMock(return_value=(None, False)),
  73. ),
  74. # Its own query would take a result the session mock holds for the test.
  75. patch("backend.app.services.queue_announcements.announce_queue_jobs_added", announce or AsyncMock()),
  76. ):
  77. await inst._add_to_print_queue(file_path, "192.168.1.100")
  78. return file_path
  79. class TestNozzleOffsetCalibration:
  80. @pytest.mark.asyncio
  81. @pytest.mark.parametrize(("wire", "stored"), [(0, "off"), (1, "on"), (2, "auto")])
  82. async def test_the_slicers_choice_is_kept(self, tmp_path, wire, stored):
  83. added: list = []
  84. factory, _ = _session(added)
  85. inst = _vp(tmp_path, factory, target_printer_id=7)
  86. await _enqueue(inst, tmp_path, {"nozzle_offset_cali": wire}, {"default_nozzle_offset_cali": "on"})
  87. assert added[0].nozzle_offset_cali == stored
  88. @pytest.mark.asyncio
  89. async def test_without_a_slicer_choice_the_default_applies(self, tmp_path):
  90. added: list = []
  91. factory, _ = _session(added)
  92. inst = _vp(tmp_path, factory, target_printer_id=7)
  93. await _enqueue(inst, tmp_path, {"timelapse": True}, {"default_nozzle_offset_cali": "off"})
  94. assert added[0].nozzle_offset_cali == "off"
  95. @pytest.mark.asyncio
  96. async def test_a_late_slicer_choice_is_patched_on(self, tmp_path):
  97. added: list = []
  98. position_max = MagicMock(scalar=MagicMock(return_value=None))
  99. pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
  100. factory, db = _session(added, [position_max, pending, MagicMock()])
  101. inst = _vp(tmp_path, factory, target_printer_id=7)
  102. inst._mqtt = MagicMock()
  103. file_path = await _enqueue(inst, tmp_path, None)
  104. await inst.on_print_command(
  105. file_path.name, {"command": "project_file", "file": file_path.name, "nozzle_offset_cali": 0}
  106. )
  107. params = dict(db.execute.await_args_list[2].args[0].compile().params)
  108. assert params["nozzle_offset_cali"] == "off"
  109. class TestNozzlePositionsNeedOnePrinter:
  110. @pytest.mark.asyncio
  111. async def test_a_vp_bound_to_a_printer_keeps_them(self, tmp_path):
  112. added: list = []
  113. factory, _ = _session(added)
  114. inst = _vp(tmp_path, factory, target_printer_id=7)
  115. await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
  116. assert json.loads(added[0].nozzle_mapping) == [16, -1]
  117. @pytest.mark.asyncio
  118. async def test_an_any_model_vp_leaves_them_to_the_dispatch(self, tmp_path):
  119. added: list = []
  120. factory, _ = _session(added)
  121. inst = _vp(tmp_path, factory)
  122. await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
  123. assert added[0].target_model is not None
  124. assert added[0].nozzle_mapping is None
  125. @pytest.mark.asyncio
  126. async def test_an_any_model_vp_does_not_patch_them_on_late(self, tmp_path):
  127. added: list = []
  128. position_max = MagicMock(scalar=MagicMock(return_value=None))
  129. pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
  130. factory, db = _session(added, [position_max, pending, MagicMock()])
  131. inst = _vp(tmp_path, factory)
  132. inst._mqtt = MagicMock()
  133. file_path = await _enqueue(inst, tmp_path, None)
  134. await inst.on_print_command(
  135. file_path.name,
  136. {"command": "project_file", "file": file_path.name, "nozzle_mapping": [16, -1], "timelapse": True},
  137. )
  138. params = dict(db.execute.await_args_list[2].args[0].compile().params)
  139. assert params["timelapse"] is True
  140. assert "nozzle_mapping" not in params
  141. class TestAnyH2DKeepsNozzleIndices:
  142. @pytest.mark.asyncio
  143. async def test_extruder_indices_are_the_same_on_every_printer(self, tmp_path):
  144. """Off a rack model the mapping names extruders, not a printer's rack."""
  145. added: list = []
  146. factory, _ = _session(added)
  147. inst = _vp(tmp_path, factory)
  148. inst.model = "O1D" # H2D
  149. await _enqueue(inst, tmp_path, {"nozzle_mapping": [0, 1]})
  150. assert json.loads(added[0].nozzle_mapping) == [0, 1]
  151. class TestAmsOnOff:
  152. @pytest.mark.asyncio
  153. async def test_the_slicers_choice_is_kept_when_it_arrives_in_time(self, tmp_path):
  154. added: list = []
  155. factory, _ = _session(added)
  156. inst = _vp(tmp_path, factory, target_printer_id=7)
  157. await _enqueue(inst, tmp_path, {"use_ams": False})
  158. assert added[0].use_ams is False
  159. @pytest.mark.asyncio
  160. async def test_on_when_the_slicer_does_not_say(self, tmp_path):
  161. added: list = []
  162. factory, _ = _session(added)
  163. inst = _vp(tmp_path, factory, target_printer_id=7)
  164. await _enqueue(inst, tmp_path, {"timelapse": True})
  165. assert added[0].use_ams is True
  166. @pytest.mark.asyncio
  167. async def test_a_vp_for_any_printer_leaves_it_on(self, tmp_path):
  168. """The slicer saw the VP, which has no AMS; the printer the job goes to
  169. is picked later and may have one (the dispatch reconciles it, #2595)."""
  170. added: list = []
  171. factory, _ = _session(added)
  172. inst = _vp(tmp_path, factory)
  173. await _enqueue(inst, tmp_path, {"use_ams": False})
  174. assert added[0].use_ams is True
  175. @pytest.mark.asyncio
  176. async def test_a_vp_for_any_printer_does_not_patch_it_on_late(self, tmp_path):
  177. added: list = []
  178. position_max = MagicMock(scalar=MagicMock(return_value=None))
  179. pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
  180. factory, db = _session(added, [position_max, pending, MagicMock()])
  181. inst = _vp(tmp_path, factory)
  182. inst._mqtt = MagicMock()
  183. file_path = await _enqueue(inst, tmp_path, None)
  184. await inst.on_print_command(
  185. file_path.name,
  186. {"command": "project_file", "file": file_path.name, "use_ams": False, "timelapse": True},
  187. )
  188. params = dict(db.execute.await_args_list[2].args[0].compile().params)
  189. assert params["timelapse"] is True
  190. assert "use_ams" not in params
  191. class TestLateAmsMappingPerPlate:
  192. @pytest.mark.asyncio
  193. async def test_each_queued_plate_records_the_highest_slot_it_prints(self, tmp_path):
  194. added: list = []
  195. factory, _ = _session(added)
  196. inst = _vp(tmp_path, factory, target_printer_id=7)
  197. with patch(
  198. "backend.app.services.filament_requirements.extract_filament_requirements",
  199. return_value=[{"slot_id": 1, "type": "PLA"}, {"slot_id": 4, "type": "PLA"}],
  200. ):
  201. file_path = await _enqueue(inst, tmp_path, {"timelapse": True})
  202. assert inst._recent_queue_item_reach[file_path.name] == {added[0].id: 4}
  203. @pytest.mark.asyncio
  204. async def test_a_plate_the_mapping_cannot_reach_keeps_the_rest_of_the_patch(self, tmp_path):
  205. import time
  206. added: list = []
  207. pending = MagicMock(all=MagicMock(return_value=[(101, None, None), (102, None, None)]))
  208. factory, db = _session(added, [pending, MagicMock(), MagicMock()])
  209. inst = VirtualPrinterInstance(
  210. vp_id=78,
  211. name="LatePlates",
  212. mode="queue",
  213. model="X1C",
  214. access_code="12345678",
  215. serial_suffix="391800078",
  216. base_dir=tmp_path,
  217. session_factory=factory,
  218. save_ams_mapping=True,
  219. target_printer_id=7,
  220. )
  221. inst._recent_queue_items["test.3mf"] = ([101, 102], time.monotonic())
  222. inst._recent_queue_item_reach["test.3mf"] = {101: 2, 102: 6}
  223. await inst.on_print_command(
  224. "test.3mf",
  225. {"command": "project_file", "file": "test.3mf", "ams_mapping": [0, 1, -1], "timelapse": True},
  226. )
  227. reached, short = (call.args[0] for call in db.execute.await_args_list[1:3])
  228. reached_params = dict(reached.compile().params)
  229. short_params = dict(short.compile().params)
  230. assert json.loads(reached_params["ams_mapping"]) == [0, 1, -1]
  231. assert [101] in reached_params.values()
  232. assert "ams_mapping" not in short_params and short_params["timelapse"] is True
  233. assert [102] in short_params.values()