Files
bambuddy/backend/tests/unit/services/test_slicer_filament_resolver.py
maziggy 9434875fa1 fix(ams): resolve a custom filament's own id from every preset source (issue #3003)
A custom filament profile reaches an AMS slot as itself through exactly one
field, tray_info_idx, and every source we can read that id from was reading it
from the wrong place or not reading it at all.

Bambu Cloud returns a preset's own filament_id either on the response envelope
or inside the preset JSON under `setting`, and only the envelope was read.
Presets of the second shape fell through to the base_id branch and reached the
slicer as the Bambu filament they inherit from. filament_type next door already
handled both spreads; filament_id now does too.

Orca Cloud was absent from the resolver entirely. A spool stores the bare
profile UUID, which matched no branch and fell through normalize_slicer_filament
-- a function that passes anything it does not recognise straight through -- so
a 36-character UUID went into the field. Orca profiles carry their own
filament_id in the slicer JSON that OrcaProfileDetail already exposes under
`setting`, so the lookup is the same one the Bambu branch does. It is
best-effort: no pairing, a dead token or a missing orca_cloud:auth permission
degrades to the fallback rather than failing the assignment, and it passes
clear_on_auth_failure=False because a background caller cannot tell a real
revocation from a lost refresh-rotation race.

configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no
real filament id. That field is 8 characters on the printer -- exactly the width
of a local preset id, less than half a cloud one. Measured on the reporter's A1:
sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success.
The slot then resolved to nothing, so the slicer showed Generic anyway and the
calibration table, keyed by the same field, lost the slot. It now falls back to
the slot's existing filament id or the generic for the material, and the route's
guard was aligned with the resolver's so both refuse the same four shapes from
one shared definition.

This reverses the contract #1053 pinned. Six tests asserted that the PFUS
belonged in tray_info_idx; the A1 capture shows it never worked, so they were
rewritten with the measurement in their docstrings.

Verified against 874 AMS trays across twelve models in the support archive: 92
already carry a custom "P" + 7 hex filament id, which is what confirms the
mechanism works and this is a lookup failure rather than a platform limit. No
tray on any model carries a setting_id, so a profile with no filament_id of its
own still cannot be told apart from its base.
2026-09-07 10:15:06 +02:00

486 lines
18 KiB
Python

"""Tests for ``resolve_slicer_filament`` (#1815).
The defensive filter at the end of the resolver clears ``tray_info_idx``
when its value isn't slicer-acceptable (literal material names + PFUS /
PFCN cloud-preset prefixes that the printer's calibration table can't
key on). Pre-#1815 it cleared ``setting_id`` alongside, which dropped
the slicer's only handle on the user's actual custom preset and forced
the caller into the generic-material fallback — Bambu Studio then
displayed "Generic <Material>" for spools whose Bambu Cloud detail
lookup didn't resolve a ``filament_id`` (cloud unauth on the on_ams_change
replay path, transient cloud failure, or custom presets whose detail
JSON omits ``filament_id``).
Post-#1815 the filter preserves a setting_id that's still a valid
slicer reference (PFUS / PFCN cloud user/shared preset, or GFS Bambu
official preset) even when ``tray_info_idx`` is cleared.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
@pytest.mark.asyncio
async def test_pfus_cloud_unavailable_preserves_setting_id():
"""Reporter scenario: PFUS cloud user preset, cloud lookup fails to
return a filament_id. setting_id must survive so the slicer can
still find the user's actual custom preset."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUS990b6e19965353",
slicer_filament_name="Jayo PETG HF",
material="PETG",
)
assert tray_info_idx == ""
assert setting_id == "PFUS990b6e19965353"
assert sub_brand is None
@pytest.mark.asyncio
async def test_pfcn_cloud_unavailable_preserves_setting_id():
"""PFCN partner/shared cloud preset (e.g. Polymaker H2D variants,
#1648) shares the same shape problem as PFUS."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFCN1234567890",
slicer_filament_name="Polymaker PolyTerra PLA",
material="PLA",
)
assert tray_info_idx == ""
assert setting_id == "PFCN1234567890"
assert sub_brand is None
@pytest.mark.asyncio
async def test_pfus_cloud_resolves_filament_id_regression_guard():
"""When cloud auth works and returns a filament_id, the resolver
keeps its existing behaviour: tray_info_idx = real filament_id,
setting_id = original PFUS reference."""
db = MagicMock()
cloud_mock = MagicMock()
cloud_mock.is_authenticated = True
cloud_mock.get_setting_detail = AsyncMock(return_value={"filament_id": "P285e239", "name": "Jayo PETG HF @P1S"})
cloud_mock.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud_mock),
):
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
db=db,
current_user=MagicMock(),
slicer_filament="PFUS990b6e19965353",
slicer_filament_name="Jayo PETG HF",
material="PETG",
)
assert tray_info_idx == "P285e239"
assert setting_id == "PFUS990b6e19965353"
assert sub_brand == "Jayo PETG HF"
@pytest.mark.asyncio
async def test_gfs_cloud_unavailable_resolves_via_normalize():
"""GFS Bambu official preset + cloud unavailable: normalize strips
the 'S' to give a real filament_id ('GFG02'), so tray_info_idx is
valid and the defensive filter doesn't trigger. setting_id stays as
the original GFS reference. Regression guard for the cloud-down
Bambu-official path."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="GFSG02",
slicer_filament_name=None,
material="PETG",
)
assert tray_info_idx == "GFG02"
assert setting_id == "GFSG02"
assert sub_brand is None
@pytest.mark.asyncio
async def test_literal_material_name_clears_both():
"""slicer_filament='PETG' (free-text material leak from legacy
spools): both tray_info_idx and setting_id must be cleared so the
caller's generic-material fallback rescues the slot. Regression
guard that the PFUS preservation doesn't accidentally preserve
literal material names."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PETG",
slicer_filament_name=None,
material="PETG",
)
assert tray_info_idx == ""
assert setting_id == ""
assert sub_brand is None
class TestThePresetsOwnType:
"""#2902: a preset is chosen from a list the slicer defines, so its
``filament_type`` is the slicer's own answer to what the material is --
no reading of a product name required. Raised by @doncaruana on the issue
after the first fix reduced "PLA Aero" to "PLA".
The resolver hands that answer back as the fourth element; the two assign
routes write it into ``tray_type`` in preference to reducing the spool's
material column. ``None`` means no preset said, and the reduction stands.
"""
@pytest.mark.asyncio
async def test_a_local_presets_type_is_returned(self):
db = MagicMock()
lp = MagicMock()
lp.filament_type = "PLA-AERO"
lp.setting = None
lp.name = "Bambu PLA Aero @BBL X1C"
result = MagicMock()
result.scalar_one_or_none = MagicMock(return_value=lp)
db.execute = AsyncMock(return_value=result)
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="38",
slicer_filament_name=None,
material="PLA",
)
assert type_override == "PLA-AERO"
@pytest.mark.asyncio
async def test_a_cloud_presets_type_is_read_out_of_its_profile(self):
"""Both slicers store it as a one-element array, and the preset JSON
sits under ``setting`` in the cloud envelope."""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={
"filament_id": "GFA11",
"name": "Bambu PLA Aero @BBL X1C",
"setting": {"filament_type": ["PLA-AERO"]},
}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, _sid, _brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="GFSA11",
slicer_filament_name=None,
material="PLA",
)
assert idx == "GFA11"
assert type_override == "PLA-AERO"
@pytest.mark.asyncio
async def test_a_bare_string_filament_type_is_accepted_too(self):
"""Hand-written and older profiles store it unwrapped. ``orca_profiles``
accepts both forms, so this has to as well."""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={"filament_id": "GFG02", "setting": {"filament_type": "PETG"}}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="GFSG02",
slicer_filament_name=None,
material="PETG",
)
assert type_override == "PETG"
@pytest.mark.asyncio
async def test_no_preset_means_no_answer(self):
"""A spool with no slicer_filament -- the case this issue was reported
for. ``material`` is required on a spool and ``slicer_filament`` is
not, so the reduction has to stay as the fallback."""
db = MagicMock()
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=None,
slicer_filament_name=None,
material="PLA+",
)
assert type_override is None
@pytest.mark.asyncio
async def test_a_preset_that_does_not_say_gets_no_opinion(self):
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(return_value={"filament_id": "GFG02", "setting": {}})
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="GFSG02",
slicer_filament_name=None,
material="PETG",
)
assert type_override is None
class TestACustomPresetsOwnFilamentId:
"""Where the id that carries a custom preset into an AMS slot comes from.
The slot holds one filament reference and the printer truncates it to 8
characters, so a custom preset reaches the slicer as itself only when its
own filament_id ("P" + 7 hex) goes into ``tray_info_idx``. 92 trays across
eight models in the support archive do exactly that, so the mechanism
works -- what #3003 found is that we only ever read one of the two places
Bambu Cloud returns that id from.
"""
@pytest.mark.asyncio
async def test_filament_id_is_read_from_inside_the_preset_json(self):
"""The envelope has none, the preset JSON does -- and it wins over base_id.
Before #3003 this fell through to the base_id branch and the slot came
out as the Bambu profile the custom preset inherits from.
"""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={
"name": "SUNLU PLA Transparent @BBL A1",
"base_id": "GFSNLS03",
"setting": {"filament_id": "P4d64437", "filament_type": ["PLA"]},
}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, sid, brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUSfb87cd50b76616",
slicer_filament_name=None,
material="PLA",
)
assert idx == "P4d64437"
assert sid == "PFUSfb87cd50b76616"
assert brand == "SUNLU PLA Transparent"
@pytest.mark.asyncio
async def test_the_envelope_still_wins_when_it_has_one(self):
"""Unchanged behaviour for the presets that already resolved."""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={
"filament_id": "P285e239",
"name": "Jayo PETG HF @P1S",
"base_id": "GFSG02",
"setting": {"filament_id": "P999aaaa"},
}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, _sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUS992454068158eb",
slicer_filament_name=None,
material="PETG",
)
assert idx == "P285e239"
@pytest.mark.asyncio
async def test_base_id_is_still_the_fallback_when_neither_place_has_one(self):
"""A preset with no filament_id of its own genuinely is its base, and
the base id is storable, so it is the right answer -- just not one to
reach for while the preset's own id is sitting under ``setting``."""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={"name": "My PLA @BBL A1", "base_id": "GFSNLS03", "setting": {}}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUSfb87cd50b76616",
slicer_filament_name=None,
material="PLA",
)
assert idx == "GFNLS03"
assert sid == "PFUSfb87cd50b76616"
class TestOrcaCloudIsTheFirstSource:
"""Source order is Orca Cloud, Bambu Cloud, local import, generic.
Orca was absent from the resolver entirely: a spool referencing an Orca
profile stores the bare UUID, which matched no branch and fell through
``normalize_slicer_filament`` -- a function that passes anything it does
not recognise straight through. The UUID reached tray_info_idx, a field
the printer truncates to 8 characters (#3003).
"""
ORCA_ID = "3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30"
@staticmethod
def _svc(profile):
svc = MagicMock()
svc.get_profile = AsyncMock(return_value=profile)
svc.close = AsyncMock()
return svc
@pytest.mark.asyncio
async def test_the_profiles_own_filament_id_is_used(self):
db = MagicMock()
svc = self._svc(
{
"id": self.ORCA_ID,
"name": "Overture Matte PLA @Orca",
"content": {"filament_id": "P56e1be0", "filament_type": ["PLA"]},
}
)
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(return_value=svc),
):
idx, sid, brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == "P56e1be0"
# The UUID is foreign to the slicer in either field, so nothing carries it.
assert sid == ""
assert brand == "Overture Matte PLA"
assert type_override == "PLA"
svc.close.assert_awaited()
@pytest.mark.asyncio
async def test_a_profile_with_no_filament_id_leaves_the_caller_its_fallback(self):
db = MagicMock()
svc = self._svc({"id": self.ORCA_ID, "name": "My PLA", "content": {"filament_type": ["PLA"]}})
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(return_value=svc),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
assert sid == ""
@pytest.mark.asyncio
async def test_an_unreachable_orca_never_leaks_the_uuid(self):
"""No pairing, dead token, Orca down -- all the same answer. The UUID
must not reach tray_info_idx, which is what happened before the branch
existed at all."""
db = MagicMock()
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(side_effect=RuntimeError("Orca Cloud is not connected")),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
assert sid == ""
@pytest.mark.asyncio
async def test_a_caller_without_the_permission_skips_the_lookup(self):
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=False)
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(side_effect=AssertionError("must not be called")),
):
idx, _sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=user,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
@pytest.mark.asyncio
async def test_a_uuid_is_refused_as_tray_info_idx_by_the_closing_guard(self):
"""Belt and braces: a profile whose content names itself by UUID still
does not put one in the field."""
db = MagicMock()
svc = self._svc({"id": self.ORCA_ID, "name": "Odd", "content": {"filament_id": self.ORCA_ID}})
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(return_value=svc),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
assert sid == ""