Selaa lähdekoodia

fix(spoolman): persist Color Name via spool.extra — Spoolman has no filament.color_name field (#1357)

  Reporter pgladel edited a spool's Color Name in Spoolman mode, hit
  Save, and saw the value snap back to the subtype on the next read.
  The earlier #1319 fix correctly handled the read/form-prefill half
  (the color_name_is_synthesized flag, blank-on-synth form init), but
  the write half assumed Spoolman has a `color_name` field on Filament.

  It doesn't. Verified against the live FilamentUpdateParameters schema
  on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material,
  price, density, diameter, weight, spool_weight, article_number,
  comment, settings_extruder_temp, settings_bed_temp, color_hex,
  multi_color_hexes, multi_color_direction, external_id, extra. No
  color_name. Spoolman's PATCH happily returns 200 for
  {"color_name": "Red"} and silently discards the unknown key, so
  find_or_create_filament was either patching a void or creating
  filament after filament with the same field-that-doesn't-stick (which
  is what produced the "BB also created a bunch of new filaments"
  duplicate trail on each save attempt).

  The fix follows the same pattern as the existing BambuStudio slicer-
  preset storage: persist color_name on spool.extra.bambu_color_name as
  a JSON-encoded string, register the extra field via
  ensure_extra_field before write (Spoolman 400s on unknown extra keys),
  and read it back in _map_spoolman_spool with priority
  extra > filament.color_name (forward-compat for any future Spoolman
  release that adds the field) > subtype synth.

  Dropped the now-dead color_name passing through
  find_or_create_filament and create_filament — Spoolman would discard
  it anyway and keeping the dead pipe risked the same confusion the
  next time someone reads this code. The previous "match by name then
  patch color_name" loop is gone; what survives is the name-match
  resilience that lets an AMS-sync-created filament named "Glow" still
  match the user-driven edit's composed "PLA Glow", which prevents
  re-introducing the duplicate-filament trail.

  The frontend form's color_name_is_synthesized handling is unchanged
  — that part already worked.
maziggy 1 viikko sitten
vanhempi
sitoutus
4a98914d4a

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
CHANGELOG.md


+ 22 - 12
backend/app/api/routes/_spoolman_helpers.py

@@ -257,18 +257,28 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
 
     created_at: str | None = spool.get("registered") or None
 
-    # Spoolman doesn't standardise a `color_name` field — most installs only
-    # populate `color_hex` (the swatch) and the filament's `name` (which often
-    # carries the colour, e.g. "PLA Basic Red"). Without a fallback the
-    # frontend lists a sea of "Unknown color" entries that all look identical
-    # except for the swatch. Fall back to the filament name minus material
-    # prefix (the same string the `subtype` field already carries — typically
-    # "Basic Red" / "PLA+ Black" / etc.) so the user can tell spools apart at
-    # a glance even on Spoolman installs that don't fill color_name.
-    # color_name_is_synthesized: surfaced so the edit form can avoid prefilling
-    # the synth value back into the input, which would otherwise round-trip the
-    # subtype string as if it were a user-set color_name (#1319).
-    stored_color_name = filament.get("color_name") or None
+    # Spoolman has no `color_name` field on Filament — confirmed against the
+    # FilamentUpdateParameters schema in 0.23.1: name/vendor_id/material/price/
+    # density/diameter/weight/spool_weight/article_number/comment/extruder_temp/
+    # bed_temp/color_hex/multi_color_hexes/multi_color_direction/external_id/
+    # extra, no color_name (#1357). The previous attempt (b8e350c3) was
+    # PATCHing a key Spoolman silently discards, which is why color_name
+    # never actually persisted from the user's edits.
+    #
+    # We persist it ourselves under spool.extra.bambu_color_name (JSON-encoded
+    # string, same pattern as bambu_slicer_filament). Read order:
+    #   1. spool.extra.bambu_color_name (the canonical store)
+    #   2. filament.color_name (forward-compat — picks up the value if a
+    #      future Spoolman release adds the field, or if an admin populated
+    #      it via a custom extra-field they registered themselves)
+    #   3. subtype (synth fallback so the inventory list isn't a sea of
+    #      "Unknown color" entries on installs with neither field set)
+    #
+    # color_name_is_synthesized = True only when we fell back to subtype.
+    # The edit form uses it to leave the input blank, so the user doesn't
+    # round-trip the synth value back as if they had set it.
+    extra_color_name = _extract_extra_str(extra, "bambu_color_name") or None
+    stored_color_name = extra_color_name or (filament.get("color_name") or None)
     color_name: str | None = stored_color_name or subtype or None
     color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
 

+ 21 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -440,18 +440,24 @@ async def create_spool(
 
     spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
 
-    # Persist slicer_filament under the spool's extra dict (mirror update_spool).
-    if data.slicer_filament is not None or data.slicer_filament_name is not None:
+    # Persist slicer_filament AND color_name under the spool's extra dict
+    # (mirror update_spool). Spoolman has no `color_name` field on filament
+    # (#1357) so we own the round-trip ourselves.
+    if data.slicer_filament is not None or data.slicer_filament_name is not None or data.color_name is not None:
         # Ensure extra fields are registered before write.
         if data.slicer_filament is not None:
             await client.ensure_extra_field("bambu_slicer_filament")
         if data.slicer_filament_name is not None:
             await client.ensure_extra_field("bambu_slicer_filament_name")
+        if data.color_name is not None:
+            await client.ensure_extra_field("bambu_color_name")
         new_extra: dict = {}
         if data.slicer_filament is not None:
             new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
         if data.slicer_filament_name is not None:
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
+        if data.color_name is not None:
+            new_extra["bambu_color_name"] = json.dumps(data.color_name)
         if new_extra:
             try:
                 async with _translate_spoolman_errors():
@@ -459,7 +465,7 @@ async def create_spool(
             except HTTPException:
                 # Best-effort — the spool already exists, log and continue.
                 logger.warning(
-                    "Failed to persist slicer_filament for spool %s",
+                    "Failed to persist slicer_filament/color_name for spool %s",
                     spool.get("id"),
                 )
 
@@ -633,25 +639,33 @@ async def update_spool(
                 clear_location=storage_location_changed and not storage_location,
             )
 
-    # Persist BambuStudio slicer preset under the spool's extra dict.
-    # Spoolman doesn't have a native field for this, so we round-trip via
-    # extra and unpack in _map_spoolman_spool. Only writes when the request
+    # Persist BambuStudio slicer preset AND color_name under spool.extra.
+    # Spoolman has no native fields for these — color_name was confirmed
+    # absent from the FilamentUpdateParameters schema in 0.23.1 (#1357), so
+    # writing `filament.color_name` was a silent no-op that left every
+    # edit looking "not saved". They all round-trip via extra and get
+    # unpacked in _map_spoolman_spool. Only writes when the request
     # explicitly set the field — passing null/omitting leaves the existing
     # extra entry untouched (write empty string to clear).
     sf_set = "slicer_filament" in data.model_fields_set
     sfn_set = "slicer_filament_name" in data.model_fields_set
-    if sf_set or sfn_set:
+    cn_set = "color_name" in data.model_fields_set
+    if sf_set or sfn_set or cn_set:
         # Ensure extra fields are registered (Spoolman rejects PATCHes with
         # unknown keys with HTTP 400). Idempotent if startup already ran this.
         if sf_set:
             await client.ensure_extra_field("bambu_slicer_filament")
         if sfn_set:
             await client.ensure_extra_field("bambu_slicer_filament_name")
+        if cn_set:
+            await client.ensure_extra_field("bambu_color_name")
         new_extra: dict = {}
         if sf_set:
             new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
         if sfn_set:
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
+        if cn_set:
+            new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
 

+ 54 - 25
backend/app/services/spoolman.py

@@ -75,6 +75,25 @@ class SpoolmanClientError(Exception):
         self.response_text = response_text
 
 
+def _filament_subtype_part(name: str, material: str) -> str:
+    """Return the subtype portion of a filament name, lowercased.
+
+    Mirrors the read-side derivation in
+    ``backend/app/api/routes/_spoolman_helpers.py::_map_spoolman_spool``:
+    if the filament name starts with the material prefix (e.g. ``"PLA Glow"``
+    when material is ``"PLA"``), strip it; otherwise return the name as-is.
+
+    Used by ``find_or_create_filament`` so that an existing filament saved by
+    the AMS-sync path with name ``"Glow"`` still matches a user-driven edit
+    that composes ``"PLA Glow"`` (#1357).
+    """
+    s = (name or "").strip()
+    m = (material or "").strip()
+    if m and s.upper().startswith(m.upper() + " "):
+        return s[len(m) + 1 :].strip().lower()
+    return s.lower()
+
+
 class SpoolmanClient:
     """Client for interacting with Spoolman API."""
 
@@ -623,48 +642,58 @@ class SpoolmanClient:
         if brand:
             vendor_id = await self.find_or_create_vendor(brand)
 
+        # Normalised match keys (case-insensitive). Computed once outside the
+        # loop so the inner comparison stays simple.
+        composed_subtype = _filament_subtype_part(name, material)
+        material_norm = material.upper()
+        brand_norm = (brand or "").strip().lower()
+
         filaments = await self.get_filaments()
         for f in filaments:
             f_material = (f.get("material") or "").upper()
-            f_name = (f.get("name") or "").strip()
             f_color = (f.get("color_hex") or "").upper()[:6]
             f_vendor = f.get("vendor") or {}
             f_vendor_name = (f_vendor.get("name") or "").strip().lower()
 
-            material_match = f_material == material.upper()
-            name_match = f_name.lower() == name.lower()
+            material_match = f_material == material_norm
+            # Match on the subtype portion of the filament name. AMS-sync
+            # auto-create (the underscore-prefixed `_find_or_create_filament`
+            # used during MQTT tray import) stores the filament as just
+            # ``tray.tray_sub_brands`` — e.g. ``"Glow"`` — while the
+            # user-driven edit path here composes ``"<material> <subtype>"``
+            # — ``"PLA Glow"``. The old literal equality `f_name == name`
+            # failed to bridge the two shapes, so every edit fell through to
+            # `create_filament`, leaving a trail of duplicate filaments AND
+            # leaving the spool either still pointed at the old filament
+            # whose `color_name` never got patched, or pointed at a new
+            # filament with the colour while the inventory list kept
+            # showing the synth fallback from the old one (#1357).
+            f_subtype_part = _filament_subtype_part(f.get("name") or "", material)
+            name_match = f_subtype_part == composed_subtype
             color_match = f_color == color
-            vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
+            vendor_match = (not brand) or f_vendor_name == brand_norm
 
             if material_match and name_match and color_match and vendor_match:
-                # #1319: color_name is not part of the match key, but if the
-                # caller passed a value that differs from what's stored, update
-                # the filament — otherwise the user's edit is silently dropped
-                # and the inventory read falls back to subtype, making it look
-                # like color_name "reverts" to the subtype on every save.
-                # Convention: None = "don't touch"; "" = explicit clear; any
-                # other string = set/update.
-                if color_name is not None:
-                    existing = (f.get("color_name") or "").strip()
-                    requested = color_name.strip()
-                    if requested != existing:
-                        payload_value: str | None = requested if requested else None
-                        try:
-                            await self.patch_filament(f["id"], {"color_name": payload_value})
-                        except Exception as e:
-                            logger.warning(
-                                "Failed to update color_name on filament %s: %s",
-                                f["id"],
-                                e,
-                            )
+                # color_name is intentionally not part of the match key and
+                # is no longer patched onto the filament here: Spoolman 0.23.1
+                # has no `color_name` field on Filament (#1357 — confirmed
+                # against the FilamentUpdateParameters schema). The earlier
+                # #1319 fix tried to patch it and Spoolman silently dropped
+                # the key, which is exactly why the user's edit looked "not
+                # saved". The route now persists color_name via
+                # spool.extra.bambu_color_name (see _map_spoolman_spool for
+                # the read side); find_or_create_filament's only job is to
+                # resolve the right filament_id for the spool link.
                 return f["id"]
 
+        # color_name omitted: Spoolman has no such field on Filament (#1357);
+        # the user's color_name lands in spool.extra.bambu_color_name via the
+        # route after find_or_create_filament returns the new id.
         filament = await self.create_filament(
             name=name,
             vendor_id=vendor_id,
             material=material,
             color_hex=color,
-            color_name=color_name,
             weight=float(label_weight),
         )
         filament_id = filament.get("id")

+ 63 - 30
backend/tests/integration/test_spoolman_inventory_api.py

@@ -66,6 +66,7 @@ def mock_spoolman_client():
     mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
     mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
     mock_client.find_or_create_filament = AsyncMock(return_value=7)
+    mock_client.ensure_extra_field = AsyncMock(return_value=True)
 
     with (
         patch(
@@ -323,39 +324,50 @@ class TestSpoolmanInventoryCRUD:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_with_explicit_null_color_name_clears(
+    async def test_update_with_explicit_null_color_name_clears_extra(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """#1319 follow-up: explicit color_name=null in the PATCH body means
-        "clear" — route translates it to "" so find_or_create_filament patches
-        the matched filament with color_name=None."""
+        """#1357: explicit color_name=null means "clear". The route writes a
+        JSON-encoded empty string to spool.extra.bambu_color_name so the read
+        path falls back to the synth value next time."""
         payload = {"color_name": None}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
-        assert kwargs["color_name"] == ""
+        mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
+        mock_spoolman_client.merge_spool_extra.assert_called_once()
+        _, kwargs = mock_spoolman_client.merge_spool_extra.call_args
+        # First positional arg is spool_id; second is the extra-dict patch.
+        args = mock_spoolman_client.merge_spool_extra.call_args.args
+        extra_patch = args[1] if len(args) > 1 else kwargs.get("new_fields", {})
+        import json as _json
+
+        assert _json.loads(extra_patch["bambu_color_name"]) == ""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_without_color_name_keeps_current(
+    async def test_update_without_color_name_skips_extra_write(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """#1319 follow-up: when color_name is omitted from the PATCH body the
-        current value is kept — None passed to find_or_create_filament means
-        "don't touch"."""
+        """#1357: when color_name is omitted from the PATCH body the extra
+        write is skipped entirely — no merge_spool_extra call, no ensure_extra
+        call for bambu_color_name. Only fields the request explicitly set go
+        through the extra round-trip."""
         payload = {"note": "only updating note"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
-        # SAMPLE_SPOOLMAN_SPOOL has no color_name field, so cur fallback is None.
-        assert kwargs["color_name"] is None
+        # No call should target bambu_color_name when color_name wasn't in the body.
+        color_name_calls = [
+            c
+            for c in mock_spoolman_client.ensure_extra_field.call_args_list
+            if c.args and c.args[0] == "bambu_color_name"
+        ]
+        assert color_name_calls == []
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -1117,17 +1129,26 @@ class TestStorageLocationPassthrough:
 
 
 class TestColorNamePassthrough:
-    """color_name is forwarded to find_or_create_filament on create and update (B6 / T5)."""
+    """color_name persistence via spool.extra.bambu_color_name (#1357).
+
+    Spoolman 0.23.1 has no `color_name` field on Filament, so Bambuddy owns
+    the round-trip via the spool's extra dict — same shape as the existing
+    bambu_slicer_filament storage. These tests pin that the create/update
+    routes register the extra field and write to merge_spool_extra, NOT to
+    find_or_create_filament's color_name parameter.
+    """
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_passes_color_name_to_filament(
+    async def test_create_writes_color_name_to_spool_extra(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """color_name from the create payload is forwarded to find_or_create_filament."""
+        """color_name from create payload lands in spool.extra.bambu_color_name."""
+        import json as _json
+
         payload = {
             "material": "PLA",
             "label_weight": 1000,
@@ -1136,41 +1157,53 @@ class TestColorNamePassthrough:
         }
         response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
         assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        assert kwargs.get("color_name") == "Bambu Green"
+        mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
+        mock_spoolman_client.merge_spool_extra.assert_called_once()
+        args = mock_spoolman_client.merge_spool_extra.call_args.args
+        extra_patch = args[1]
+        assert _json.loads(extra_patch["bambu_color_name"]) == "Bambu Green"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_passes_color_name_to_filament(
+    async def test_update_writes_color_name_to_spool_extra(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """color_name from the update payload is forwarded to find_or_create_filament."""
+        """color_name from update payload lands in spool.extra.bambu_color_name —
+        this is the #1357 reproduction: previously the value went to
+        filament.color_name which Spoolman silently dropped."""
+        import json as _json
+
         payload = {"color_name": "Jade White"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        assert kwargs.get("color_name") == "Jade White"
+        mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
+        mock_spoolman_client.merge_spool_extra.assert_called_once()
+        args = mock_spoolman_client.merge_spool_extra.call_args.args
+        extra_patch = args[1]
+        assert _json.loads(extra_patch["bambu_color_name"]) == "Jade White"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_omits_color_name_when_not_provided(
+    async def test_update_omits_color_name_skips_extra_write(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """When color_name is not in the PATCH payload, the existing filament color_name is used."""
+        """When color_name is absent from the PATCH body, the route must not
+        write to spool.extra at all (preserves any existing value)."""
         payload = {"note": "no color_name here"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        # color_name falls back to current filament's color_name (which is None in test fixture)
-        assert kwargs.get("color_name") is None
+        color_name_calls = [
+            c
+            for c in mock_spoolman_client.ensure_extra_field.call_args_list
+            if c.args and c.args[0] == "bambu_color_name"
+        ]
+        assert color_name_calls == []
 
 
 class TestSpoolmanInventoryAuth:

+ 53 - 0
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -218,6 +218,59 @@ class TestMapSpoolmanSpool:
         # color_name falls back to subtype.
         assert result["color_name"] == "Basic Red"
 
+    def test_color_name_read_from_spool_extra_first(self):
+        """#1357: the canonical store for color_name is
+        spool.extra.bambu_color_name (JSON-encoded). Read priority is
+        extra > filament.color_name > subtype-synth. The user's
+        Bambuddy-saved value MUST win even when Spoolman's own
+        filament.color_name happens to be populated from some other source.
+        """
+        spool = {
+            **MINIMAL_SPOOL,
+            "extra": {"bambu_color_name": '"Galaxy Black"'},
+            "filament": {
+                **MINIMAL_SPOOL["filament"],
+                "name": "PLA Glow",
+                "color_name": "Glow",  # would be picked up if extra weren't preferred
+            },
+        }
+        result = _map_spoolman_spool(spool)
+        assert result["color_name"] == "Galaxy Black"
+        assert result["color_name_is_synthesized"] is False
+
+    def test_color_name_empty_extra_falls_through_to_filament(self):
+        """An explicit empty string in spool.extra.bambu_color_name (the
+        "user cleared the field" shape) must NOT mask Spoolman's own
+        filament.color_name if one exists — it falls through to the next
+        layer instead of suppressing it."""
+        spool = {
+            **MINIMAL_SPOOL,
+            "extra": {"bambu_color_name": '""'},
+            "filament": {
+                **MINIMAL_SPOOL["filament"],
+                "color_name": "Sunset",
+            },
+        }
+        result = _map_spoolman_spool(spool)
+        assert result["color_name"] == "Sunset"
+        assert result["color_name_is_synthesized"] is False
+
+    def test_color_name_empty_extra_falls_through_to_synth(self):
+        """When extra is cleared and filament has no color_name either,
+        fall all the way through to the subtype synth — same UX as a fresh
+        Spoolman install."""
+        spool = {
+            **MINIMAL_SPOOL,
+            "extra": {"bambu_color_name": '""'},
+            "filament": {
+                **MINIMAL_SPOOL["filament"],
+                "name": "PLA Basic Red",
+            },
+        }
+        result = _map_spoolman_spool(spool)
+        assert result["color_name"] == "Basic Red"
+        assert result["color_name_is_synthesized"] is True
+
     def test_color_name_none_when_both_fields_empty(self):
         """If neither color_name nor a usable subtype exists, return None — UI
         falls back to its own 'Unknown color' string rather than showing a

+ 74 - 42
backend/tests/unit/test_spoolman_inventory_methods.py

@@ -323,26 +323,15 @@ class TestFindOrCreateFilament:
         assert result == 7
 
     @pytest.mark.asyncio
-    async def test_patches_color_name_on_existing_filament_when_changed(self, client):
-        """#1319: color_name is not part of the match key, so when a caller
-        updates a spool with a new color_name and the material/name/color/vendor
-        still match an existing filament, the existing filament's color_name
-        must be patched — otherwise the user's edit is silently dropped."""
-        existing = {**SAMPLE_FILAMENT, "color_name": None}
-        with (
-            patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
-            patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
-            patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
-        ):
-            result = await client.find_or_create_filament(
-                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
-            )
-        assert result == 7
-        mock_patch.assert_called_once_with(7, {"color_name": "Sunny Yellow"})
-
-    @pytest.mark.asyncio
-    async def test_does_not_patch_when_color_name_unchanged(self, client):
-        existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
+    async def test_color_name_does_not_trigger_filament_patch(self, client):
+        """#1357: Spoolman 0.23.1 has no `color_name` field on Filament
+        (verified against FilamentUpdateParameters schema). find_or_create_filament
+        must NOT attempt to PATCH it — the route now persists the user's
+        color_name to spool.extra.bambu_color_name instead. Any patch call
+        from this layer would be a silent no-op (Spoolman ignores unknown
+        keys) and was the original symptom of "edits never save".
+        """
+        existing = {**SAMPLE_FILAMENT}
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
@@ -355,47 +344,88 @@ class TestFindOrCreateFilament:
         mock_patch.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_does_not_patch_when_color_name_empty(self, client):
-        """An empty/None color_name should not clobber an existing value."""
-        existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
+    async def test_matches_filament_named_with_just_subtype(self, client):
+        """#1357: AMS-sync auto-create saves the filament with name set to just
+        ``tray.tray_sub_brands`` (e.g. ``"Glow"`` without the material prefix),
+        but the user-driven edit path composes ``"<material> <subtype>"``
+        (``"PLA Glow"``). Before this fix the literal `f_name == name` check
+        failed to bridge the two shapes, so every edit fell through to
+        ``create_filament`` and left a trail of duplicate filaments. Now the
+        name match strips the material prefix on both sides, so the two
+        shapes resolve to the same subtype key."""
+        existing = {
+            **SAMPLE_FILAMENT,
+            "id": 11,
+            "name": "Glow",  # AMS-sync shape: just subtype
+            "material": "PLA",
+            "color_hex": "AAF3C6",
+            "color_name": None,
+            "vendor": {"id": 3, "name": "Amazon Basics"},
+        }
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
             patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
+            patch.object(client, "create_filament", AsyncMock()) as mock_create,
         ):
-            result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name=None)
-        assert result == 7
+            result = await client.find_or_create_filament(
+                "PLA", "Glow", "Amazon Basics", "AAF3C6", 1000, color_name="Bright Glow"
+            )
+        assert result == 11
+        # color_name is no longer written via the filament — see #1357 — and
+        # the function must not create a duplicate filament.
         mock_patch.assert_not_called()
+        mock_create.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_clears_color_name_when_empty_string_passed(self, client):
-        """#1319 follow-up: empty string means "explicit clear" — the route
-        layer translates a wire-level null into "" so the user can blank the
-        field on a previously-set spool."""
-        existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
+    async def test_still_matches_filament_named_material_plus_subtype(self, client):
+        """The composed-name shape (``"PLA Basic"`` matching a Spoolman filament
+        also named ``"PLA Basic"``) must keep working — the normalisation strips
+        the prefix on both sides, so the comparison is on the subtype part."""
+        existing = {
+            **SAMPLE_FILAMENT,
+            "id": 7,
+            "name": "PLA Basic",
+            "material": "PLA",
+            "color_hex": "FF0000",
+            "color_name": "Sunset",
+        }
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
-            patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
+            patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})),
+            patch.object(client, "create_filament", AsyncMock()) as mock_create,
         ):
-            result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="")
+            result = await client.find_or_create_filament(
+                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
+            )
         assert result == 7
-        mock_patch.assert_called_once_with(7, {"color_name": None})
+        mock_create.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_patch_failure_does_not_block_match(self, client):
-        """A patch_filament failure must not prevent returning the matched id —
-        save should still link the spool to the correct filament."""
-        existing = {**SAMPLE_FILAMENT, "color_name": None}
+    async def test_name_match_does_not_cross_materials(self, client):
+        """Sanity check: a filament with name=subtype must NOT match a request
+        with a different material that happens to share the subtype string.
+        material_match runs first and fails, so the iteration moves on and
+        ``create_filament`` is called."""
+        existing = {
+            **SAMPLE_FILAMENT,
+            "id": 7,
+            "name": "Basic",
+            "material": "PETG",  # different material
+            "color_hex": "FF0000",
+        }
+        new_filament = {"id": 99, "name": "PLA Basic"}
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
-            patch.object(client, "patch_filament", AsyncMock(side_effect=SpoolmanUnavailableError("boom"))),
+            patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
         ):
             result = await client.find_or_create_filament(
-                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
+                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
             )
-        assert result == 7
+        assert result == 99
+        mock_create.assert_called_once()
 
     @pytest.mark.asyncio
     async def test_creates_filament_when_no_match(self, client):
@@ -407,12 +437,15 @@ class TestFindOrCreateFilament:
         ):
             result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
         assert result == 99
+        # color_name is intentionally not forwarded to create_filament (#1357):
+        # Spoolman has no such field on Filament, so passing it would be a
+        # no-op. The route persists color_name to spool.extra.bambu_color_name
+        # after this returns.
         mock_create.assert_called_once_with(
             name="PETG Pro",
             vendor_id=3,
             material="PETG",
             color_hex="00FF00",
-            color_name=None,
             weight=1000.0,
         )
 
@@ -443,7 +476,6 @@ class TestFindOrCreateFilament:
             vendor_id=None,
             material="ABS",
             color_hex="FF0000",
-            color_name=None,
             weight=750.0,
         )
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä