ソースを参照

fix(queue): models matched without regard to case when adding jobs and in pipelines, stored in the printers' spelling; webhook reprints reuse the slicer's AMS slots; a multi-plate file queued without a plate stores the plate that prints

maziggy 3 日 前
親
コミット
918aa26ffa

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ 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
+- **Queue jobs added through the API reach the printers they should** — Three cases in the API and webhooks:
+  - **A model in lower case** (`"target_model": "x1c"`) was refused with "No active printers for model", although the scheduler matches models without regard to case. Adding (one job, alternatives, or library files), editing and pipelines now match it the same way and store the printers' own spelling (`X1C`); two alternatives for one model in different case are refused as the same printers. A pipeline aimed at a model class in lower case found no printers.
+  - **A reprint through the webhook** (`/webhook/queue/add`) of a print sent through the virtual printer ignored the AMS slots the slicer had picked, so the scheduler matched slots by colour and type again. It now reuses them on the printer they were picked on, as a reprint from the UI does.
+  - **A multi-plate file queued without a plate** (`/library/files/add-to-queue`, `POST /queue/`) prints its default plate, but the scheduler required every plate's filament, so a printer with that plate's filament loaded was never chosen, or the job was held for another plate's filament. The plate that prints is now stored on the job.
 - **Queue edits made while the queue is being worked through are kept** — The scheduler works on the queue as it read it at the start of each round, and a round can take minutes while a smart plug powers a printer on. A job edited in the meantime lost the edit: an AMS mapping set by hand was overwritten, a job moved to another printer (or an "Any model" job pinned to one) could still be sent to the printer the round had picked, and a new plate could be mapped from the old plate's filaments. A job edited during a round now waits for the next one, which reads the edit. A bulk edit likewise leaves a job that started dispatching while the edit was checked, instead of changing its printer or cost centre under the upload. While waiting for a printer to power on, the scheduler no longer keeps a database transaction open.
 - **A print's filament and cost are charged with its own plate and AMS mapping** — Bambuddy remembers per archive which plate and AMS slots a dispatch uses, for the usage and cost charged when the print ends. Three cases used the wrong ones:
   - **A whole file queued after one plate of it** was sent but never started (refused, or the printer stayed idle) was charged as that plate, with its slots.

+ 21 - 13
backend/app/api/routes/library.py

@@ -24,7 +24,7 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
-from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
+from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf, printed_plate_of_multi_plate_file
 from backend.app.core.auth import (
     ApiKeyActor,
     QueueReviewRequired,
@@ -3203,17 +3203,19 @@ async def add_files_to_queue(
     # silently declines when it finds nothing, because there, owning no such
     # printer is the user's situation rather than their mistake -- the file
     # still queues, as the unassigned row it has always been.
-    active_models: set[str] = set()
+    # Keyed without regard to case, as the scheduler matches a model, to the
+    # printers' own spelling, which the items store.
+    active_models: dict[str, str] = {}
     if request.printer_id is None:
-        active_models = {
-            model
-            for (model,) in (
-                await db.execute(select(Printer.model).where(Printer.is_active == True).distinct())  # noqa: E712
-            ).all()
-            if model
-        }
-        if target_model_norm and target_model_norm not in active_models:
-            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+        for (model,) in (
+            await db.execute(select(Printer.model).where(Printer.is_active == True).order_by(Printer.id))  # noqa: E712
+        ).all():
+            if model:
+                active_models.setdefault(model.lower(), model)
+        if target_model_norm:
+            if target_model_norm.lower() not in active_models:
+                raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+            target_model_norm = active_models[target_model_norm.lower()]
 
     # Get all requested files
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
@@ -3312,11 +3314,16 @@ async def add_files_to_queue(
                     item_target_model = target_model_norm
                 else:
                     inferred = resolve_variant_model(lib_file)
-                    item_target_model = inferred if inferred in active_models else None
+                    item_target_model = active_models.get(inferred.lower()) if inferred else None
 
             # Filament the scheduler must match before handing a model-based
             # item to hardware. Without it the item goes to whichever printer
             # of that model is idle, whatever is loaded in it.
+            # The plate that prints: added without one, a multi-plate file
+            # prints its default plate. Stored on the item, so the filament a
+            # printer must have, the AMS mapping and the unmatched-filament
+            # check all read that plate rather than every plate.
+            printed_plate = await asyncio.to_thread(printed_plate_of_multi_plate_file, file_path)
             required_filament_types = None
             if item_target_model:
                 # POST /queue/'s own extractor, borrowed rather than
@@ -3328,7 +3335,7 @@ async def add_files_to_queue(
                 # bulk add of a few hundred -- especially from an external
                 # folder on a NAS -- the zip reads add up to a stall the whole
                 # event loop takes, status ingest included.
-                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
+                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path, printed_plate)
                 if filament_types:
                     required_filament_types = json.dumps(filament_types)
 
@@ -3338,6 +3345,7 @@ async def add_files_to_queue(
                 printer_id=item_printer_id,
                 target_model=item_target_model,
                 required_filament_types=required_filament_types,
+                plate_id=printed_plate,
                 library_file_id=file_id,
                 project_id=lib_file.project_id
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),

+ 4 - 3
backend/app/api/routes/pipeline_runs.py

@@ -450,7 +450,7 @@ async def _pick_assignments(
         (
             await db.execute(
                 select(Printer)
-                .where(Printer.model == pipeline.target_model_class)
+                .where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
                 .where(Printer.is_active.is_(True))
                 .order_by(Printer.id)
             )
@@ -475,8 +475,9 @@ async def _pick_assignments(
         # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
         return [(matching[i % len(matching)].id, None) for i in range(copies)]
     # max_parallel — leave printer_id=None, set target_model so the scheduler
-    # picks any free X1C / P1S / … for each item independently.
-    return [(None, pipeline.target_model_class)] * copies
+    # picks any free X1C / P1S / … for each item independently. In the
+    # printers' own spelling: the class is matched without regard to case.
+    return [(None, matching[0].model)] * copies
 
 
 def _make_orchestration_callable(

+ 79 - 40
backend/app/api/routes/print_queue.py

@@ -2,6 +2,7 @@
 
 import json
 import logging
+import re
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
@@ -616,6 +617,65 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     return response
 
 
+def saved_slicer_ams_mapping(archive: PrintArchive | None, printer_id: int | None) -> str | None:
+    """The AMS slots the slicer picked when *archive* printed, as JSON, for a
+    reprint on that same printer; None otherwise.
+
+    Slot numbers mean something only on the printer they were picked on, so a
+    model-based job (no printer) or one for another printer gets none.
+    """
+    if archive is None or not archive.extra_data or printer_id is None:
+        return None
+    saved = archive.extra_data.get("slicer_ams_mapping")
+    if (
+        isinstance(saved, dict)
+        and saved.get("printer_id") == printer_id
+        and isinstance(saved.get("mapping"), list)
+        and saved["mapping"]
+    ):
+        return json.dumps(saved["mapping"])
+    return None
+
+
+def printed_plate_of_multi_plate_file(file_path: Path | None) -> int | None:
+    """The plate a dispatch of *file_path* prints when no plate is named, for
+    a file with more than one sliced plate; None otherwise.
+
+    Stored on a queue job that names none, it makes every later step (the
+    filament a printer must have, the AMS mapping, the unmatched-filament
+    check, charging) read the plate that prints rather than all of them.
+    What prints does not change: the dispatch resolves the same plate.
+    """
+    if file_path is None or not file_path.name.lower().endswith(".3mf") or not file_path.exists():
+        return None
+    try:
+        with zipfile.ZipFile(file_path) as zf:
+            plates = [n for n in zf.namelist() if re.fullmatch(r"Metadata/plate_\d+\.gcode", n)]
+    except (OSError, zipfile.BadZipFile):
+        return None
+    if len(plates) < 2:
+        return None
+    from backend.app.services.print_scheduler import _effective_plate_id
+
+    return _effective_plate_id(None, file_path)
+
+
+async def _active_printer_model(db: AsyncSession, model: str) -> str | None:
+    """The model as an active printer spells it, matched without regard to case
+    as the scheduler matches it ("x1c" finds the X1Cs); None when no active
+    printer is of that model. Stored in that spelling, a job compares equal
+    wherever its model is compared exactly."""
+    return (
+        await db.execute(
+            select(Printer.model)
+            .where(func.lower(Printer.model) == model.lower())
+            .where(Printer.is_active == True)  # noqa: E712
+            .order_by(Printer.id)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+
+
 @router.get("/", response_model=list[PrintQueueItemResponse])
 async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
@@ -758,24 +818,17 @@ async def _resolve_queue_variants(
                 f"{library_file.filename} was sliced for {sliced_for} and cannot be dispatched to {model} printers",
             )
 
-        if model in seen_models:
+        # The printers' own spelling, matched without regard to case
+        has_printer = await _active_printer_model(db, model)
+        model = has_printer or model
+        if model.lower() in seen_models:
             raise HTTPException(
                 400,
-                f"{library_file.filename} and {seen_models[model]} are both for {model} — "
+                f"{library_file.filename} and {seen_models[model.lower()]} are both for {model} — "
                 "variants must target different printers",
             )
-        seen_models[model] = library_file.filename
-
-        has_printer = (
-            (
-                await db.execute(
-                    select(Printer).where(Printer.model == model).where(Printer.is_active == True)  # noqa: E712
-                )
-            )
-            .scalars()
-            .first()
-        )
-        any_active_printer = any_active_printer or bool(has_printer)
+        seen_models[model.lower()] = library_file.filename
+        any_active_printer = any_active_printer or has_printer is not None
 
         resolved.append((spec, library_file, model))
 
@@ -905,11 +958,10 @@ async def add_to_queue(
     # target_model there is just the first candidate, and _resolve_queue_variants
     # has already required that *some* candidate has a printer.
     if target_model_norm and not data.variants:
-        result = await db.execute(
-            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
-        )
-        if not result.scalars().first():
+        active_model = await _active_printer_model(db, target_model_norm)
+        if active_model is None:
             raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+        target_model_norm = active_model
 
     # Validate archive exists (if provided) and get it for filament extraction
     archive = None
@@ -965,9 +1017,10 @@ async def add_to_queue(
     elif library_file:
         lib_path = Path(library_file.file_path)
         file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+    plate_id = data.plate_id or printed_plate_of_multi_plate_file(file_path)
     if target_model_norm:
         if file_path and file_path.exists():
-            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
+            filament_types = _extract_filament_types_from_3mf(file_path, plate_id)
             if filament_types:
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
@@ -1126,7 +1179,7 @@ async def add_to_queue(
             db,
             archive=archive,
             library_file=library_file,
-            plate_id=data.plate_id,
+            plate_id=plate_id,
             ams_mapping=data.ams_mapping,
             printer_id=data.printer_id,
         )
@@ -1172,21 +1225,8 @@ async def add_to_queue(
     wants_live_color_match = any(
         isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
     )
-    if (
-        ams_mapping_json is None
-        and not wants_live_color_match
-        and archive
-        and archive.extra_data
-        and data.printer_id is not None
-    ):
-        saved = archive.extra_data.get("slicer_ams_mapping")
-        if (
-            isinstance(saved, dict)
-            and saved.get("printer_id") == data.printer_id
-            and isinstance(saved.get("mapping"), list)
-            and saved["mapping"]
-        ):
-            ams_mapping_json = json.dumps(saved["mapping"])
+    if ams_mapping_json is None and not wants_live_color_match:
+        ams_mapping_json = saved_slicer_ams_mapping(archive, data.printer_id)
     items = []
     for i in range(quantity):
         item = PrintQueueItem(
@@ -1208,7 +1248,7 @@ async def add_to_queue(
             skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             nozzle_rack_choice=nozzle_rack_choice_json,
-            plate_id=data.plate_id,
+            plate_id=plate_id,
             bed_levelling=data.bed_levelling,
             flow_cali=data.flow_cali,
             vibration_cali=data.vibration_cali,
@@ -2154,11 +2194,10 @@ async def update_queue_item(
 
     # Validate target_model has active printers
     if "target_model" in update_data and update_data["target_model"]:
-        result = await db.execute(
-            select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True)  # noqa: E712
-        )
-        if not result.scalars().first():
+        active_model = await _active_printer_model(db, update_data["target_model"])
+        if active_model is None:
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
+        update_data["target_model"] = active_model
 
         # Cross-model safety gate (#2578) — same check as the create route, so
         # a mismatched target can't be introduced by editing either.

+ 3 - 1
backend/app/api/routes/webhook.py

@@ -5,7 +5,7 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.print_queue import _assert_can_queue_archive
+from backend.app.api.routes.print_queue import _assert_can_queue_archive, saved_slicer_ams_mapping
 from backend.app.core.auth import (
     ApiKeyActor,
     api_key_printer_scope,
@@ -168,6 +168,8 @@ async def webhook_add_to_queue(
         manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
         # No print options in the request: the install's defaults.
         **await default_print_options(db),
+        # The slicer's slots, as a reprint from the UI reuses them
+        ams_mapping=saved_slicer_ams_mapping(archive, data.printer_id),
     )
     db.add(queue_item)
     await db.commit()

+ 11 - 2
backend/app/services/pipeline_eligibility.py

@@ -30,7 +30,7 @@ import json
 from dataclasses import dataclass
 from typing import Literal
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.local_preset import LocalPreset
@@ -282,7 +282,16 @@ async def check_pipeline_eligibility(
             issues=(EligibilityIssue(kind="class_not_set"),),
         )
 
-    candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
+    # Without regard to case, as the queue and the scheduler match a model
+    candidates = (
+        (
+            await db.execute(
+                select(Printer).where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
+            )
+        )
+        .scalars()
+        .all()
+    )
 
     if not candidates:
         return EligibilityReport(

+ 115 - 0
backend/tests/integration/test_library_add_to_queue_plate_filament.py

@@ -0,0 +1,115 @@
+"""A multi-plate file queued without a plate is matched on the plate that prints (PL-C6).
+
+Queued without a plate, the file prints its default plate. The scheduler was
+nevertheless told every plate's filament, so an X1C with only plate 1's PLA
+loaded never qualified because plate 2 needs PETG-CF; and with the type list
+narrowed alone, the AMS mapping and the unmatched-filament check still read
+every plate. The plate that prints is now stored on the job.
+"""
+
+import json
+import zipfile
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _two_plate_3mf(path):
+    plate = (
+        "<plate><metadata key='index' value='{n}'/><filament id='{n}' type='{t}' color='#FFFFFF' used_g='5'/></plate>"
+    )
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            "<?xml version='1.0'?><config>" + plate.format(n=1, t="PLA") + plate.format(n=2, t="PETG-CF") + "</config>",
+        )
+        zf.writestr("Metadata/plate_1.gcode", "; plate 1")
+        zf.writestr("Metadata/plate_2.gcode", "; plate 2")
+
+
+async def _library_file(db_session, tmp_path, monkeypatch) -> LibraryFile:
+    from backend.app.core.config import settings as app_settings
+
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    _two_plate_3mf(tmp_path / "two.gcode.3mf")
+    lib_file = LibraryFile(
+        filename="two.gcode.3mf",
+        file_path="two.gcode.3mf",
+        file_type="3mf",
+        file_size=1,
+        file_hash="two-plates",
+        source_type="uploaded",
+        file_metadata={"sliced_for_model": "X1C"},
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    return lib_file
+
+
+async def _requirements(db_session, item) -> list[str]:
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    return [r["type"] for r in await PrintScheduler()._get_filament_requirements(db_session, item)]
+
+
+@pytest.mark.parametrize("model", ["X1C", "x1c"])
+async def test_a_bulk_add_from_the_library(async_client, db_session, printer_factory, tmp_path, monkeypatch, model):
+    """In lower case too, as the scheduler matches a model (Q-U8)."""
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+
+    response = await async_client.post(
+        "/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id], "target_model": model}
+    )
+
+    assert response.status_code == 200, response.text
+    item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.library_file_id == lib_file.id))
+    assert (item.plate_id, item.target_model, json.loads(item.required_filament_types)) == (1, "X1C", ["PLA"])
+    assert await _requirements(db_session, item) == ["PLA"]
+
+
+async def test_post_queue(async_client, db_session, printer_factory, tmp_path, monkeypatch):
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+
+    response = await async_client.post("/api/v1/queue/", json={"library_file_id": lib_file.id, "target_model": "X1C"})
+
+    assert response.status_code == 200, response.text
+    item = await db_session.get(PrintQueueItem, response.json()["id"])
+    assert (item.plate_id, json.loads(item.required_filament_types)) == (1, ["PLA"])
+    assert await _requirements(db_session, item) == ["PLA"]
+
+
+async def test_a_plate_named_by_the_caller_stands(async_client, db_session, printer_factory, tmp_path, monkeypatch):
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+
+    response = await async_client.post(
+        "/api/v1/queue/", json={"library_file_id": lib_file.id, "target_model": "X1C", "plate_id": 2}
+    )
+
+    item = await db_session.get(PrintQueueItem, response.json()["id"])
+    assert (item.plate_id, json.loads(item.required_filament_types)) == (2, ["PETG-CF"])
+
+
+async def test_a_single_plate_file_keeps_no_plate(async_client, db_session, printer_factory, tmp_path, monkeypatch):
+    """Nothing to choose between, so the job stays as it always was."""
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+    with zipfile.ZipFile(tmp_path / "two.gcode.3mf", "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            "<?xml version='1.0'?><config><plate><metadata key='index' value='1'/>"
+            "<filament id='1' type='PLA' color='#FFFFFF' used_g='5'/></plate></config>",
+        )
+        zf.writestr("Metadata/plate_1.gcode", "; plate 1")
+
+    response = await async_client.post("/api/v1/queue/", json={"library_file_id": lib_file.id, "target_model": "X1C"})
+
+    item = await db_session.get(PrintQueueItem, response.json()["id"])
+    assert item.plate_id is None

+ 25 - 0
backend/tests/integration/test_pipeline_runs_api.py

@@ -665,6 +665,31 @@ class TestPipelineC:
         # AMS empty + no live state → both are offline, so ok=False.
         assert body["ok"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_matches_the_model_regardless_of_case(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        """As the queue and the scheduler match a model (Q-U8)."""
+        await printer_factory(model="X1C")
+        pipeline = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "x1c"},
+        )
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        body = resp.json()
+        assert len(body["printer_reports"]) == 1
+        assert not any(i["kind"] == "no_class_matches" for i in body["issues"])
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_class_eligibility_no_matching_printers(

+ 62 - 0
backend/tests/integration/test_queue_target_model_case.py

@@ -0,0 +1,62 @@
+"""A target model is matched without regard to case, as the scheduler does (Q-U8).
+
+The scheduler finds printers for "x1c" among the X1Cs, but adding or editing a
+queue job refused the same name ("No active printers for model: x1c"). The job
+stores the printer's own spelling, so everything comparing it exactly agrees.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.print_queue import PrintQueueItem
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_adding_a_job_for_a_model_in_lower_case(async_client, db_session, printer_factory, archive_factory):
+    printer = await printer_factory(model="X1C")
+    archive = await archive_factory(printer.id)
+
+    response = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "target_model": "x1c"})
+
+    assert response.status_code == 200, response.text
+    item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
+    assert item.target_model == "X1C"
+
+
+async def test_editing_a_job_onto_a_model_in_lower_case(async_client, db_session, printer_factory, archive_factory):
+    printer = await printer_factory(model="X1C")
+    archive = await archive_factory(printer.id)
+    created = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "printer_id": printer.id})
+
+    response = await async_client.patch(
+        f"/api/v1/queue/{created.json()['id']}", json={"printer_id": None, "target_model": "x1c"}
+    )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["target_model"] == "X1C"
+
+
+async def test_a_model_no_active_printer_has_is_still_refused(async_client, printer_factory, archive_factory):
+    printer = await printer_factory(model="X1C")
+    archive = await archive_factory(printer.id)
+
+    response = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "target_model": "p1s"})
+
+    assert response.status_code == 400
+
+
+@pytest.mark.parametrize(("strategy", "expected"), [("fill_one_first", "pinned"), ("max_parallel", "X1C")])
+async def test_a_pipeline_aimed_at_a_model_in_lower_case(db_session, printer_factory, strategy, expected):
+    from backend.app.api.routes.pipeline_runs import _pick_assignments
+    from backend.app.models.slicer_pipeline import SlicerPipeline
+
+    printer = await printer_factory(model="X1C")
+    pipeline = SlicerPipeline(target_kind="printer_class", target_model_class="x1c", fanout_strategy=strategy)
+
+    assignments = await _pick_assignments(db_session, pipeline, 2)
+
+    if expected == "pinned":
+        assert assignments == [(printer.id, None)] * 2
+    else:
+        assert assignments == [(None, "X1C")] * 2

+ 28 - 0
backend/tests/integration/test_queue_variants_api.py

@@ -162,6 +162,34 @@ class TestQueueWithVariants:
         assert r.status_code == 400
         assert "different printers" in r.json()["detail"]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_same_model_in_another_case_is_the_same_printers(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """Matched without regard to case, as the scheduler matches (Q-U8)."""
+        await printer_factory(model="H2S")
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("h2s")
+
+        r = await _queue_variants(async_client, a.id, b.id)
+        assert r.status_code == 400
+        assert "different printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_candidate_takes_the_printers_spelling(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        await printer_factory(model="H2S")
+        await printer_factory(model="X1C")
+        a = await sliced_file_factory("h2s")
+        b = await sliced_file_factory("X1C")
+
+        r = await _queue_variants(async_client, a.id, b.id)
+        assert r.status_code == 200, r.text
+        assert [v.target_model for v in await _variants_of(db_session, r.json()["id"])] == ["H2S", "X1C"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rejects_the_same_file_twice(self, async_client, sliced_file_factory, printer_factory):

+ 42 - 0
backend/tests/integration/test_webhook_reprint_slicer_mapping.py

@@ -0,0 +1,42 @@
+"""A webhook reprint keeps the slicer's AMS slots, as a reprint from the UI does (F9).
+
+An archive printed through the virtual printer remembers which AMS slots the
+slicer picked on its printer. Queued again for that printer from the UI, the
+job reuses them; queued through the webhook it did not, and the scheduler
+matched slots by colour and type again, possibly other spools.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.tests.integration.test_queue_creation_paths_c4 import _webhook_key
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _archive(db_session, archive_factory, printer_id: int):
+    archive = await archive_factory(printer_id)
+    archive.extra_data = {"slicer_ams_mapping": {"printer_id": printer_id, "mapping": [2, 0]}}
+    await db_session.commit()
+    return archive
+
+
+@pytest.mark.parametrize("for_its_printer", [True, False])
+async def test_the_webhook_reuses_the_slots_for_the_printer_they_were_picked_on(
+    async_client, db_session, printer_factory, archive_factory, for_its_printer
+):
+    printer = await printer_factory()
+    other = await printer_factory(name="Other")
+    archive = await _archive(db_session, archive_factory, printer.id)
+
+    response = await async_client.post(
+        "/api/v1/webhook/queue/add",
+        json={"printer_id": printer.id if for_its_printer else other.id, "archive_id": archive.id},
+        headers=await _webhook_key(db_session),
+    )
+
+    assert response.status_code == 200, response.text
+    item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
+    # Slot numbers only mean something on the printer they were picked on
+    assert item.ams_mapping == ("[2, 0]" if for_its_printer else None)