Procházet zdrojové kódy

fix(ams): keep slot card preset name in sync after spool swaps

  The slot card on PrintersPage shows slot_preset_mappings.preset_name
  first in its display fallback chain. Three write paths swap which
  spool occupies a given slot:

  - internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
  - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
  - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)

  Only the first one was reconciling the row. After an RFID-driven
  spool change, the card kept surfacing the previous spool's preset
  name until the user opened Configure Slot manually.

  Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a
  freshly-inserted Bambu PLA-CF spool. The matching row in
  slot_preset_mappings was last written in March when a PLA Silk+
  spool had been in that slot - confirmed live in the database.

  New backend/app/services/slot_preset_writer.py exposes a primitive
  upsert_slot_preset plus two derivation wrappers: one for the
  internal Spool ORM object, one for the Spoolman API dict shape.
  All three call sites now go through the helper, so the row stays
  in lockstep with the assigned spool regardless of inventory mode.

  Bug shape exists in both inventory modes and the patch fixes both
  per feedback_inventory_modes_parity. The Spoolman path was latent
  for users who'd never manually picked a slot preset; the same
  "stale row overrides correct catalog name" symptom appeared for
  those who had.

  Existing stale rows self-heal on the next RFID-driven swap.
maziggy před 2 měsíci
rodič
revize
0793022818

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 17 - 42
backend/app/api/routes/inventory.py

@@ -394,48 +394,23 @@ async def apply_spool_to_slot_via_mqtt(
         )
 
     # Persist slot preset mapping for UI display (preset_name on hover card).
-    try:
-        from backend.app.models.slot_preset import SlotPresetMapping
-
-        preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
-        preset_source = "cloud"
-        if sf:
-            base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
-            try:
-                int(base_sf_mapping)
-                preset_id_to_save = f"local_{base_sf_mapping}"
-                preset_source = "local"
-            except (ValueError, TypeError):
-                preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
-        else:
-            preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
-
-        if preset_id_to_save:
-            existing_mapping = await db.execute(
-                select(SlotPresetMapping).where(
-                    SlotPresetMapping.printer_id == printer_id,
-                    SlotPresetMapping.ams_id == ams_id,
-                    SlotPresetMapping.tray_id == tray_id,
-                )
-            )
-            mapping = existing_mapping.scalar_one_or_none()
-            if mapping:
-                mapping.preset_id = preset_id_to_save
-                mapping.preset_name = preset_name
-                mapping.preset_source = preset_source
-            else:
-                mapping = SlotPresetMapping(
-                    printer_id=printer_id,
-                    ams_id=ams_id,
-                    tray_id=tray_id,
-                    preset_id=preset_id_to_save,
-                    preset_name=preset_name,
-                    preset_source=preset_source,
-                )
-                db.add(mapping)
-            await db.commit()
-    except Exception as e:
-        logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
+    # Shared with the RFID auto-assign path — both must keep this row in sync
+    # with the currently-assigned spool, otherwise the slot card surfaces the
+    # previous spool's preset name (the PrintersPage display chain consults
+    # slot_preset_mappings.preset_name first).
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
+
+    await upsert_slot_preset_for_spool(
+        db=db,
+        spool=spool,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=tray_info_idx,
+        tray_sub_brands=tray_sub_brands,
+        tray_type=tray_type,
+        setting_id=setting_id,
+    )
 
     logger.info(
         "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",

+ 18 - 0
backend/app/main.py

@@ -1695,6 +1695,24 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         printer_id,
                                         result["id"],
                                     )
+                                # Reconcile slot_preset_mappings (the same row internal
+                                # mode keeps in sync via inventory + spool_tag_matcher).
+                                # Without this the slot card surfaces the previous spool's
+                                # preset name — same bug shape, different inventory mode.
+                                from backend.app.services.slot_preset_writer import (
+                                    upsert_slot_preset_for_spoolman_spool,
+                                )
+
+                                await upsert_slot_preset_for_spoolman_spool(
+                                    db=db,
+                                    spoolman_spool=result,
+                                    tray_info_idx=tray.tray_info_idx or "",
+                                    tray_sub_brands=tray.tray_sub_brands or "",
+                                    tray_type=tray.tray_type or "",
+                                    printer_id=printer_id,
+                                    ams_id=ams_id,
+                                    tray_id=tray.tray_id,
+                                )
                     except Exception as e:
                         logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
 

+ 158 - 0
backend/app/services/slot_preset_writer.py

@@ -0,0 +1,158 @@
+"""Shared upsert for the slot_preset_mappings row that drives the AMS slot
+card's displayed preset name.
+
+Three call sites must keep this row in sync with the currently-assigned spool:
+
+- ``api.routes.inventory.apply_spool_to_slot_via_mqtt`` (internal manual assign)
+- ``services.spool_tag_matcher.auto_assign_spool`` (internal RFID auto-assign)
+- ``main.auto_sync_spoolman_ams_trays`` (Spoolman RFID-driven sync)
+
+If any of them skips this row, the slot card surfaces the previous spool's
+preset name because the PrintersPage display chain consults
+slot_preset_mappings.preset_name first — it overrides cloudInfo.name and the
+spool's own slicer_filament_name.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.models.spool import Spool
+from backend.app.utils.filament_ids import filament_id_to_setting_id
+
+logger = logging.getLogger(__name__)
+
+
+async def upsert_slot_preset(
+    *,
+    db: AsyncSession,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    preset_id: str,
+    preset_name: str,
+    preset_source: str = "cloud",
+) -> None:
+    """Primitive upsert. No-op when ``preset_id`` is empty (the column is
+    NOT NULL on the model, and an empty string isn't a useful key to
+    overwrite by). Soft-fails on DB errors so a broken upsert never
+    cascades into the surrounding spool-assign flow.
+    """
+    if not preset_id:
+        return
+    try:
+        existing = await db.execute(
+            select(SlotPresetMapping).where(
+                SlotPresetMapping.printer_id == printer_id,
+                SlotPresetMapping.ams_id == ams_id,
+                SlotPresetMapping.tray_id == tray_id,
+            )
+        )
+        mapping = existing.scalar_one_or_none()
+        if mapping:
+            mapping.preset_id = preset_id
+            mapping.preset_name = preset_name
+            mapping.preset_source = preset_source
+        else:
+            mapping = SlotPresetMapping(
+                printer_id=printer_id,
+                ams_id=ams_id,
+                tray_id=tray_id,
+                preset_id=preset_id,
+                preset_name=preset_name,
+                preset_source=preset_source,
+            )
+            db.add(mapping)
+        await db.commit()
+    except Exception as e:
+        logger.warning(
+            "Failed to save slot preset mapping for printer=%d ams=%d tray=%d: %s",
+            printer_id,
+            ams_id,
+            tray_id,
+            e,
+        )
+
+
+async def upsert_slot_preset_for_spool(
+    *,
+    db: AsyncSession,
+    spool: Spool,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    tray_info_idx: str = "",
+    tray_sub_brands: str = "",
+    tray_type: str = "",
+    setting_id: str = "",
+) -> None:
+    """Convenience wrapper for internal-mode call sites — derives the
+    (preset_id, preset_name, preset_source) triple from a ``Spool`` ORM object,
+    then defers to ``upsert_slot_preset``.
+
+    Local numeric ``spool.slicer_filament`` (e.g. ``"50"``) → ``local_50``;
+    cloud-form ids (GFS… / GFA… via ``filament_id_to_setting_id`` on the
+    tray's ``tray_info_idx``) → standard setting_id form.
+    """
+    preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
+    preset_source = "cloud"
+    sf = spool.slicer_filament or ""
+    if sf:
+        base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
+        try:
+            int(base_sf_mapping)
+            preset_id = f"local_{base_sf_mapping}"
+            preset_source = "local"
+        except (ValueError, TypeError):
+            preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
+    else:
+        preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+    await upsert_slot_preset(
+        db=db,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        preset_id=preset_id,
+        preset_name=preset_name,
+        preset_source=preset_source,
+    )
+
+
+async def upsert_slot_preset_for_spoolman_spool(
+    *,
+    db: AsyncSession,
+    spoolman_spool: dict,
+    tray_info_idx: str,
+    tray_sub_brands: str,
+    tray_type: str,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+) -> None:
+    """Spoolman-mode counterpart. Derives ``preset_name`` from the Spoolman
+    filament's display name (falling back to material if absent) and
+    ``preset_id`` from the AMS-reported tray_info_idx (the cloud filament
+    id the printer is currently using). ``preset_source`` is always
+    ``"cloud"`` since Spoolman doesn't carry a local-preset concept.
+
+    The ``spoolman_spool`` dict is the shape returned by
+    ``SpoolmanClient.sync_ams_tray`` — ``spool["filament"]["name"]`` etc.
+    """
+    filament = spoolman_spool.get("filament") or {}
+    preset_name = filament.get("name") or filament.get("material") or tray_sub_brands or tray_type
+    preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+    await upsert_slot_preset(
+        db=db,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        preset_id=preset_id,
+        preset_name=preset_name or "",
+        preset_source="cloud",
+    )

+ 17 - 0
backend/app/services/spool_tag_matcher.py

@@ -578,4 +578,21 @@ async def auto_assign_spool(
     except Exception as e:
         logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
 
+    # Reconcile slot_preset_mappings so the AMS slot card stops surfacing the
+    # previous spool's preset name. Shared with the manual-assign path
+    # (inventory.apply_spool_to_slot_via_mqtt). Outside the try above so a
+    # transient MQTT failure doesn't leave the display row stale.
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
+
+    await upsert_slot_preset_for_spool(
+        db=db,
+        spool=spool,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=tray_info_idx,
+        tray_sub_brands=tray.get("tray_sub_brands", "") if tray else "",
+        tray_type=tray.get("tray_type", "") if tray else "",
+    )
+
     return assignment

+ 201 - 0
backend/tests/unit/services/test_slot_preset_writer.py

@@ -0,0 +1,201 @@
+"""Tests for the shared slot_preset_mappings writer.
+
+The writer keeps PrintersPage's slot-card preset_name in sync with the
+currently-assigned spool from three different inventory paths:
+
+* internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
+* internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
+* Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)
+
+Internal-mode regression coverage lives in test_spool_tag_matcher.py
+(which exercises the call site end-to-end). This file focuses on the
+helper-level contracts — local-preset id formatting, soft-fail on
+no preset_id, and the Spoolman derivation path.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.services.slot_preset_writer import (
+    upsert_slot_preset,
+    upsert_slot_preset_for_spoolman_spool,
+)
+
+
+@pytest.mark.asyncio
+async def test_upsert_no_op_when_preset_id_empty(db_session, printer_factory):
+    """An empty preset_id is not a useful key — the model's column is NOT
+    NULL and an empty string would overwrite the user's last good preset
+    with garbage. Skip without raising."""
+    printer = await printer_factory()
+    await upsert_slot_preset(
+        db=db_session,
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+        preset_id="",
+        preset_name="ignored",
+    )
+    result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
+    assert result.scalar_one_or_none() is None
+
+
+@pytest.mark.asyncio
+async def test_upsert_inserts_then_updates(db_session, printer_factory):
+    """First call inserts, second call on same (printer, ams, tray) updates
+    in place rather than violating the unique constraint."""
+    printer = await printer_factory()
+    await upsert_slot_preset(
+        db=db_session,
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=1,
+        preset_id="GFSA50",
+        preset_name="Bambu PLA-CF",
+        preset_source="cloud",
+    )
+    await upsert_slot_preset(
+        db=db_session,
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=1,
+        preset_id="GFSA00",
+        preset_name="Bambu PLA Basic",
+        preset_source="cloud",
+    )
+
+    rows = (
+        (await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id)))
+        .scalars()
+        .all()
+    )
+    assert len(rows) == 1
+    assert rows[0].preset_id == "GFSA00"
+    assert rows[0].preset_name == "Bambu PLA Basic"
+
+
+# -- Spoolman derivation ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_uses_filament_name_and_tray_info_idx(db_session, printer_factory):
+    """A Spoolman spool with a typical filament shape — name + material —
+    should land in the row with the AMS-reported tray_info_idx as
+    preset_id (normalised to setting_id form) and the filament name as
+    preset_name."""
+    printer = await printer_factory()
+    spoolman_spool = {
+        "id": 42,
+        "filament": {
+            "id": 7,
+            "name": "Bambu PLA-CF Burgundy Red",
+            "material": "PLA-CF",
+        },
+    }
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool=spoolman_spool,
+        tray_info_idx="GFA50",
+        tray_sub_brands="PLA-CF",
+        tray_type="PLA",
+        printer_id=printer.id,
+        ams_id=1,
+        tray_id=2,
+    )
+    mapping = (
+        await db_session.execute(
+            select(SlotPresetMapping).where(
+                SlotPresetMapping.printer_id == printer.id,
+                SlotPresetMapping.ams_id == 1,
+                SlotPresetMapping.tray_id == 2,
+            )
+        )
+    ).scalar_one()
+    assert mapping.preset_id == "GFSA50"
+    assert mapping.preset_name == "Bambu PLA-CF Burgundy Red"
+    assert mapping.preset_source == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_falls_back_to_material_when_name_missing(db_session, printer_factory):
+    """Some Spoolman setups have unnamed filaments — fall back through
+    material → tray_sub_brands → tray_type so we never write an empty
+    preset_name."""
+    printer = await printer_factory()
+    spoolman_spool = {"id": 99, "filament": {"material": "PETG"}}
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool=spoolman_spool,
+        tray_info_idx="GFG00",
+        tray_sub_brands="PETG Basic",
+        tray_type="PETG",
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+    )
+    mapping = (
+        await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
+    ).scalar_one()
+    assert mapping.preset_name == "PETG"
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_overwrites_stale_internal_row(db_session, printer_factory):
+    """Mirror of the internal-mode regression: pre-seed a stale row with a
+    previous spool's name, run the Spoolman helper, verify the row now
+    reflects the freshly-synced Spoolman spool. This is the bug shape
+    that would surface on a Spoolman user with a manually-set preset."""
+    printer = await printer_factory()
+    db_session.add(
+        SlotPresetMapping(
+            printer_id=printer.id,
+            ams_id=1,
+            tray_id=2,
+            preset_id="GFSA06_09",
+            preset_name="Bambu PLA Silk+",
+            preset_source="cloud",
+        )
+    )
+    await db_session.commit()
+
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool={"id": 42, "filament": {"name": "Bambu PLA-CF"}},
+        tray_info_idx="GFA50",
+        tray_sub_brands="PLA-CF",
+        tray_type="PLA",
+        printer_id=printer.id,
+        ams_id=1,
+        tray_id=2,
+    )
+    mapping = (
+        await db_session.execute(
+            select(SlotPresetMapping).where(
+                SlotPresetMapping.printer_id == printer.id,
+                SlotPresetMapping.ams_id == 1,
+                SlotPresetMapping.tray_id == 2,
+            )
+        )
+    ).scalar_one()
+    assert mapping.preset_name == "Bambu PLA-CF"
+    assert mapping.preset_id == "GFSA50"
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_skips_when_tray_info_idx_unknown(db_session, printer_factory):
+    """No tray_info_idx → no preset_id → upsert skips. Caller must not
+    write an empty-string preset_id (would clobber any existing row)."""
+    printer = await printer_factory()
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool={"id": 1, "filament": {"name": "Random"}},
+        tray_info_idx="",
+        tray_sub_brands="",
+        tray_type="PLA",
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+    )
+    result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
+    assert result.scalar_one_or_none() is None

+ 182 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -1365,3 +1365,185 @@ async def test_auto_assign_kprofile_takes_priority_over_live_cali_idx(db_session
     mqtt_mock.extrusion_cali_sel.assert_called_once()
     call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
     assert call_kwargs["cali_idx"] == 7  # stored profile, not 99
+
+
+# -- slot_preset_mappings reconciliation on RFID auto-assign ----------------
+#
+# The slot card on PrintersPage shows slot_preset_mappings.preset_name first
+# in its fallback chain (it's the user-configured override for a slot). When a
+# new spool gets auto-assigned via RFID the manual-assign path used to be the
+# only one that kept this row in sync, so the slot card kept showing the
+# previous spool's preset name until the user opened Configure Slot manually.
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_overwrites_stale_slot_preset_mapping(db_session, printer_factory):
+    """Pre-seed a slot_preset_mappings row from a previous spool, run RFID
+    auto-assign with a different filament, and verify the row reflects the
+    NEW spool's preset (not the stale one). The bug being pinned: the user's
+    AMS-B3 (PLA-CF) kept showing 'Bambu PLA Silk+' because the row was last
+    written when the PLA Silk+ spool was loaded back in March.
+    """
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select as sa_select
+
+    from backend.app.models.slot_preset import SlotPresetMapping
+
+    printer = await printer_factory()
+    db_session.add(
+        SlotPresetMapping(
+            printer_id=printer.id,
+            ams_id=1,
+            tray_id=2,
+            preset_id="GFSA06_09",
+            preset_name="Bambu PLA Silk+",
+            preset_source="cloud",
+        )
+    )
+    await db_session.commit()
+
+    spool = Spool(
+        material="PLA-CF",
+        subtype="CF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="GFA50",
+        slicer_filament_name="Bambu PLA-CF",
+        rgba="951E23FF",
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=1,
+        tray_id=2,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+        tray_info_idx="GFA50",
+    )
+    await db_session.commit()
+
+    result = await db_session.execute(
+        sa_select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer.id,
+            SlotPresetMapping.ams_id == 1,
+            SlotPresetMapping.tray_id == 2,
+        )
+    )
+    mapping = result.scalar_one()
+    assert mapping.preset_name == "Bambu PLA-CF"
+    assert mapping.preset_id == "GFSA50"
+    assert mapping.preset_source == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_inserts_slot_preset_when_absent(db_session, printer_factory):
+    """No pre-existing row → auto-assign inserts one. Pairs with the upsert
+    case to keep both branches of the helper covered from this path."""
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select as sa_select
+
+    from backend.app.models.slot_preset import SlotPresetMapping
+
+    printer = await printer_factory()
+    spool = Spool(
+        material="PLA-CF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="GFA50",
+        slicer_filament_name="Bambu PLA-CF",
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=3,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+        tray_info_idx="GFA50",
+    )
+    await db_session.commit()
+
+    result = await db_session.execute(
+        sa_select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer.id,
+            SlotPresetMapping.ams_id == 0,
+            SlotPresetMapping.tray_id == 3,
+        )
+    )
+    mapping = result.scalar_one()
+    assert mapping.preset_id == "GFSA50"
+    assert mapping.preset_name == "Bambu PLA-CF"
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_local_preset_uses_local_prefix(db_session, printer_factory):
+    """Spools whose slicer_filament is a numeric local-preset id get saved
+    with a `local_{n}` preset_id (matches the manual-assign path's shape).
+    """
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select as sa_select
+
+    from backend.app.models.slot_preset import SlotPresetMapping
+
+    printer = await printer_factory()
+    spool = Spool(
+        material="PLA",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="50",  # numeric → local preset
+        slicer_filament_name="My Custom PLA",
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+    )
+    await db_session.commit()
+
+    result = await db_session.execute(
+        sa_select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer.id,
+            SlotPresetMapping.ams_id == 0,
+            SlotPresetMapping.tray_id == 0,
+        )
+    )
+    mapping = result.scalar_one()
+    assert mapping.preset_id == "local_50"
+    assert mapping.preset_source == "local"
+    assert mapping.preset_name == "My Custom PLA"

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů