test_vp_queue_intake_d2.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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. def _session(added: list, execute_results=None):
  13. db = AsyncMock()
  14. db.add = MagicMock(side_effect=lambda item: (added.append(item), setattr(item, "id", 100 + len(added)))[0])
  15. db.flush = AsyncMock()
  16. db.commit = AsyncMock()
  17. if execute_results is not None:
  18. db.execute = AsyncMock(side_effect=execute_results)
  19. ctx = AsyncMock()
  20. ctx.__aenter__ = AsyncMock(return_value=db)
  21. ctx.__aexit__ = AsyncMock(return_value=False)
  22. return MagicMock(return_value=ctx), db
  23. def _vp(tmp_path, factory, **kwargs):
  24. return VirtualPrinterInstance(
  25. vp_id=77,
  26. name="Intake",
  27. mode="queue",
  28. model="O1C2",
  29. access_code="12345678",
  30. serial_suffix="391800077",
  31. base_dir=tmp_path,
  32. session_factory=factory,
  33. **kwargs,
  34. )
  35. async def _enqueue(inst, tmp_path, slicer_opts: dict | None, settings: dict | None = None, announce=None):
  36. file_path = tmp_path / "test.3mf"
  37. file_path.write_bytes(b"fake3mf")
  38. if slicer_opts is not None:
  39. await inst.on_print_command(file_path.name, {"command": "project_file", **slicer_opts})
  40. archive = MagicMock(id=1, print_name="test")
  41. values = settings or {}
  42. async def _setting(_db, key):
  43. return values.get(key)
  44. with (
  45. patch("backend.app.api.routes.settings.get_setting", side_effect=_setting),
  46. patch(
  47. "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=archive
  48. ),
  49. patch("backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05),
  50. # Its own query would take a result the session mock holds for the test.
  51. patch("backend.app.services.queue_announcements.announce_queue_jobs_added", announce or AsyncMock()),
  52. ):
  53. await inst._add_to_print_queue(file_path, "192.168.1.100")
  54. return file_path
  55. class TestNozzleOffsetCalibration:
  56. @pytest.mark.asyncio
  57. @pytest.mark.parametrize(("wire", "stored"), [(0, "off"), (1, "on"), (2, "auto")])
  58. async def test_the_slicers_choice_is_kept(self, tmp_path, wire, stored):
  59. added: list = []
  60. factory, _ = _session(added)
  61. inst = _vp(tmp_path, factory, target_printer_id=7)
  62. await _enqueue(inst, tmp_path, {"nozzle_offset_cali": wire}, {"default_nozzle_offset_cali": "on"})
  63. assert added[0].nozzle_offset_cali == stored
  64. @pytest.mark.asyncio
  65. async def test_without_a_slicer_choice_the_default_applies(self, tmp_path):
  66. added: list = []
  67. factory, _ = _session(added)
  68. inst = _vp(tmp_path, factory, target_printer_id=7)
  69. await _enqueue(inst, tmp_path, {"timelapse": True}, {"default_nozzle_offset_cali": "off"})
  70. assert added[0].nozzle_offset_cali == "off"
  71. @pytest.mark.asyncio
  72. async def test_a_late_slicer_choice_is_patched_on(self, tmp_path):
  73. added: list = []
  74. position_max = MagicMock(scalar=MagicMock(return_value=None))
  75. pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
  76. factory, db = _session(added, [position_max, pending, MagicMock()])
  77. inst = _vp(tmp_path, factory, target_printer_id=7)
  78. inst._mqtt = MagicMock()
  79. file_path = await _enqueue(inst, tmp_path, None)
  80. await inst.on_print_command(
  81. file_path.name, {"command": "project_file", "file": file_path.name, "nozzle_offset_cali": 0}
  82. )
  83. params = dict(db.execute.await_args_list[2].args[0].compile().params)
  84. assert params["nozzle_offset_cali"] == "off"
  85. class TestNozzlePositionsNeedOnePrinter:
  86. @pytest.mark.asyncio
  87. async def test_a_vp_bound_to_a_printer_keeps_them(self, tmp_path):
  88. added: list = []
  89. factory, _ = _session(added)
  90. inst = _vp(tmp_path, factory, target_printer_id=7)
  91. await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
  92. assert json.loads(added[0].nozzle_mapping) == [16, -1]
  93. @pytest.mark.asyncio
  94. async def test_an_any_model_vp_leaves_them_to_the_dispatch(self, tmp_path):
  95. added: list = []
  96. factory, _ = _session(added)
  97. inst = _vp(tmp_path, factory)
  98. await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
  99. assert added[0].target_model is not None
  100. assert added[0].nozzle_mapping is None
  101. @pytest.mark.asyncio
  102. async def test_an_any_model_vp_does_not_patch_them_on_late(self, tmp_path):
  103. added: list = []
  104. position_max = MagicMock(scalar=MagicMock(return_value=None))
  105. pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
  106. factory, db = _session(added, [position_max, pending, MagicMock()])
  107. inst = _vp(tmp_path, factory)
  108. inst._mqtt = MagicMock()
  109. file_path = await _enqueue(inst, tmp_path, None)
  110. await inst.on_print_command(
  111. file_path.name,
  112. {"command": "project_file", "file": file_path.name, "nozzle_mapping": [16, -1], "timelapse": True},
  113. )
  114. params = dict(db.execute.await_args_list[2].args[0].compile().params)
  115. assert params["timelapse"] is True
  116. assert "nozzle_mapping" not in params
  117. class TestAnyH2DKeepsNozzleIndices:
  118. @pytest.mark.asyncio
  119. async def test_extruder_indices_are_the_same_on_every_printer(self, tmp_path):
  120. """Off a rack model the mapping names extruders, not a printer's rack."""
  121. added: list = []
  122. factory, _ = _session(added)
  123. inst = _vp(tmp_path, factory)
  124. inst.model = "O1D" # H2D
  125. await _enqueue(inst, tmp_path, {"nozzle_mapping": [0, 1]})
  126. assert json.loads(added[0].nozzle_mapping) == [0, 1]
  127. class TestAmsOnOff:
  128. @pytest.mark.asyncio
  129. async def test_the_slicers_choice_is_kept_when_it_arrives_in_time(self, tmp_path):
  130. added: list = []
  131. factory, _ = _session(added)
  132. inst = _vp(tmp_path, factory, target_printer_id=7)
  133. await _enqueue(inst, tmp_path, {"use_ams": False})
  134. assert added[0].use_ams is False
  135. @pytest.mark.asyncio
  136. async def test_on_when_the_slicer_does_not_say(self, tmp_path):
  137. added: list = []
  138. factory, _ = _session(added)
  139. inst = _vp(tmp_path, factory, target_printer_id=7)
  140. await _enqueue(inst, tmp_path, {"timelapse": True})
  141. assert added[0].use_ams is True
  142. @pytest.mark.asyncio
  143. async def test_a_vp_for_any_printer_leaves_it_on(self, tmp_path):
  144. """The slicer saw the VP, which has no AMS; the printer the job goes to
  145. is picked later and may have one (the dispatch reconciles it, #2595)."""
  146. added: list = []
  147. factory, _ = _session(added)
  148. inst = _vp(tmp_path, factory)
  149. await _enqueue(inst, tmp_path, {"use_ams": False})
  150. assert added[0].use_ams is True
  151. @pytest.mark.asyncio
  152. async def test_a_vp_for_any_printer_does_not_patch_it_on_late(self, tmp_path):
  153. added: list = []
  154. position_max = MagicMock(scalar=MagicMock(return_value=None))
  155. pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
  156. factory, db = _session(added, [position_max, pending, MagicMock()])
  157. inst = _vp(tmp_path, factory)
  158. inst._mqtt = MagicMock()
  159. file_path = await _enqueue(inst, tmp_path, None)
  160. await inst.on_print_command(
  161. file_path.name,
  162. {"command": "project_file", "file": file_path.name, "use_ams": False, "timelapse": True},
  163. )
  164. params = dict(db.execute.await_args_list[2].args[0].compile().params)
  165. assert params["timelapse"] is True
  166. assert "use_ams" not in params
  167. class TestLateAmsMappingPerPlate:
  168. @pytest.mark.asyncio
  169. async def test_each_queued_plate_records_the_highest_slot_it_prints(self, tmp_path):
  170. added: list = []
  171. factory, _ = _session(added)
  172. inst = _vp(tmp_path, factory, target_printer_id=7)
  173. with patch(
  174. "backend.app.services.filament_requirements.extract_filament_requirements",
  175. return_value=[{"slot_id": 1, "type": "PLA"}, {"slot_id": 4, "type": "PLA"}],
  176. ):
  177. file_path = await _enqueue(inst, tmp_path, {"timelapse": True})
  178. assert inst._recent_queue_item_reach[file_path.name] == {added[0].id: 4}
  179. @pytest.mark.asyncio
  180. async def test_a_plate_the_mapping_cannot_reach_keeps_the_rest_of_the_patch(self, tmp_path):
  181. import time
  182. added: list = []
  183. pending = MagicMock(all=MagicMock(return_value=[(101, None, None), (102, None, None)]))
  184. factory, db = _session(added, [pending, MagicMock(), MagicMock()])
  185. inst = VirtualPrinterInstance(
  186. vp_id=78,
  187. name="LatePlates",
  188. mode="queue",
  189. model="X1C",
  190. access_code="12345678",
  191. serial_suffix="391800078",
  192. base_dir=tmp_path,
  193. session_factory=factory,
  194. save_ams_mapping=True,
  195. target_printer_id=7,
  196. )
  197. inst._recent_queue_items["test.3mf"] = ([101, 102], time.monotonic())
  198. inst._recent_queue_item_reach["test.3mf"] = {101: 2, 102: 6}
  199. await inst.on_print_command(
  200. "test.3mf",
  201. {"command": "project_file", "file": "test.3mf", "ams_mapping": [0, 1, -1], "timelapse": True},
  202. )
  203. reached, short = (call.args[0] for call in db.execute.await_args_list[1:3])
  204. reached_params = dict(reached.compile().params)
  205. short_params = dict(short.compile().params)
  206. assert json.loads(reached_params["ams_mapping"]) == [0, 1, -1]
  207. assert [101] in reached_params.values()
  208. assert "ams_mapping" not in short_params and short_params["timelapse"] is True
  209. assert [102] in short_params.values()