Browse Source

fix(virtual-printer): queued prints keep the slicer's options, not another printer's nozzles

- Nozzle offset calibration from the slicer (or the default print
  option) is stored on VP queue items, in both intake paths; it always
  fell back to auto.
- AMS on/off from the slicer is stored when the options arrive in
  time, as it already was when they arrived late.
- On a rack model (H2C), the slicer's nozzle positions are kept only
  by a VP bound to one printer; an "Any" VP leaves them to the
  dispatch, which resolves them against the rack of the printer it
  picks.
- A late slicer AMS mapping is not stamped onto a plate it cannot
  reach.
- A job the scheduler moves to another printer, or releases to the
  pool, drops everything worked out for the old printer
  (drop_printer_resolved), not only its AMS mapping.
maziggy 6 ngày trước cách đây
mục cha
commit
dc72a0daea

+ 6 - 0
CHANGELOG.md

@@ -164,6 +164,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Prints queued through a virtual printer keep the slicer's nozzle choices** —
+  - **Nozzle offset calibration** from the slicer, or the default from Settings → Workflow, is used. It always ran on "Auto".
+  - **H2C rack nozzles** picked in the slicer are kept only by a virtual printer bound to one printer. A virtual printer for any H2C sent those positions to whichever H2C took the job, whatever that printer's rack held; the dispatch now picks them from that printer's rack.
+  - **A job the queue moves to another printer**, or releases back to the pool, no longer takes the old printer's nozzle positions along.
+  - **AMS on or off** from the slicer is used when the slicer's options arrive in time; it was used only when they arrived late.
+  - **A slicer AMS mapping that arrives late** is no longer put on a plate of a multi-plate send that it cannot reach; that plate gets a computed mapping, as when the options arrive in time.
 - **Settings that were not saved or not used** —
   - **Max copies per run** (Workflow) and the Docker compose directory used in the update instructions are saved. They changed on screen and were never sent, so a 100-copy pipeline run stayed refused at the old limit of 50.
   - **Default printer** (General) is now selected in the print dialog when it opens, unless the file was sliced for another model or the job goes to a model rather than a printer. The setting was saved and never used.

+ 5 - 3
backend/app/services/print_scheduler.py

@@ -64,6 +64,7 @@ from backend.app.services.printer_manager import (
     supports_drying,
     supports_drying_while_printing,
 )
+from backend.app.services.queue_item_printer import drop_printer_resolved
 from backend.app.services.smart_plug_manager import publish_plug_state, smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
     _global_tray_from_assignment,
@@ -7976,8 +7977,9 @@ class PrintScheduler:
         )
         self._short_hold_checked_at.pop(item.id, None)
         item.printer_id = match_id
-        # Resolved against the printer it leaves (#3239).
-        item.ams_mapping = None
+        # Resolved against the printer it leaves (#3239): the tray mapping and
+        # the nozzle positions.
+        drop_printer_resolved(item)
         item.manual_start = False
         item.filament_short = False
         item.waiting_reason = None
@@ -8145,7 +8147,7 @@ class PrintScheduler:
             # has the filament, since an unresolved slot is deliberately not a
             # conflict worth recomputing over.
             item.printer_id = None
-            item.ams_mapping = None
+            drop_printer_resolved(item)
         await db.commit()
 
         job_name = await self._get_job_name(db, item)

+ 73 - 11
backend/app/services/virtual_printer/manager.py

@@ -28,6 +28,7 @@ from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
+from backend.app.utils.printer_models import is_nozzle_rack_model
 
 if TYPE_CHECKING:
     from backend.app.services.printer_manager import PrinterManager
@@ -131,13 +132,13 @@ _SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
 _RECENT_QUEUE_ITEM_TTL = 30.0
 
 # BambuStudio's tri-state calibration options (bed_leveling / flow_cali /
-# nozzle_offset_cali) travel on the project_file command as a bool plus an int
-# companion — off=0, on=1, auto=2 (getValueInt parity). The int carries the full
-# state; the bool is true only for "on".
+# nozzle_offset_cali) travel on the project_file command as an int — off=0,
+# on=1, auto=2 (getValueInt parity). Bed levelling and flow calibration add a
+# bool that is true only for "on"; nozzle offset calibration is the int alone.
 _TRISTATE_INT = {0: "off", 1: "on", 2: "auto"}
 
 
-def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str | None:
+def _tristate_from_slicer(data: dict, bool_field: str | None, int_field: str) -> str | None:
     """Reconstruct off/on/auto from a captured slicer project_file dict.
 
     Prefer the int companion (auto_bed_leveling / extrude_cali_flag / etc.) which
@@ -151,7 +152,7 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
             resolved = None
         if resolved is not None:
             return resolved
-    if bool_field in data:
+    if bool_field is not None and bool_field in data:
         return "on" if bool(data[bool_field]) else "off"
     return None
 
@@ -325,6 +326,9 @@ class VirtualPrinterInstance:
         # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
         # queue-add.
         self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
+        # Per entry above: the highest slot id each queued plate prints, so a
+        # late slicer AMS mapping is stamped only onto plates it reaches.
+        self._recent_queue_item_reach: dict[str, dict[int, int]] = {}
 
         # Per-instance services
         self._proxy: SlicerProxyManager | None = None
@@ -411,6 +415,18 @@ class VirtualPrinterInstance:
             # IDLE reset was wrong — staying at FINISH is the designed
             # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
 
+    def _keeps_slicer_nozzle_mapping(self) -> bool:
+        """Whether the slicer's ``nozzle_mapping`` is stored on the queue item.
+
+        On a rack model (H2C) it holds physical rack positions, which name the
+        rack of the printer the slicer was looking at: kept only when this VP
+        targets that one printer. A VP for any printer of the model leaves it
+        empty, and the dispatch resolves the positions against the rack of the
+        printer it picks. Elsewhere it holds extruder indices, which are the
+        same on every printer of the model.
+        """
+        return self.target_printer_id is not None or not is_nozzle_rack_model(self.model)
+
     def _target_is_dual_nozzle(self) -> bool:
         """Whether the target printer has two nozzles, which decides the global
         tray of an external spool in the slicer's mapping (#3237).
@@ -511,11 +527,13 @@ class VirtualPrinterInstance:
         committed for this filename and patches in the slicer's
         ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
         while the items are still ``pending`` (scheduler hasn't dispatched
-        them yet).
+        them yet). The two mappings pass the same gates as in
+        ``_add_to_print_queue``.
         """
         if not self._session_factory:
             return
         entry = self._recent_queue_items.get(stash_key)
+        plate_reach = self._recent_queue_item_reach.pop(stash_key, {})
         if entry is None:
             return
         queue_item_ids, committed_at = entry
@@ -534,6 +552,7 @@ class VirtualPrinterInstance:
         for bool_field, int_field, column in (
             ("bed_leveling", "auto_bed_leveling", "bed_levelling"),
             ("flow_cali", "extrude_cali_flag", "flow_cali"),
+            (None, "nozzle_offset_cali", "nozzle_offset_cali"),
         ):
             resolved = _tristate_from_slicer(data, bool_field, int_field)
             if resolved is not None:
@@ -548,7 +567,8 @@ class VirtualPrinterInstance:
             if mqtt_field in data:
                 patch[column] = bool(data[mqtt_field])
 
-        raw = data.get("nozzle_mapping")
+        # Same gate as in `_add_to_print_queue`.
+        raw = data.get("nozzle_mapping") if self._keeps_slicer_nozzle_mapping() else None
         if raw is not None:
             if isinstance(raw, str):
                 try:
@@ -610,8 +630,29 @@ class VirtualPrinterInstance:
                 if not eligible_ids:
                     self._recent_queue_items.pop(stash_key, None)
                     return
-                if patch:
-                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+                # As in `_add_to_print_queue`: a mapping shorter than a plate's
+                # highest slot id cannot address that plate's own slots, so that
+                # plate is left to the scheduler's computed mapping.
+                short_ids: list[int] = []
+                if "ams_mapping" in patch:
+                    reach = len(json.loads(patch["ams_mapping"]))
+                    short_ids = [i for i in eligible_ids if plate_reach.get(i, 0) > reach]
+                    if short_ids:
+                        logger.warning(
+                            "[VP %s] Late slicer ams_mapping has %d entries, too few for queue item(s) %s; "
+                            "leaving those to the scheduler's mapping.",
+                            self.name,
+                            reach,
+                            short_ids,
+                        )
+                reached_ids = [i for i in eligible_ids if i not in short_ids]
+                if patch and reached_ids:
+                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(reached_ids)).values(**patch))
+                short_patch = {k: v for k, v in patch.items() if k != "ams_mapping"}
+                if short_ids and short_patch:
+                    await db.execute(
+                        update(PrintQueueItem).where(PrintQueueItem.id.in_(short_ids)).values(**short_patch)
+                    )
 
                 # The archive was already created (with no slicer_ams_mapping)
                 # before this late MQTT arrived — see
@@ -915,7 +956,7 @@ class VirtualPrinterInstance:
                         return bool(slicer_opts[field_mqtt])
                     return settings_default
 
-                def _slicer_tristate(bool_field: str, int_field: str, settings_default: str) -> str:
+                def _slicer_tristate(bool_field: str | None, int_field: str, settings_default: str) -> str:
                     """Slicer's tri-state (off/on/auto) if present, else the default."""
                     if slicer_opts is not None:
                         resolved = _tristate_from_slicer(slicer_opts, bool_field, int_field)
@@ -936,6 +977,11 @@ class VirtualPrinterInstance:
                     "extrude_cali_flag",
                     _tristate_setting(await get_setting(db, "default_flow_cali"), "auto"),
                 )
+                nozzle_offset_cali = _slicer_tristate(
+                    None,
+                    "nozzle_offset_cali",
+                    _tristate_setting(await get_setting(db, "default_nozzle_offset_cali"), "auto"),
+                )
                 vibration_cali = _slicer_or(
                     "vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
                 )
@@ -943,6 +989,8 @@ class VirtualPrinterInstance:
                     "layer_inspect", _bool_setting(await get_setting(db, "default_layer_inspect"), False)
                 )
                 timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
+                # No setting for it: the column default (on) when the slicer is silent.
+                use_ams = _slicer_or("use_ams", True)
 
                 # "Ask for Outcome" is a default print option like the ones
                 # above. A plate sent here from Bambu Studio is also one of the
@@ -965,8 +1013,13 @@ class VirtualPrinterInstance:
                 # in the original fix but BambuStudio never actually sends it
                 # — verified via wire capture on H2C — so only `nozzle_mapping`
                 # is forwarded now.)
+                #
+                # On a rack model only for a VP that targets one printer: the
+                # positions name that printer's rack. An "Any <model>" VP leaves
+                # them to the dispatch, which resolves them against the rack of
+                # the printer it picks. See `_keeps_slicer_nozzle_mapping`.
                 nozzle_mapping_json: str | None = None
-                if slicer_opts is not None:
+                if slicer_opts is not None and self._keeps_slicer_nozzle_mapping():
                     raw = slicer_opts.get("nozzle_mapping")
                     if raw is not None:
                         # BambuStudio's NetworkAgent embeds this as parsed
@@ -1109,6 +1162,7 @@ class VirtualPrinterInstance:
                     # force_color_match=True when the per-VP setting is on, so
                     # upgraders keep the old behaviour by default.
                     queue_item_ids: list[int] = []
+                    queue_item_reach: dict[int, int] = {}
                     for offset, plate_id in enumerate(plate_ids, start=1):
                         required_filament_types_json: str | None = None
                         filament_overrides_json: str | None = None
@@ -1152,6 +1206,7 @@ class VirtualPrinterInstance:
                         # back to a computed mapping for that plate instead
                         # (#2700 review).
                         plate_ams_mapping_json = queue_ams_mapping_json
+                        plate_max_slot = max((r.get("slot_id") or 0) for r in requirements) if requirements else 0
                         if queue_ams_mapping is not None and requirements:
                             max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
                             if max_slot_id > len(queue_ams_mapping):
@@ -1177,9 +1232,11 @@ class VirtualPrinterInstance:
                             filament_overrides=filament_overrides_json,
                             bed_levelling=bed_levelling,
                             flow_cali=flow_cali,
+                            nozzle_offset_cali=nozzle_offset_cali,
                             vibration_cali=vibration_cali,
                             layer_inspect=layer_inspect,
                             timelapse=timelapse,
+                            use_ams=use_ams,
                             confirm_outcome=confirm_outcome,
                             # Per-VP opt-in for auto-print G-code injection (#1516).
                             # Default off; when on, the scheduler still no-ops unless
@@ -1198,6 +1255,7 @@ class VirtualPrinterInstance:
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         queue_item_ids.append(queue_item.id)
+                        queue_item_reach[queue_item.id] = plate_max_slot
                     await db.commit()
                     # Track the freshly-committed queue items so
                     # `on_print_command` can retroactively stamp slicer-side
@@ -1210,6 +1268,10 @@ class VirtualPrinterInstance:
                     cutoff = now - _RECENT_QUEUE_ITEM_TTL
                     self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
                     self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
+                    self._recent_queue_item_reach = {
+                        k: v for k, v in self._recent_queue_item_reach.items() if k in self._recent_queue_items
+                    }
+                    self._recent_queue_item_reach[file_path.name] = queue_item_reach
                     # Last-chance check: MQTT for this filename could have
                     # arrived during ANY await between the initial pop and
                     # now — wait_for itself, archive_print, db.flush,

+ 10 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1737,6 +1737,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
 
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=42,
             name="H2CRack",
             mode="queue",
@@ -2301,6 +2303,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
 
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=44,
             name="H2CMultiPlate",
             mode="queue",
@@ -2391,6 +2395,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
 
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=99,
             name="LateMQTT",
             mode="queue",
@@ -2879,6 +2885,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
 
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=96,
             name="RaceCommitYield",
             mode="queue",
@@ -2971,6 +2979,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
 
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=98,
             name="LateMQTTDispatched",
             mode="queue",

+ 233 - 0
backend/tests/unit/services/test_vp_queue_intake_d2.py

@@ -0,0 +1,233 @@
+"""What a VP in queue mode takes from the slicer onto the queue item.
+
+Nozzle offset calibration was never read, so every VP-queued print ran with
+"auto" whatever Bambu Studio or the default print options said. The slicer's
+nozzle positions were stored for an "Any <model>" VP too, and the dispatch
+sends a stored mapping as it is instead of resolving it against the rack of the
+printer it picked.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+
+def _session(added: list, execute_results=None):
+    db = AsyncMock()
+    db.add = MagicMock(side_effect=lambda item: (added.append(item), setattr(item, "id", 100 + len(added)))[0])
+    db.flush = AsyncMock()
+    db.commit = AsyncMock()
+    if execute_results is not None:
+        db.execute = AsyncMock(side_effect=execute_results)
+    ctx = AsyncMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    return MagicMock(return_value=ctx), db
+
+
+def _vp(tmp_path, factory, **kwargs):
+    return VirtualPrinterInstance(
+        vp_id=77,
+        name="Intake",
+        mode="queue",
+        model="O1C2",
+        access_code="12345678",
+        serial_suffix="391800077",
+        base_dir=tmp_path,
+        session_factory=factory,
+        **kwargs,
+    )
+
+
+async def _enqueue(inst, tmp_path, slicer_opts: dict | None, settings: dict | None = None):
+    file_path = tmp_path / "test.3mf"
+    file_path.write_bytes(b"fake3mf")
+    if slicer_opts is not None:
+        await inst.on_print_command(file_path.name, {"command": "project_file", **slicer_opts})
+    archive = MagicMock(id=1, print_name="test")
+    values = settings or {}
+
+    async def _setting(_db, key):
+        return values.get(key)
+
+    with (
+        patch("backend.app.api.routes.settings.get_setting", side_effect=_setting),
+        patch(
+            "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=archive
+        ),
+        patch("backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05),
+    ):
+        await inst._add_to_print_queue(file_path, "192.168.1.100")
+    return file_path
+
+
+class TestNozzleOffsetCalibration:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("wire", "stored"), [(0, "off"), (1, "on"), (2, "auto")])
+    async def test_the_slicers_choice_is_kept(self, tmp_path, wire, stored):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"nozzle_offset_cali": wire}, {"default_nozzle_offset_cali": "on"})
+
+        assert added[0].nozzle_offset_cali == stored
+
+    @pytest.mark.asyncio
+    async def test_without_a_slicer_choice_the_default_applies(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"timelapse": True}, {"default_nozzle_offset_cali": "off"})
+
+        assert added[0].nozzle_offset_cali == "off"
+
+    @pytest.mark.asyncio
+    async def test_a_late_slicer_choice_is_patched_on(self, tmp_path):
+        added: list = []
+        position_max = MagicMock(scalar=MagicMock(return_value=None))
+        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        factory, db = _session(added, [position_max, pending, MagicMock()])
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+        inst._mqtt = MagicMock()
+
+        file_path = await _enqueue(inst, tmp_path, None)
+        await inst.on_print_command(
+            file_path.name, {"command": "project_file", "file": file_path.name, "nozzle_offset_cali": 0}
+        )
+
+        params = dict(db.execute.await_args_list[2].args[0].compile().params)
+        assert params["nozzle_offset_cali"] == "off"
+
+
+class TestNozzlePositionsNeedOnePrinter:
+    @pytest.mark.asyncio
+    async def test_a_vp_bound_to_a_printer_keeps_them(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
+
+        assert json.loads(added[0].nozzle_mapping) == [16, -1]
+
+    @pytest.mark.asyncio
+    async def test_an_any_model_vp_leaves_them_to_the_dispatch(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory)
+
+        await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
+
+        assert added[0].target_model is not None
+        assert added[0].nozzle_mapping is None
+
+    @pytest.mark.asyncio
+    async def test_an_any_model_vp_does_not_patch_them_on_late(self, tmp_path):
+        added: list = []
+        position_max = MagicMock(scalar=MagicMock(return_value=None))
+        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        factory, db = _session(added, [position_max, pending, MagicMock()])
+        inst = _vp(tmp_path, factory)
+        inst._mqtt = MagicMock()
+
+        file_path = await _enqueue(inst, tmp_path, None)
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "file": file_path.name, "nozzle_mapping": [16, -1], "timelapse": True},
+        )
+
+        params = dict(db.execute.await_args_list[2].args[0].compile().params)
+        assert params["timelapse"] is True
+        assert "nozzle_mapping" not in params
+
+
+class TestAnyH2DKeepsNozzleIndices:
+    @pytest.mark.asyncio
+    async def test_extruder_indices_are_the_same_on_every_printer(self, tmp_path):
+        """Off a rack model the mapping names extruders, not a printer's rack."""
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory)
+        inst.model = "O1D"  # H2D
+
+        await _enqueue(inst, tmp_path, {"nozzle_mapping": [0, 1]})
+
+        assert json.loads(added[0].nozzle_mapping) == [0, 1]
+
+
+class TestAmsOnOff:
+    @pytest.mark.asyncio
+    async def test_the_slicers_choice_is_kept_when_it_arrives_in_time(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"use_ams": False})
+
+        assert added[0].use_ams is False
+
+    @pytest.mark.asyncio
+    async def test_on_when_the_slicer_does_not_say(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"timelapse": True})
+
+        assert added[0].use_ams is True
+
+
+class TestLateAmsMappingPerPlate:
+    @pytest.mark.asyncio
+    async def test_each_queued_plate_records_the_highest_slot_it_prints(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        with patch(
+            "backend.app.services.filament_requirements.extract_filament_requirements",
+            return_value=[{"slot_id": 1, "type": "PLA"}, {"slot_id": 4, "type": "PLA"}],
+        ):
+            file_path = await _enqueue(inst, tmp_path, {"timelapse": True})
+
+        assert inst._recent_queue_item_reach[file_path.name] == {added[0].id: 4}
+
+    @pytest.mark.asyncio
+    async def test_a_plate_the_mapping_cannot_reach_keeps_the_rest_of_the_patch(self, tmp_path):
+        import time
+
+        added: list = []
+        pending = MagicMock(all=MagicMock(return_value=[(101, None), (102, None)]))
+        factory, db = _session(added, [pending, MagicMock(), MagicMock()])
+        inst = VirtualPrinterInstance(
+            vp_id=78,
+            name="LatePlates",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800078",
+            base_dir=tmp_path,
+            session_factory=factory,
+            save_ams_mapping=True,
+            target_printer_id=7,
+        )
+        inst._recent_queue_items["test.3mf"] = ([101, 102], time.monotonic())
+        inst._recent_queue_item_reach["test.3mf"] = {101: 2, 102: 6}
+
+        await inst.on_print_command(
+            "test.3mf",
+            {"command": "project_file", "file": "test.3mf", "ams_mapping": [0, 1, -1], "timelapse": True},
+        )
+
+        reached, short = (call.args[0] for call in db.execute.await_args_list[1:3])
+        reached_params = dict(reached.compile().params)
+        short_params = dict(short.compile().params)
+        assert json.loads(reached_params["ams_mapping"]) == [0, 1, -1]
+        assert [101] in reached_params.values()
+        assert "ams_mapping" not in short_params and short_params["timelapse"] is True
+        assert [102] in short_params.values()

+ 7 - 1
backend/tests/unit/test_ams_mapping_per_printer_2799.py

@@ -407,12 +407,18 @@ class TestHoldOnTheModelBasedPath:
         scheduler = _scheduler(P2S_5_TRAYS)
         scheduler._get_job_name = AsyncMock(return_value="body1")
         scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
-        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+        item = _item(
+            ams_mapping=json.dumps([0, -1, -1]),
+            nozzle_mapping=json.dumps([16, -1]),
+            nozzle_rack_choice=json.dumps({"0": 16}),
+        )
 
         blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
 
         assert blocked is True
         assert item.printer_id is None
+        # So do the nozzle positions, which name that printer's rack.
+        assert item.nozzle_mapping is None and item.nozzle_rack_choice is None
         # The mapping goes with the assignment: its tray IDs were resolved
         # against the printer just released, so leaving it would hold the job
         # again on the next printer even when that one has the filament.

+ 11 - 1
backend/tests/unit/test_scheduler_model_filament_short_3137.py

@@ -254,7 +254,16 @@ class TestAssignment:
 class TestHeldJob:
     @pytest.mark.asyncio
     async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
-        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        item_id = await _add_item(
+            queue_db,
+            printer_id=1,
+            manual_start=True,
+            filament_short=True,
+            # Worked out for printer 1; printer 2 works out its own.
+            ams_mapping="[0]",
+            nozzle_mapping="[16]",
+            nozzle_rack_choice='{"0": 16}',
+        )
         launched = MagicMock()
 
         await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
@@ -265,6 +274,7 @@ class TestHeldJob:
         assert item.printer_id == 2
         assert item.manual_start is False
         assert item.filament_short is False
+        assert (item.ams_mapping, item.nozzle_mapping, item.nozzle_rack_choice) == (None, None, None)
 
     @pytest.mark.asyncio
     async def test_stays_held_while_every_other_printer_is_short(self, queue_db):