test_vp_external_spool_mapping_3237.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  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. def _extract(data, is_dual_nozzle):
  24. raw = _extract_slicer_ams_mapping_json(data, "[test]", is_dual_nozzle=is_dual_nozzle)
  25. return None if raw is None else json.loads(raw)
  26. class TestExtract:
  27. def test_left_external_spool_on_dual_nozzle(self):
  28. assert _extract(H2C_PAYLOAD, is_dual_nozzle=True) == [254, 3, 2]
  29. def test_right_external_spool_on_dual_nozzle(self):
  30. data = {"ams_mapping": [-1, 3], "ams_mapping2": [EXT_RIGHT, {"ams_id": 0, "slot_id": 3}]}
  31. assert _extract(data, is_dual_nozzle=True) == [255, 3]
  32. def test_single_nozzle_external_spool_is_254(self):
  33. # Single-nozzle printers have one external spool; the wire calls it
  34. # 255, Bambuddy's global tray is 254.
  35. data = {"ams_mapping": [0, -1], "ams_mapping2": [{"ams_id": 0, "slot_id": 0}, EXT_RIGHT]}
  36. assert _extract(data, is_dual_nozzle=False) == [0, 254]
  37. def test_unused_filament_stays_unmapped(self):
  38. data = {"ams_mapping": [-1, 3], "ams_mapping2": [UNMAPPED, {"ams_id": 0, "slot_id": 3}]}
  39. assert _extract(data, is_dual_nozzle=True) == [-1, 3]
  40. def test_external_only_job_is_kept(self):
  41. # Every flat entry is -1, but this is a real pick, not the #2589
  42. # unresolved sentinel.
  43. assert _extract({"ams_mapping": [-1], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [254]
  44. def test_unresolved_sentinel_still_dropped(self):
  45. data = {"ams_mapping": [-1, -1], "ams_mapping2": [UNMAPPED, UNMAPPED]}
  46. assert _extract(data, is_dual_nozzle=True) is None
  47. def test_stringified_mapping2_is_parsed(self):
  48. data = {"ams_mapping": "[-1, 3, 2]", "ams_mapping2": json.dumps(H2C_PAYLOAD["ams_mapping2"])}
  49. assert _extract(data, is_dual_nozzle=True) == [254, 3, 2]
  50. def test_missing_or_unusable_mapping2_keeps_flat_list(self):
  51. assert _extract({"ams_mapping": [-1, 3, 2]}, is_dual_nozzle=True) == [-1, 3, 2]
  52. assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": "not json"}, is_dual_nozzle=True) == [-1, 3, 2]
  53. # Length mismatch: positions can't be paired, leave it alone.
  54. assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [-1, 3, 2]
  55. def _instance(tmp_path, *, model="O1C", printer_manager=None, session_factory=None):
  56. return VirtualPrinterInstance(
  57. vp_id=3237,
  58. name="ExtSpool",
  59. mode="queue",
  60. model=model,
  61. access_code="12345678",
  62. serial_suffix="391803237",
  63. base_dir=tmp_path,
  64. session_factory=session_factory,
  65. save_ams_mapping=True,
  66. target_printer_id=7,
  67. printer_manager=printer_manager,
  68. )
  69. def _manager_with(client):
  70. manager = MagicMock()
  71. manager.get_client = MagicMock(return_value=client)
  72. return manager
  73. class TestTargetIsDualNozzle:
  74. def test_live_detection_wins(self, tmp_path):
  75. client = MagicMock(_is_dual_nozzle=True, model="Some future model")
  76. assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
  77. def test_client_model(self, tmp_path):
  78. client = MagicMock(_is_dual_nozzle=False, model="H2C")
  79. assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
  80. def test_single_nozzle_client(self, tmp_path):
  81. client = MagicMock(_is_dual_nozzle=False, model="X1C")
  82. assert not _instance(tmp_path, printer_manager=_manager_with(client))._target_is_dual_nozzle()
  83. def test_falls_back_to_vp_model_without_client(self, tmp_path):
  84. assert _instance(tmp_path, model="O1C", printer_manager=_manager_with(None))._target_is_dual_nozzle()
  85. assert not _instance(tmp_path, model="BL-P001")._target_is_dual_nozzle()
  86. @pytest.mark.asyncio
  87. async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
  88. added_items = []
  89. mock_db = AsyncMock()
  90. mock_db.add = MagicMock(side_effect=added_items.append)
  91. mock_db.commit = AsyncMock()
  92. session_ctx = AsyncMock()
  93. session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  94. session_ctx.__aexit__ = AsyncMock(return_value=False)
  95. session_factory = MagicMock(return_value=session_ctx)
  96. client = MagicMock(_is_dual_nozzle=True, model="H2C")
  97. inst = _instance(tmp_path, printer_manager=_manager_with(client), session_factory=session_factory)
  98. file_path = tmp_path / "test.3mf"
  99. file_path.write_bytes(b"fake3mf")
  100. await inst.on_print_command(file_path.name, dict(H2C_PAYLOAD))
  101. mock_archive = MagicMock(id=1, print_name="test")
  102. with (
  103. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  104. patch(
  105. "backend.app.services.archive.ArchiveService.archive_print",
  106. new_callable=AsyncMock,
  107. return_value=mock_archive,
  108. ) as archive_print,
  109. ):
  110. await inst._add_to_print_queue(file_path, "192.168.1.100")
  111. assert len(added_items) == 1
  112. assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
  113. assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [254, 3, 2]