|
|
@@ -0,0 +1,145 @@
|
|
|
+"""A VP with "Save AMS mapping" keeps the slicer's external-spool pick (#3237).
|
|
|
+
|
|
|
+The slicer writes an external spool as -1 in the flat ``ams_mapping``, the same
|
|
|
+as a filament with no tray, and names it only in ``ams_mapping2``. Saving the
|
|
|
+flat list alone made dispatch send the external filament as unmapped, and the
|
|
|
+printer stopped with 0700-8012 before the first layer.
|
|
|
+"""
|
|
|
+
|
|
|
+import json
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.services.virtual_printer.manager import (
|
|
|
+ VirtualPrinterInstance,
|
|
|
+ _extract_slicer_ams_mapping_json,
|
|
|
+)
|
|
|
+
|
|
|
+EXT_LEFT = {"ams_id": 254, "slot_id": 0}
|
|
|
+EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
|
|
|
+UNMAPPED = {"ams_id": 255, "slot_id": 255}
|
|
|
+
|
|
|
+# The reporter's H2C job: left external spool plus AMS 0 slots 3 and 2.
|
|
|
+H2C_PAYLOAD = {
|
|
|
+ "command": "project_file",
|
|
|
+ "ams_mapping": [-1, 3, 2],
|
|
|
+ "ams_mapping2": [EXT_LEFT, {"ams_id": 0, "slot_id": 3}, {"ams_id": 0, "slot_id": 2}],
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def _extract(data, is_dual_nozzle):
|
|
|
+ raw = _extract_slicer_ams_mapping_json(data, "[test]", is_dual_nozzle=is_dual_nozzle)
|
|
|
+ return None if raw is None else json.loads(raw)
|
|
|
+
|
|
|
+
|
|
|
+class TestExtract:
|
|
|
+ def test_left_external_spool_on_dual_nozzle(self):
|
|
|
+ assert _extract(H2C_PAYLOAD, is_dual_nozzle=True) == [254, 3, 2]
|
|
|
+
|
|
|
+ def test_right_external_spool_on_dual_nozzle(self):
|
|
|
+ data = {"ams_mapping": [-1, 3], "ams_mapping2": [EXT_RIGHT, {"ams_id": 0, "slot_id": 3}]}
|
|
|
+ assert _extract(data, is_dual_nozzle=True) == [255, 3]
|
|
|
+
|
|
|
+ def test_single_nozzle_external_spool_is_254(self):
|
|
|
+ # Single-nozzle printers have one external spool; the wire calls it
|
|
|
+ # 255, Bambuddy's global tray is 254.
|
|
|
+ data = {"ams_mapping": [0, -1], "ams_mapping2": [{"ams_id": 0, "slot_id": 0}, EXT_RIGHT]}
|
|
|
+ assert _extract(data, is_dual_nozzle=False) == [0, 254]
|
|
|
+
|
|
|
+ def test_unused_filament_stays_unmapped(self):
|
|
|
+ data = {"ams_mapping": [-1, 3], "ams_mapping2": [UNMAPPED, {"ams_id": 0, "slot_id": 3}]}
|
|
|
+ assert _extract(data, is_dual_nozzle=True) == [-1, 3]
|
|
|
+
|
|
|
+ def test_external_only_job_is_kept(self):
|
|
|
+ # Every flat entry is -1, but this is a real pick, not the #2589
|
|
|
+ # unresolved sentinel.
|
|
|
+ assert _extract({"ams_mapping": [-1], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [254]
|
|
|
+
|
|
|
+ def test_unresolved_sentinel_still_dropped(self):
|
|
|
+ data = {"ams_mapping": [-1, -1], "ams_mapping2": [UNMAPPED, UNMAPPED]}
|
|
|
+ assert _extract(data, is_dual_nozzle=True) is None
|
|
|
+
|
|
|
+ def test_stringified_mapping2_is_parsed(self):
|
|
|
+ data = {"ams_mapping": "[-1, 3, 2]", "ams_mapping2": json.dumps(H2C_PAYLOAD["ams_mapping2"])}
|
|
|
+ assert _extract(data, is_dual_nozzle=True) == [254, 3, 2]
|
|
|
+
|
|
|
+ def test_missing_or_unusable_mapping2_keeps_flat_list(self):
|
|
|
+ assert _extract({"ams_mapping": [-1, 3, 2]}, is_dual_nozzle=True) == [-1, 3, 2]
|
|
|
+ assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": "not json"}, is_dual_nozzle=True) == [-1, 3, 2]
|
|
|
+ # Length mismatch: positions can't be paired, leave it alone.
|
|
|
+ assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [-1, 3, 2]
|
|
|
+
|
|
|
+
|
|
|
+def _instance(tmp_path, *, model="O1C", printer_manager=None, session_factory=None):
|
|
|
+ return VirtualPrinterInstance(
|
|
|
+ vp_id=3237,
|
|
|
+ name="ExtSpool",
|
|
|
+ mode="queue",
|
|
|
+ model=model,
|
|
|
+ access_code="12345678",
|
|
|
+ serial_suffix="391803237",
|
|
|
+ base_dir=tmp_path,
|
|
|
+ session_factory=session_factory,
|
|
|
+ save_ams_mapping=True,
|
|
|
+ target_printer_id=7,
|
|
|
+ printer_manager=printer_manager,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _manager_with(client):
|
|
|
+ manager = MagicMock()
|
|
|
+ manager.get_client = MagicMock(return_value=client)
|
|
|
+ return manager
|
|
|
+
|
|
|
+
|
|
|
+class TestTargetIsDualNozzle:
|
|
|
+ def test_live_detection_wins(self, tmp_path):
|
|
|
+ client = MagicMock(_is_dual_nozzle=True, model="Some future model")
|
|
|
+ assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
|
|
|
+
|
|
|
+ def test_client_model(self, tmp_path):
|
|
|
+ client = MagicMock(_is_dual_nozzle=False, model="H2C")
|
|
|
+ assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
|
|
|
+
|
|
|
+ def test_single_nozzle_client(self, tmp_path):
|
|
|
+ client = MagicMock(_is_dual_nozzle=False, model="X1C")
|
|
|
+ assert not _instance(tmp_path, printer_manager=_manager_with(client))._target_is_dual_nozzle()
|
|
|
+
|
|
|
+ def test_falls_back_to_vp_model_without_client(self, tmp_path):
|
|
|
+ assert _instance(tmp_path, model="O1C", printer_manager=_manager_with(None))._target_is_dual_nozzle()
|
|
|
+ assert not _instance(tmp_path, model="BL-P001")._target_is_dual_nozzle()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
|
|
|
+ added_items = []
|
|
|
+ mock_db = AsyncMock()
|
|
|
+ mock_db.add = MagicMock(side_effect=added_items.append)
|
|
|
+ mock_db.commit = AsyncMock()
|
|
|
+ session_ctx = AsyncMock()
|
|
|
+ session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
|
|
+ session_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
+ session_factory = MagicMock(return_value=session_ctx)
|
|
|
+
|
|
|
+ client = MagicMock(_is_dual_nozzle=True, model="H2C")
|
|
|
+ inst = _instance(tmp_path, printer_manager=_manager_with(client), session_factory=session_factory)
|
|
|
+
|
|
|
+ file_path = tmp_path / "test.3mf"
|
|
|
+ file_path.write_bytes(b"fake3mf")
|
|
|
+ await inst.on_print_command(file_path.name, dict(H2C_PAYLOAD))
|
|
|
+
|
|
|
+ mock_archive = MagicMock(id=1, print_name="test")
|
|
|
+ with (
|
|
|
+ patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
|
|
|
+ patch(
|
|
|
+ "backend.app.services.archive.ArchiveService.archive_print",
|
|
|
+ new_callable=AsyncMock,
|
|
|
+ return_value=mock_archive,
|
|
|
+ ) as archive_print,
|
|
|
+ ):
|
|
|
+ await inst._add_to_print_queue(file_path, "192.168.1.100")
|
|
|
+
|
|
|
+ assert len(added_items) == 1
|
|
|
+ assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
|
|
|
+ assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [254, 3, 2]
|