Browse Source

fix(queue): Force color match is kept for a job queued to a specific printer: it waits for a spool of the exact colour and variant instead of printing on the first spool of the same material, and two slots asking for the same filament need two spools of it (#3280)

maziggy 13 hours ago
parent
commit
1c86625b82

+ 6 - 0
CHANGELOG.md

@@ -169,6 +169,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
+- **Force color match is kept for a job queued to a specific printer (#3280, reported by @Lioncat55)** — A job queued for one printer, for example through a virtual printer with **Force color match** on, printed on the first spool of the same material when none had the requested colour. A job queued for "Any" model was held in the same situation.
+  - **Now held:** the job waits with "Needs PLA #A4AAAC" on the queue row until a spool of that material, in exactly that colour, is loaded. Then **Start** sends it. A spool of a similar colour no longer counts, and when the file asks for a particular variant (Basic, Matte, Silk), a spool of another variant doesn't either. A spool that reports no variant still counts.
+  - **One spool per slot:** two slots asking for the same filament need two spools of it, for a job queued for one printer and for "Any" model alike. Before, one spool counted for both when choosing a printer, and the second slot was then left without one.
+  - **A slot chosen earlier is checked again:** a slot picked before the spool was swapped is checked against the colour again when the job is sent.
+  - **Printers without an AMS** hold the job too, instead of failing it, so the right spool can be put on the external holder.
+  - **Print Anyway** still sends the job as it is.
 - **Escape closes the Build Plate Check result as soon as it appears** — Pressed in the moment the result opened, Escape could be ignored, so the window stayed open and a chamber light the check had turned on stayed on until it was closed another way.
 - **The Printers page sent hundreds of requests on a print farm, and every page asked for each printer's status** — Each printer card fetched its own status, slot presets, AMS labels, power plug, action buttons, sensor readings, firmware check and queue, one request per printer. With 59 printers that was about 550 requests when the page opened, which the server worked through one behind the other, so cards took 5 to 20 seconds to fill. The sidebar, the camera wall and other pages also fetched every printer's status one by one.
   - **Now batched:** requests for the same kind of data that arrive within a few milliseconds go out as one request, so the Printers page sends about one request per kind of data however many printers there are. The cards' own refresh timers are unchanged, including the 30-second status poll, and they too are combined into one request each round.

+ 128 - 22
backend/app/services/print_scheduler.py

@@ -626,6 +626,31 @@ def _is_unmatched_hold_reason(reason: str | None) -> bool:
     return bool(reason) and reason.startswith(_UNMATCHED_HOLD_PREFIX)
 
 
+def _forced_override_requirements(item: PrintQueueItem) -> list[dict]:
+    """The item's force_color_match overrides as filament requirements (#3280).
+
+    What ``_build_override_direct_mapping`` matches when the 3MF cannot be
+    read, so the checks after it judge the same list.
+    """
+    try:
+        overrides = json.loads(item.filament_overrides) if item.filament_overrides else []
+    except (json.JSONDecodeError, TypeError):
+        return []
+    if not isinstance(overrides, list):
+        return []
+    return [
+        {
+            "slot_id": o.get("slot_id"),
+            "type": o.get("type", ""),
+            "color": o.get("color", ""),
+            "tray_info_idx": o.get("tray_info_idx", ""),
+            "force_color_match": True,
+        }
+        for o in overrides
+        if isinstance(o, dict) and o.get("force_color_match") and isinstance(o.get("slot_id"), int)
+    ]
+
+
 def _unresolved_required(required: list[dict], mapping: list) -> list[dict]:
     """The requirements in ``required`` that ``mapping`` gives no tray (#2799).
 
@@ -3511,24 +3536,36 @@ class PrintScheduler:
             for tray in ams_unit.get("tray", []):
                 tray_type = tray.get("tray_type")
                 if tray_type:
-                    color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                    color_norm = self._normalize_color_for_compare(self._normalize_color(tray.get("tray_color")))
                     loaded.append((canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or ""))
         for vt in raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
-                color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                color_norm = self._normalize_color_for_compare(self._normalize_color(vt.get("tray_color")))
                 loaded.append((canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
 
+        # One spool per slot, the way the matcher assigns them: two slots asking
+        # for the same filament need two spools of it (#3280). Slots naming a
+        # variant go first and take that variant before a spool naming none, so
+        # a slot open to any variant cannot use up the one a later slot needs.
         missing = []
-        for o in force_overrides:
+        taken: set[int] = set()
+        for o in sorted(force_overrides, key=lambda o: not o.get("tray_info_idx")):
             o_type = canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
             o_idx = o.get("tray_info_idx") or ""
-            satisfied = any(
-                t_type == o_type and t_color == o_color and (not o_idx or not t_idx or o_idx == t_idx)
-                for t_type, t_color, t_idx in loaded
-            )
-            if not satisfied:
+            fits = [
+                i
+                for i, (t_type, t_color, t_idx) in enumerate(loaded)
+                if i not in taken
+                and t_type == o_type
+                and t_color == o_color
+                and (not o_idx or not t_idx or o_idx == t_idx)
+            ]
+            pick = next((i for i in fits if o_idx and loaded[i][2] == o_idx), fits[0] if fits else None)
+            if pick is not None:
+                taken.add(pick)
+            else:
                 color_label = o.get("color_name") or o.get("color", "?")
                 missing.append(f"{o_type} ({color_label})")
         return missing
@@ -4000,9 +4037,14 @@ class PrintScheduler:
             mapping = await self._compute_ams_mapping_for_printer(db, item.printer_id, item) or []
 
         required = await self._get_filament_requirements(db, item)
-        if not required:
-            return None
-        self._apply_filament_overrides(item, required)
+        if required:
+            self._apply_filament_overrides(item, required)
+        else:
+            # The same fallback the hold uses, or Start would release a job the
+            # next pass holds again (#3280).
+            required = _forced_override_requirements(item)
+            if not required:
+                return None
         missing = _unresolved_required(required, mapping)
         return [_describe_filament(req, "nozzle_id") for req in missing] or None
 
@@ -4140,6 +4182,18 @@ class PrintScheduler:
             if want and have and want != have:
                 return f"slot {slot_id} needs {req.get('type')} but tray {tray} holds {loaded_tray.get('type')}"
 
+            # A forced slot fits only a tray the matcher would give it (#3280).
+            if req.get("force_color_match"):
+                want_idx = req.get("tray_info_idx") or ""
+                have_idx = loaded_tray.get("tray_info_idx") or ""
+                if self._normalize_color_for_compare(req.get("color")) != self._normalize_color_for_compare(
+                    loaded_tray.get("color")
+                ) or (want_idx and have_idx and want_idx != have_idx):
+                    return (
+                        f"slot {slot_id} is forced to {req.get('type')} {req.get('color')} "
+                        f"but tray {tray} holds {loaded_tray.get('type')} {loaded_tray.get('color')}"
+                    )
+
         return None
 
     async def _unmappable_without_ams_message(
@@ -4194,6 +4248,10 @@ class PrintScheduler:
             # item on stale evidence.
             return None
         self._apply_filament_overrides(item, required)
+        if any(r.get("force_color_match") for r in required):
+            # A forced colour is a spool the user can load on the external
+            # holder, so the unmatched hold stages the job instead (#3280).
+            return None
         return _unmatched_filament_message(required, loaded)
 
     async def _fail_unmappable_item(
@@ -4372,11 +4430,15 @@ class PrintScheduler:
                     # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
                     # it here too, letting the matcher pin the correct variant slot on a
                     # printer holding two same-colour spools of different variants (#2650).
-                    # If that variant isn't loaded the matcher falls back to type+colour,
-                    # so an eligible printer never fails to map.
+                    # A spool that names no variant still counts; one naming another
+                    # variant does not, as in `_get_missing_force_color_slots` (#3280).
                     req["tray_info_idx"] = (
                         override.get("tray_info_idx", "") if override.get("force_color_match") else ""
                     )
+                    # The matcher satisfies a forced slot only the way
+                    # `_get_missing_force_color_slots` does, never by a similar
+                    # colour or the type alone (#3280).
+                    req["force_color_match"] = bool(override.get("force_color_match"))
                     logger.debug(
                         "Queue item %s: Override slot %d -> %s %s",
                         item.id,
@@ -4410,9 +4472,10 @@ class PrintScheduler:
                 "color": o.get("color", ""),
                 # These are all force_color_match overrides, so the idx (when the
                 # 3MF carried one) is the intended variant, not a stale swap —
-                # keep it so the matcher pins the right variant slot, falling back
-                # to type+colour when it isn't loaded (#2650).
+                # keep it so the matcher pins the right variant slot (#2650); a
+                # spool naming another variant does not satisfy it (#3280).
                 "tray_info_idx": o.get("tray_info_idx", ""),
+                "force_color_match": True,
             }
             for o in force_overrides
         ]
@@ -4762,7 +4825,18 @@ class PrintScheduler:
         used_tray_ids: set[int] = set()
         comparisons = []
 
-        for req in required:
+        # Forced slots first: a slot that would take any tray of its type must
+        # not take the one tray a forced slot can use (#3280). The mapping is
+        # built by slot_id below, so the order only decides who goes first.
+        # Among forced slots, those naming a variant first, as
+        # `_get_missing_force_color_slots` counts them.
+        for req in sorted(
+            required,
+            key=lambda r: (
+                not r.get("force_color_match"),
+                bool(r.get("force_color_match")) and not r.get("tray_info_idx"),
+            ),
+        ):
             req_type = (req.get("type") or "").upper()
             req_color = req.get("color", "")
             req_tray_info_idx = req.get("tray_info_idx", "")
@@ -4822,8 +4896,34 @@ class PrintScheduler:
                     ],
                 )
 
+            # A forced slot takes only what `_get_missing_force_color_slots`
+            # accepts: same type, exact colour, and the same variant when both
+            # sides name one -- preferring the tray that does. Nothing looser:
+            # the model-based path holds a job the printer cannot satisfy this
+            # way, and a printer-pinned job fell through to type_only and
+            # printed in whatever colour came first (#3280).
+            forced = bool(req.get("force_color_match"))
+            if forced:
+                want_type = canonical_filament_type(req_type)
+                want_color = self._normalize_color_for_compare(req_color)
+                candidates = [
+                    f
+                    for f in available
+                    if canonical_filament_type((f.get("type") or "").upper()) == want_type
+                    and self._normalize_color_for_compare(f.get("color", "")) == want_color
+                    and (
+                        not req_tray_info_idx
+                        or not f.get("tray_info_idx")
+                        or f.get("tray_info_idx") == req_tray_info_idx
+                    )
+                ]
+                exact_match = next(
+                    (f for f in candidates if req_tray_info_idx and f.get("tray_info_idx") == req_tray_info_idx),
+                    candidates[0] if candidates else None,
+                )
+
             # Check if tray_info_idx is unique among available trays
-            if req_tray_info_idx:
+            if req_tray_info_idx and not forced:
                 idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
                 if len(idx_matches) == 1:
                     # Unique tray_info_idx - use it as definitive match
@@ -4855,7 +4955,7 @@ class PrintScheduler:
                             type_only_match = f
 
             # If no idx_match yet, do standard type/color matching on all available trays
-            if not idx_match and not exact_match and not similar_match and not type_only_match:
+            if not forced and not idx_match and not exact_match and not similar_match and not type_only_match:
                 for f in available:
                     f_type = (f.get("type") or "").upper()
                     if canonical_filament_type(f_type) != canonical_filament_type(req_type):
@@ -4911,11 +5011,12 @@ class PrintScheduler:
                 )
             else:
                 logger.info(
-                    "[ams-match] NO MATCH for req slot=%s (type=%r color=%r tii=%r)",
+                    "[ams-match] NO MATCH for req slot=%s (type=%r color=%r tii=%r%s)",
                     req.get("slot_id"),
                     req_type,
                     req_color,
                     req_tray_info_idx,
+                    ", force color match" if forced else "",
                 )
 
         # Build mapping array
@@ -8268,9 +8369,14 @@ class PrintScheduler:
             mapping = []
 
         required = await self._get_filament_requirements(db, item)
-        if not required:
-            return False
-        self._apply_filament_overrides(item, required)
+        if required:
+            self._apply_filament_overrides(item, required)
+        else:
+            # An unreadable 3MF still has its forced colours to go by: they are
+            # what the mapping was built from (#3280).
+            required = _forced_override_requirements(item)
+            if not required:
+                return False
 
         unmatched = _unresolved_required(required, mapping)
         if not unmatched:

+ 363 - 0
backend/tests/unit/test_force_color_pinned_3280.py

@@ -0,0 +1,363 @@
+"""Force color match on a printer-pinned queue item (#3280).
+
+A virtual printer in queue mode with Force color match queued a job for one
+A1. The file wanted light grey PLA (#A4AAAC); the AMS held black, blue grey and
+white PLA, none of them that grey. The model-based path would have held the
+job, but the pinned path went straight to the matcher, which was never told the
+slot was forced: with no exact or similar colour it took the first PLA tray
+(``type_only``) and the job printed in black.
+
+A forced slot is satisfied only by what ``_get_missing_force_color_slots``
+accepts: the same type, the exact colour, and the same variant when both sides
+carry a ``tray_info_idx``. Anything less leaves the slot unresolved, and the
+unmatched-filament hold (#2799) stages the job instead of dispatching it.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+# The 3MF as sliced; the force override below rewrites slot 1.
+REQUIREMENTS = [{"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "tray_info_idx": "GFA00"}]
+
+FORCED = [{"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "tray_info_idx": "GFA00", "force_color_match": True}]
+
+# The reporter's A1 AMS Lite at dispatch.
+A1_TRAYS = [
+    {"global_tray_id": 0, "type": "PETG", "color": "FFFFFFFF", "tray_info_idx": "GFG00"},
+    {"global_tray_id": 1, "type": "PLA", "color": "000000FF", "tray_info_idx": "GFA00"},
+    {"global_tray_id": 2, "type": "PLA", "color": "5A657BFF", "tray_info_idx": "GFA00"},
+    {"global_tray_id": 3, "type": "PLA", "color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+]
+
+
+def _item(**overrides):
+    item = MagicMock()
+    item.id = 495
+    item.printer_id = 4
+    item.archive_id = 460
+    item.library_file_id = None
+    item.plate_id = None
+    item.ams_mapping = None
+    item.skip_filament_check = False
+    item.filament_overrides = json.dumps(FORCED)
+    item.required_filament_types = None
+    item.manual_start = False
+    item.filament_short = False
+    item.waiting_reason = None
+    for key, value in overrides.items():
+        setattr(item, key, value)
+    return item
+
+
+def _scheduler(trays, requirements=REQUIREMENTS):
+    scheduler = PrintScheduler()
+    scheduler._build_loaded_filaments = MagicMock(return_value=[dict(t) for t in trays])
+    scheduler._get_filament_requirements = AsyncMock(
+        return_value=[dict(r) for r in requirements] if requirements else None
+    )
+    scheduler._get_bool_setting = AsyncMock(return_value=False)
+    scheduler._get_job_name = AsyncMock(return_value="bracket")
+    scheduler._get_printer = AsyncMock(return_value=MagicMock(model="A1"))
+    return scheduler
+
+
+def _forced_req(color="#A4AAAC", idx="GFA00"):
+    return {"slot_id": 1, "type": "PLA", "color": color, "tray_info_idx": idx, "force_color_match": True}
+
+
+def _tray(gtid, color, idx="GFA00", type_="PLA"):
+    return {"global_tray_id": gtid, "type": type_, "color": color, "tray_info_idx": idx}
+
+
+class TestMatcherHonoursForcedSlots:
+    """``_match_filaments_to_slots`` never satisfies a forced slot loosely."""
+
+    def test_the_reported_case_is_left_unresolved(self):
+        """No #A4AAAC PLA loaded: type_only used to pick the black tray."""
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], A1_TRAYS) == [-1]
+
+    def test_a_similar_colour_does_not_satisfy_a_forced_slot(self):
+        """Within the similar-colour box, but not the colour the user forced."""
+        trays = [_tray(0, "A0A8AAFF")]
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], trays) == [-1]
+
+    def test_a_unique_variant_in_the_wrong_colour_does_not_satisfy_it(self):
+        """The idx bucket alone never looked at colour."""
+        trays = [_tray(0, "000000FF", idx="GFA00"), _tray(1, "A4AAACFF", idx="GFA01")]
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], trays) == [-1]
+
+    def test_the_exact_colour_is_matched(self):
+        trays = [_tray(0, "000000FF"), _tray(1, "A4AAACFF")]
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], trays) == [1]
+
+    def test_the_exact_colour_in_the_right_variant_wins_over_another_variant(self):
+        """#2650: two same-colour spools, the forced variant picks between them."""
+        trays = [_tray(0, "A4AAACFF", idx="GFA01"), _tray(1, "A4AAACFF", idx="GFA00")]
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], trays) == [1]
+
+    def test_the_named_variant_wins_over_a_spool_that_names_none(self):
+        """Both are acceptable; the one that is known to be right comes first."""
+        trays = [_tray(0, "A4AAACFF", idx=""), _tray(1, "A4AAACFF", idx="GFA00")]
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], trays) == [1]
+
+    def test_a_spool_without_a_variant_still_counts(self):
+        """Third-party spools report no idx; the force check accepts them too."""
+        trays = [_tray(0, "A4AAACFF", idx="")]
+        assert PrintScheduler()._match_filaments_to_slots([_forced_req()], trays) == [0]
+
+    def test_an_unforced_slot_does_not_take_the_forced_slots_only_tray(self):
+        """Slot 1 would take any PLA; slot 2 can only use tray 0. Matched in slot
+        order, slot 1 took tray 0 and slot 2 was held with its spool loaded."""
+        unforced = {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": ""}
+        forced = {**_forced_req(), "slot_id": 2}
+        trays = [_tray(0, "A4AAACFF"), _tray(1, "000000FF")]
+        assert PrintScheduler()._match_filaments_to_slots([unforced, forced], trays) == [1, 0]
+
+    def test_an_unforced_slot_still_falls_back_to_type_only(self):
+        """Nothing changes for jobs that did not ask for strict matching."""
+        req = {"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "tray_info_idx": ""}
+        assert PrintScheduler()._match_filaments_to_slots([req], A1_TRAYS) == [1]
+
+
+class TestOverridesCarryTheForceFlag:
+    def test_a_force_override_marks_the_requirement(self):
+        scheduler = PrintScheduler()
+        reqs = [dict(r) for r in REQUIREMENTS]
+        scheduler._apply_filament_overrides(_item(), reqs)
+        assert reqs[0]["force_color_match"] is True
+
+    def test_a_preference_override_does_not(self):
+        scheduler = PrintScheduler()
+        reqs = [dict(r) for r in REQUIREMENTS]
+        pref = [{"slot_id": 1, "type": "PLA", "color": "#000000"}]
+        scheduler._apply_filament_overrides(_item(filament_overrides=json.dumps(pref)), reqs)
+        assert not reqs[0].get("force_color_match")
+
+    def test_the_no_3mf_fallback_is_strict_too(self):
+        """#1436's direct mapping from the overrides alone."""
+        status = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFA00"},
+                        ],
+                    }
+                ]
+            }
+        )
+        assert PrintScheduler()._build_override_direct_mapping(FORCED, status) == [-1]
+
+
+class TestPinnedItemIsHeld:
+    """The pinned dispatch path: ``_ensure_ams_mapping`` then the unmatched hold."""
+
+    @pytest.mark.asyncio
+    async def test_the_reported_job_is_held_instead_of_printing_in_black(self):
+        scheduler = _scheduler(A1_TRAYS)
+        item = _item()
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [{"id": 0}]}, ams_filament_backup=None)
+            unmappable = await scheduler._ensure_ams_mapping(db, 4, item)
+            blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert unmappable is None
+        assert blocked is True
+        assert item.manual_start is True
+        assert item.waiting_reason == "Needs PLA #A4AAAC"
+
+    @pytest.mark.asyncio
+    async def test_the_job_goes_out_once_the_colour_is_loaded(self):
+        trays = [*A1_TRAYS[:3], _tray(3, "A4AAACFF")]
+        scheduler = _scheduler(trays)
+        item = _item()
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [{"id": 0}]}, ams_filament_backup=None)
+            await scheduler._ensure_ams_mapping(db, 4, item)
+            blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert json.loads(item.ams_mapping) == [3]
+        assert blocked is False
+
+    @pytest.mark.asyncio
+    async def test_a_stored_mapping_onto_the_wrong_colour_is_not_trusted(self):
+        """A mapping computed earlier (or by hand) that points the forced slot at
+        the black spool fits by type, which was all the conflict check asked."""
+        scheduler = _scheduler(A1_TRAYS)
+        item = _item(ams_mapping=json.dumps([1]))
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [{"id": 0}]}, ams_filament_backup=None)
+            conflict = await scheduler._stored_mapping_conflict(db, 4, item, [1])
+            await scheduler._ensure_ams_mapping(db, 4, item)
+            blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert conflict is not None
+        assert "#A4AAAC" in conflict
+        assert blocked is True
+
+    @pytest.mark.asyncio
+    async def test_a_stored_mapping_onto_the_forced_colour_is_kept(self):
+        trays = [*A1_TRAYS[:3], _tray(3, "A4AAACFF")]
+        scheduler = _scheduler(trays)
+        item = _item(ams_mapping=json.dumps([3]))
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [{"id": 0}]}, ams_filament_backup=None)
+            assert await scheduler._stored_mapping_conflict(db, 4, item, [3]) is None
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_still_dispatches(self):
+        scheduler = _scheduler(A1_TRAYS)
+        item = _item(ams_mapping=json.dumps([-1]), skip_filament_check=True)
+        assert await scheduler._block_on_unmatched_filament(AsyncMock(), item) is False
+
+    @pytest.mark.asyncio
+    async def test_an_unreadable_3mf_is_held_on_the_overrides(self):
+        """No requirements from the file: the forced overrides are the requirements."""
+        scheduler = _scheduler(A1_TRAYS, requirements=None)
+        item = _item(ams_mapping=json.dumps([-1]))
+        db = AsyncMock()
+
+        blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert blocked is True
+        assert item.waiting_reason == "Needs PLA #A4AAAC"
+
+    @pytest.mark.asyncio
+    async def test_start_on_an_unreadable_3mf_names_the_forced_colour(self):
+        """Start must answer like the hold, or it releases a job the next pass
+        holds again, with no Print Anyway offered in between."""
+        scheduler = _scheduler(A1_TRAYS, requirements=None)
+        item = _item(ams_mapping=json.dumps([-1]))
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [{"id": 0}]}, ams_filament_backup=None)
+            missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
+
+        assert missing == ["PLA #A4AAAC"]
+
+
+class TestPrinterWithoutAms:
+    """#2771 fails an unmatchable item on a printer with only the external spool.
+    A forced colour is something the user can load there, so it is held instead."""
+
+    @pytest.mark.asyncio
+    async def test_a_forced_colour_is_not_failed(self):
+        external = [{"global_tray_id": 254, "type": "PLA", "color": "000000FF", "tray_info_idx": "GFA00"}]
+        scheduler = _scheduler(external)
+        item = _item()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [], "vt_tray": [{}]})
+            message = await scheduler._unmappable_without_ams_message(AsyncMock(), 4, item, [-1])
+
+        assert message is None
+
+    @pytest.mark.asyncio
+    async def test_an_unforced_mismatch_still_fails(self):
+        external = [{"global_tray_id": 254, "type": "PETG", "color": "000000FF", "tray_info_idx": ""}]
+        scheduler = _scheduler(external)
+        item = _item(filament_overrides=None)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(raw_data={"ams": [], "vt_tray": [{}]})
+            message = await scheduler._unmappable_without_ams_message(AsyncMock(), 4, item, [-1])
+
+        assert message is not None
+
+
+class TestOneSpoolPerForcedSlot:
+    """Two forced slots asking for the same filament need two spools of it.
+
+    The matcher never gives one tray to two slots, so a pre-check that let one
+    spool satisfy both picked a printer the matcher then could not map, and an
+    "Any model" job was held, released and picked again without end.
+    """
+
+    @staticmethod
+    def _status(trays):
+        return MagicMock(raw_data={"ams": [{"id": 0, "tray": trays}]})
+
+    @staticmethod
+    def _white(tray_id, idx="GFA00"):
+        return {"id": tray_id, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": idx}
+
+    @staticmethod
+    def _override(slot_id, idx=None):
+        o = {"slot_id": slot_id, "type": "PLA", "color": "#FFFFFF", "force_color_match": True}
+        if idx is not None:
+            o["tray_info_idx"] = idx
+        return o
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_one_spool_does_not_satisfy_two_slots(self, mock_pm):
+        mock_pm.get_status.return_value = self._status([self._white(0)])
+        missing = PrintScheduler()._get_missing_force_color_slots(5, [self._override(1), self._override(2)])
+        assert missing == ["PLA (#FFFFFF)"]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_two_spools_satisfy_two_slots(self, mock_pm):
+        mock_pm.get_status.return_value = self._status([self._white(0), self._white(1)])
+        assert PrintScheduler()._get_missing_force_color_slots(5, [self._override(1), self._override(2)]) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_a_slot_open_to_any_variant_leaves_the_named_variant_alone(self, mock_pm):
+        """Slot 1 takes any white PLA, slot 2 only Basic. Taken in slot order,
+        slot 1 would use up the Basic spool and slot 2 would read as missing."""
+        mock_pm.get_status.return_value = self._status([self._white(0, "GFA00"), self._white(1, "GFA06")])
+        overrides = [self._override(1), self._override(2, "GFA00")]
+        assert PrintScheduler()._get_missing_force_color_slots(5, overrides) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_a_named_variant_takes_its_own_spool_before_one_naming_none(self, mock_pm):
+        """Slot 1 wants Basic, slot 2 Silk. The spool naming no variant is the
+        only one Silk can use, so Basic must take the Basic spool."""
+        mock_pm.get_status.return_value = self._status([self._white(0, ""), self._white(1, "GFA00")])
+        overrides = [self._override(1, "GFA00"), self._override(2, "GFA06")]
+        assert PrintScheduler()._get_missing_force_color_slots(5, overrides) == []
+
+    def test_the_matcher_agrees(self):
+        """Whatever the pre-check accepts, the matcher maps."""
+        open_slot = {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "force_color_match": True}
+        basic = {"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA00", "force_color_match": True}
+        trays = [_tray(0, "FFFFFFFF", idx="GFA00"), _tray(1, "FFFFFFFF", idx="GFA06")]
+        assert PrintScheduler()._match_filaments_to_slots([open_slot, basic], trays) == [1, 0]
+
+    def test_unforced_slots_keep_their_order(self):
+        """Only forced slots are reordered; plain jobs match as they always did."""
+        plain = {"slot_id": 1, "type": "PLA", "color": "#123456", "tray_info_idx": ""}
+        named = {"slot_id": 2, "type": "PLA", "color": "#654321", "tray_info_idx": "GFA00"}
+        trays = [_tray(0, "000000FF", idx="GFA06"), _tray(1, "FFFFFFFF", idx="GFA06")]
+        assert PrintScheduler()._match_filaments_to_slots([plain, named], trays) == [0, 1]
+
+
+class TestASpoolWithoutAColour:
+    """The pre-check reads a tray the way the matcher does: no colour is #808080."""
+
+    @staticmethod
+    def _status():
+        return MagicMock(raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]})
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_grey_is_found_on_a_tray_without_a_colour(self, mock_pm):
+        mock_pm.get_status.return_value = self._status()
+        override = [{"slot_id": 1, "type": "PLA", "color": "#808080", "force_color_match": True}]
+        assert PrintScheduler()._get_missing_force_color_slots(5, override) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_an_empty_colour_is_not_found_on_it(self, mock_pm):
+        mock_pm.get_status.return_value = self._status()
+        override = [{"slot_id": 1, "type": "PLA", "color": "", "force_color_match": True}]
+        assert PrintScheduler()._get_missing_force_color_slots(5, override) != []

+ 3 - 5
backend/tests/unit/test_scheduler_force_color_ams_fallback.py

@@ -65,11 +65,9 @@ class TestBuildOverrideDirectMapping:
         )
         overrides = [{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": True}]
         result = scheduler._build_override_direct_mapping(overrides, status)
-        # Type matches but color is far off (red vs beige) → type-only fallback → [0]
-        # If colour threshold is exceeded, falls back to type-only, which IS a match.
-        # The important thing: result is not None and has the right length.
-        assert result is not None
-        assert len(result) == 1
+        # Type matches but the colour does not: a forced slot never falls back to
+        # type-only (#3280), so the slot stays unresolved and the job is held.
+        assert result == [-1]
 
     def test_multiple_overrides_map_multiple_slots(self, scheduler):
         """Two overrides with different slot_ids produce a two-element mapping."""