mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
486 lines
18 KiB
Python
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 == ""
|