test_vp_external_spool_mapping_3237.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """A VP with "Save AMS mapping" keeps the slicer's external-spool pick (#3237).
  2. The slicer writes an external spool as -1 in the flat ``ams_mapping``, the same
  3. as a filament with no tray, and names it only in ``ams_mapping2``. Saving the
  4. flat list alone made dispatch send the external filament as unmapped, and the
  5. printer stopped with 0700-8012 before the first layer.
  6. """
  7. import json
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from backend.app.services.virtual_printer.manager import (
  11. VirtualPrinterInstance,
  12. _extract_slicer_ams_mapping_json,
  13. )
  14. EXT_LEFT = {"ams_id": 254, "slot_id": 0}
  15. EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
  16. UNMAPPED = {"ams_id": 255, "slot_id": 255}
  17. # The reporter's H2C job: left external spool plus AMS 0 slots 3 and 2.
  18. H2C_PAYLOAD = {
  19. "command": "project_file",
  20. "ams_mapping": [-1, 3, 2],
  21. "ams_mapping2": [EXT_LEFT, {"ams_id": 0, "slot_id": 3}, {"ams_id": 0, "slot_id": 2}],
  22. }
  23. @pytest.fixture(autouse=True)
  24. def _fake_file_manager_store():
  25. """Uploads go to the File Manager (library_store); its own tests cover it.
  26. Here it stands in with file id 1 and the default folder settings, so these
  27. tests see what the virtual printer does around it.
  28. """
  29. stored = MagicMock(id=1, filename="upload.3mf", folder_id=None)
  30. with (
  31. patch(
  32. "backend.app.services.virtual_printer.library_store.store_upload",
  33. AsyncMock(return_value=(stored, False)),
  34. ) as store,
  35. patch(
  36. "backend.app.services.virtual_printer.library_store.upload_settings",
  37. AsyncMock(return_value=(None, False)),
  38. ),
  39. ):
  40. yield store
  41. def _extract(data, is_dual_nozzle):
  42. raw = _extract_slicer_ams_mapping_json(data, "[test]", is_dual_nozzle=is_dual_nozzle)
  43. return None if raw is None else json.loads(raw)
  44. class TestExtract:
  45. def test_left_external_spool_on_dual_nozzle(self):
  46. assert _extract(H2C_PAYLOAD, is_dual_nozzle=True) == [254, 3, 2]
  47. def test_right_external_spool_on_dual_nozzle(self):
  48. data = {"ams_mapping": [-1, 3], "ams_mapping2": [EXT_RIGHT, {"ams_id": 0, "slot_id": 3}]}
  49. assert _extract(data, is_dual_nozzle=True) == [255, 3]
  50. def test_single_nozzle_external_spool_is_254(self):
  51. # Single-nozzle printers have one external spool; the wire calls it
  52. # 255, Bambuddy's global tray is 254.
  53. data = {"ams_mapping": [0, -1], "ams_mapping2": [{"ams_id": 0, "slot_id": 0}, EXT_RIGHT]}
  54. assert _extract(data, is_dual_nozzle=False) == [0, 254]
  55. def test_unused_filament_stays_unmapped(self):
  56. data = {"ams_mapping": [-1, 3], "ams_mapping2": [UNMAPPED, {"ams_id": 0, "slot_id": 3}]}
  57. assert _extract(data, is_dual_nozzle=True) == [-1, 3]
  58. def test_external_only_job_is_kept(self):
  59. # Every flat entry is -1, but this is a real pick, not the #2589
  60. # unresolved sentinel.
  61. assert _extract({"ams_mapping": [-1], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [254]
  62. def test_unresolved_sentinel_still_dropped(self):
  63. data = {"ams_mapping": [-1, -1], "ams_mapping2": [UNMAPPED, UNMAPPED]}
  64. assert _extract(data, is_dual_nozzle=True) is None
  65. def test_stringified_mapping2_is_parsed(self):
  66. data = {"ams_mapping": "[-1, 3, 2]", "ams_mapping2": json.dumps(H2C_PAYLOAD["ams_mapping2"])}
  67. assert _extract(data, is_dual_nozzle=True) == [254, 3, 2]
  68. def test_missing_or_unusable_mapping2_keeps_flat_list(self):
  69. assert _extract({"ams_mapping": [-1, 3, 2]}, is_dual_nozzle=True) == [-1, 3, 2]
  70. assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": "not json"}, is_dual_nozzle=True) == [-1, 3, 2]
  71. # Length mismatch: positions can't be paired, leave it alone.
  72. assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [-1, 3, 2]
  73. def _instance(tmp_path, *, model="O1C", printer_manager=None, session_factory=None):
  74. return VirtualPrinterInstance(
  75. vp_id=3237,
  76. name="ExtSpool",
  77. mode="queue",
  78. model=model,
  79. access_code="12345678",
  80. serial_suffix="391803237",
  81. base_dir=tmp_path,
  82. session_factory=session_factory,
  83. save_ams_mapping=True,
  84. target_printer_id=7,
  85. printer_manager=printer_manager,
  86. )
  87. def _manager_with(client):
  88. manager = MagicMock()
  89. manager.get_client = MagicMock(return_value=client)
  90. return manager
  91. class TestTargetIsDualNozzle:
  92. def test_live_detection_wins(self, tmp_path):
  93. client = MagicMock(_is_dual_nozzle=True, model="Some future model")
  94. assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
  95. def test_client_model(self, tmp_path):
  96. client = MagicMock(_is_dual_nozzle=False, model="H2C")
  97. assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
  98. def test_single_nozzle_client(self, tmp_path):
  99. client = MagicMock(_is_dual_nozzle=False, model="X1C")
  100. assert not _instance(tmp_path, printer_manager=_manager_with(client))._target_is_dual_nozzle()
  101. def test_falls_back_to_vp_model_without_client(self, tmp_path):
  102. assert _instance(tmp_path, model="O1C", printer_manager=_manager_with(None))._target_is_dual_nozzle()
  103. assert not _instance(tmp_path, model="BL-P001")._target_is_dual_nozzle()
  104. @pytest.mark.asyncio
  105. async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
  106. added_items = []
  107. mock_db = AsyncMock()
  108. mock_db.add = MagicMock(side_effect=added_items.append)
  109. mock_db.commit = AsyncMock()
  110. session_ctx = AsyncMock()
  111. session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  112. session_ctx.__aexit__ = AsyncMock(return_value=False)
  113. session_factory = MagicMock(return_value=session_ctx)
  114. client = MagicMock(_is_dual_nozzle=True, model="H2C")
  115. inst = _instance(tmp_path, printer_manager=_manager_with(client), session_factory=session_factory)
  116. file_path = tmp_path / "test.3mf"
  117. file_path.write_bytes(b"fake3mf")
  118. await inst.on_print_command(file_path.name, dict(H2C_PAYLOAD))
  119. with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
  120. await inst._add_to_print_queue(file_path, "192.168.1.100")
  121. assert len(added_items) == 1
  122. assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
  123. # Kept on the item for the archive its print makes.
  124. assert json.loads(added_items[0].slicer_ams_mapping)["mapping"] == [254, 3, 2]
  125. # An H3C job: AMS 3H slot 1, then the external inputs of hotends 0, 1 and 5.
  126. H3C_PAYLOAD = {
  127. "command": "project_file",
  128. "ams_mapping": [28, -1, -1, -1],
  129. "ams_mapping2": [
  130. {"ams_id": 32, "slot_id": 0},
  131. {"ams_id": 255, "slot_id": 0},
  132. {"ams_id": 254, "slot_id": 0},
  133. {"ams_id": 250, "slot_id": 0},
  134. ],
  135. }
  136. class TestRackPrinterExternalInputs:
  137. """An H3C has one external input per hotend (247-255); each keeps its own id."""
  138. def test_rack_layout_keeps_each_input(self):
  139. from backend.app.utils.tray_ids import TrayLayout
  140. raw = _extract_slicer_ams_mapping_json(H3C_PAYLOAD, "[test]", layout=TrayLayout.ams3h())
  141. assert json.loads(raw) == [28, 255, 254, 250]
  142. def test_target_h3c_layout_from_its_model(self, tmp_path):
  143. manager = _manager_with(None)
  144. manager.get_status = MagicMock(return_value=None)
  145. manager.get_model = MagicMock(return_value="H3C")
  146. assert _instance(tmp_path, model="N10", printer_manager=manager)._target_tray_layout().rack
  147. def test_any_available_h3c_uses_the_vp_model(self, tmp_path):
  148. inst = _instance(tmp_path, model="N10")
  149. inst.target_printer_id = None
  150. assert inst._target_tray_layout().rack
  151. def test_other_targets_are_not_rack(self, tmp_path):
  152. manager = _manager_with(None)
  153. manager.get_status = MagicMock(return_value=None)
  154. manager.get_model = MagicMock(return_value="H2C")
  155. assert not _instance(tmp_path, printer_manager=manager)._target_tray_layout().rack
  156. inst = _instance(tmp_path, model="BL-P001")
  157. inst.target_printer_id = None
  158. assert not inst._target_tray_layout().rack
  159. @pytest.mark.asyncio
  160. async def test_queue_item_keeps_h3c_external_inputs(self, tmp_path):
  161. added_items = []
  162. mock_db = AsyncMock()
  163. mock_db.add = MagicMock(side_effect=added_items.append)
  164. mock_db.commit = AsyncMock()
  165. session_ctx = AsyncMock()
  166. session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  167. session_ctx.__aexit__ = AsyncMock(return_value=False)
  168. session_factory = MagicMock(return_value=session_ctx)
  169. manager = _manager_with(MagicMock(_is_dual_nozzle=False, model="N10"))
  170. manager.get_status = MagicMock(return_value=None)
  171. manager.get_model = MagicMock(return_value="N10")
  172. inst = _instance(tmp_path, model="N10", printer_manager=manager, session_factory=session_factory)
  173. file_path = tmp_path / "test.3mf"
  174. file_path.write_bytes(b"fake3mf")
  175. await inst.on_print_command(file_path.name, dict(H3C_PAYLOAD))
  176. with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
  177. await inst._add_to_print_queue(file_path, "192.168.1.100")
  178. assert len(added_items) == 1
  179. assert json.loads(added_items[0].ams_mapping) == [28, 255, 254, 250]