test_slicer_filament_resolver.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. """Tests for ``resolve_slicer_filament`` (#1815).
  2. The defensive filter at the end of the resolver clears ``tray_info_idx``
  3. when its value isn't slicer-acceptable (literal material names + PFUS /
  4. PFCN cloud-preset prefixes that the printer's calibration table can't
  5. key on). Pre-#1815 it cleared ``setting_id`` alongside, which dropped
  6. the slicer's only handle on the user's actual custom preset and forced
  7. the caller into the generic-material fallback — Bambu Studio then
  8. displayed "Generic <Material>" for spools whose Bambu Cloud detail
  9. lookup didn't resolve a ``filament_id`` (cloud unauth on the on_ams_change
  10. replay path, transient cloud failure, or custom presets whose detail
  11. JSON omits ``filament_id``).
  12. Post-#1815 the filter preserves a setting_id that's still a valid
  13. slicer reference (PFUS / PFCN cloud user/shared preset, or GFS Bambu
  14. official preset) even when ``tray_info_idx`` is cleared.
  15. """
  16. from __future__ import annotations
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
  20. @pytest.mark.asyncio
  21. async def test_pfus_cloud_unavailable_preserves_setting_id():
  22. """Reporter scenario: PFUS cloud user preset, cloud lookup fails to
  23. return a filament_id. setting_id must survive so the slicer can
  24. still find the user's actual custom preset."""
  25. db = MagicMock()
  26. with patch(
  27. "backend.app.api.routes.cloud.build_authenticated_cloud",
  28. AsyncMock(return_value=None),
  29. ):
  30. tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
  31. db=db,
  32. current_user=None,
  33. slicer_filament="PFUS990b6e19965353",
  34. slicer_filament_name="Jayo PETG HF",
  35. material="PETG",
  36. )
  37. assert tray_info_idx == ""
  38. assert setting_id == "PFUS990b6e19965353"
  39. assert sub_brand is None
  40. @pytest.mark.asyncio
  41. async def test_pfcn_cloud_unavailable_preserves_setting_id():
  42. """PFCN partner/shared cloud preset (e.g. Polymaker H2D variants,
  43. #1648) shares the same shape problem as PFUS."""
  44. db = MagicMock()
  45. with patch(
  46. "backend.app.api.routes.cloud.build_authenticated_cloud",
  47. AsyncMock(return_value=None),
  48. ):
  49. tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
  50. db=db,
  51. current_user=None,
  52. slicer_filament="PFCN1234567890",
  53. slicer_filament_name="Polymaker PolyTerra PLA",
  54. material="PLA",
  55. )
  56. assert tray_info_idx == ""
  57. assert setting_id == "PFCN1234567890"
  58. assert sub_brand is None
  59. @pytest.mark.asyncio
  60. async def test_pfus_cloud_resolves_filament_id_regression_guard():
  61. """When cloud auth works and returns a filament_id, the resolver
  62. keeps its existing behaviour: tray_info_idx = real filament_id,
  63. setting_id = original PFUS reference."""
  64. db = MagicMock()
  65. cloud_mock = MagicMock()
  66. cloud_mock.is_authenticated = True
  67. cloud_mock.get_setting_detail = AsyncMock(return_value={"filament_id": "P285e239", "name": "Jayo PETG HF @P1S"})
  68. cloud_mock.close = AsyncMock()
  69. with patch(
  70. "backend.app.api.routes.cloud.build_authenticated_cloud",
  71. AsyncMock(return_value=cloud_mock),
  72. ):
  73. tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
  74. db=db,
  75. current_user=MagicMock(),
  76. slicer_filament="PFUS990b6e19965353",
  77. slicer_filament_name="Jayo PETG HF",
  78. material="PETG",
  79. )
  80. assert tray_info_idx == "P285e239"
  81. assert setting_id == "PFUS990b6e19965353"
  82. assert sub_brand == "Jayo PETG HF"
  83. @pytest.mark.asyncio
  84. async def test_gfs_cloud_unavailable_resolves_via_normalize():
  85. """GFS Bambu official preset + cloud unavailable: normalize strips
  86. the 'S' to give a real filament_id ('GFG02'), so tray_info_idx is
  87. valid and the defensive filter doesn't trigger. setting_id stays as
  88. the original GFS reference. Regression guard for the cloud-down
  89. Bambu-official path."""
  90. db = MagicMock()
  91. with patch(
  92. "backend.app.api.routes.cloud.build_authenticated_cloud",
  93. AsyncMock(return_value=None),
  94. ):
  95. tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
  96. db=db,
  97. current_user=None,
  98. slicer_filament="GFSG02",
  99. slicer_filament_name=None,
  100. material="PETG",
  101. )
  102. assert tray_info_idx == "GFG02"
  103. assert setting_id == "GFSG02"
  104. assert sub_brand is None
  105. @pytest.mark.asyncio
  106. async def test_literal_material_name_clears_both():
  107. """slicer_filament='PETG' (free-text material leak from legacy
  108. spools): both tray_info_idx and setting_id must be cleared so the
  109. caller's generic-material fallback rescues the slot. Regression
  110. guard that the PFUS preservation doesn't accidentally preserve
  111. literal material names."""
  112. db = MagicMock()
  113. with patch(
  114. "backend.app.api.routes.cloud.build_authenticated_cloud",
  115. AsyncMock(return_value=None),
  116. ):
  117. tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
  118. db=db,
  119. current_user=None,
  120. slicer_filament="PETG",
  121. slicer_filament_name=None,
  122. material="PETG",
  123. )
  124. assert tray_info_idx == ""
  125. assert setting_id == ""
  126. assert sub_brand is None
  127. class TestThePresetsOwnType:
  128. """#2902: a preset is chosen from a list the slicer defines, so its
  129. ``filament_type`` is the slicer's own answer to what the material is --
  130. no reading of a product name required. Raised by @doncaruana on the issue
  131. after the first fix reduced "PLA Aero" to "PLA".
  132. The resolver hands that answer back as the fourth element; the two assign
  133. routes write it into ``tray_type`` in preference to reducing the spool's
  134. material column. ``None`` means no preset said, and the reduction stands.
  135. """
  136. @pytest.mark.asyncio
  137. async def test_a_local_presets_type_is_returned(self):
  138. db = MagicMock()
  139. lp = MagicMock()
  140. lp.filament_type = "PLA-AERO"
  141. lp.setting = None
  142. lp.name = "Bambu PLA Aero @BBL X1C"
  143. result = MagicMock()
  144. result.scalar_one_or_none = MagicMock(return_value=lp)
  145. db.execute = AsyncMock(return_value=result)
  146. _idx, _sid, _brand, type_override = await resolve_slicer_filament(
  147. db=db,
  148. current_user=None,
  149. slicer_filament="38",
  150. slicer_filament_name=None,
  151. material="PLA",
  152. )
  153. assert type_override == "PLA-AERO"
  154. @pytest.mark.asyncio
  155. async def test_a_cloud_presets_type_is_read_out_of_its_profile(self):
  156. """Both slicers store it as a one-element array, and the preset JSON
  157. sits under ``setting`` in the cloud envelope."""
  158. db = MagicMock()
  159. cloud = MagicMock()
  160. cloud.is_authenticated = True
  161. cloud.get_setting_detail = AsyncMock(
  162. return_value={
  163. "filament_id": "GFA11",
  164. "name": "Bambu PLA Aero @BBL X1C",
  165. "setting": {"filament_type": ["PLA-AERO"]},
  166. }
  167. )
  168. cloud.close = AsyncMock()
  169. with patch(
  170. "backend.app.api.routes.cloud.build_authenticated_cloud",
  171. AsyncMock(return_value=cloud),
  172. ):
  173. idx, _sid, _brand, type_override = await resolve_slicer_filament(
  174. db=db,
  175. current_user=None,
  176. slicer_filament="GFSA11",
  177. slicer_filament_name=None,
  178. material="PLA",
  179. )
  180. assert idx == "GFA11"
  181. assert type_override == "PLA-AERO"
  182. @pytest.mark.asyncio
  183. async def test_a_bare_string_filament_type_is_accepted_too(self):
  184. """Hand-written and older profiles store it unwrapped. ``orca_profiles``
  185. accepts both forms, so this has to as well."""
  186. db = MagicMock()
  187. cloud = MagicMock()
  188. cloud.is_authenticated = True
  189. cloud.get_setting_detail = AsyncMock(
  190. return_value={"filament_id": "GFG02", "setting": {"filament_type": "PETG"}}
  191. )
  192. cloud.close = AsyncMock()
  193. with patch(
  194. "backend.app.api.routes.cloud.build_authenticated_cloud",
  195. AsyncMock(return_value=cloud),
  196. ):
  197. _idx, _sid, _brand, type_override = await resolve_slicer_filament(
  198. db=db,
  199. current_user=None,
  200. slicer_filament="GFSG02",
  201. slicer_filament_name=None,
  202. material="PETG",
  203. )
  204. assert type_override == "PETG"
  205. @pytest.mark.asyncio
  206. async def test_no_preset_means_no_answer(self):
  207. """A spool with no slicer_filament -- the case this issue was reported
  208. for. ``material`` is required on a spool and ``slicer_filament`` is
  209. not, so the reduction has to stay as the fallback."""
  210. db = MagicMock()
  211. _idx, _sid, _brand, type_override = await resolve_slicer_filament(
  212. db=db,
  213. current_user=None,
  214. slicer_filament=None,
  215. slicer_filament_name=None,
  216. material="PLA+",
  217. )
  218. assert type_override is None
  219. @pytest.mark.asyncio
  220. async def test_a_preset_that_does_not_say_gets_no_opinion(self):
  221. db = MagicMock()
  222. cloud = MagicMock()
  223. cloud.is_authenticated = True
  224. cloud.get_setting_detail = AsyncMock(return_value={"filament_id": "GFG02", "setting": {}})
  225. cloud.close = AsyncMock()
  226. with patch(
  227. "backend.app.api.routes.cloud.build_authenticated_cloud",
  228. AsyncMock(return_value=cloud),
  229. ):
  230. _idx, _sid, _brand, type_override = await resolve_slicer_filament(
  231. db=db,
  232. current_user=None,
  233. slicer_filament="GFSG02",
  234. slicer_filament_name=None,
  235. material="PETG",
  236. )
  237. assert type_override is None
  238. class TestACustomPresetsOwnFilamentId:
  239. """Where the id that carries a custom preset into an AMS slot comes from.
  240. The slot holds one filament reference and the printer truncates it to 8
  241. characters, so a custom preset reaches the slicer as itself only when its
  242. own filament_id ("P" + 7 hex) goes into ``tray_info_idx``. 92 trays across
  243. eight models in the support archive do exactly that, so the mechanism
  244. works -- what #3003 found is that we only ever read one of the two places
  245. Bambu Cloud returns that id from.
  246. """
  247. @pytest.mark.asyncio
  248. async def test_filament_id_is_read_from_inside_the_preset_json(self):
  249. """The envelope has none, the preset JSON does -- and it wins over base_id.
  250. Before #3003 this fell through to the base_id branch and the slot came
  251. out as the Bambu profile the custom preset inherits from.
  252. """
  253. db = MagicMock()
  254. cloud = MagicMock()
  255. cloud.is_authenticated = True
  256. cloud.get_setting_detail = AsyncMock(
  257. return_value={
  258. "name": "SUNLU PLA Transparent @BBL A1",
  259. "base_id": "GFSNLS03",
  260. "setting": {"filament_id": "P4d64437", "filament_type": ["PLA"]},
  261. }
  262. )
  263. cloud.close = AsyncMock()
  264. with patch(
  265. "backend.app.api.routes.cloud.build_authenticated_cloud",
  266. AsyncMock(return_value=cloud),
  267. ):
  268. idx, sid, brand, _type = await resolve_slicer_filament(
  269. db=db,
  270. current_user=None,
  271. slicer_filament="PFUSfb87cd50b76616",
  272. slicer_filament_name=None,
  273. material="PLA",
  274. )
  275. assert idx == "P4d64437"
  276. assert sid == "PFUSfb87cd50b76616"
  277. assert brand == "SUNLU PLA Transparent"
  278. @pytest.mark.asyncio
  279. async def test_the_envelope_still_wins_when_it_has_one(self):
  280. """Unchanged behaviour for the presets that already resolved."""
  281. db = MagicMock()
  282. cloud = MagicMock()
  283. cloud.is_authenticated = True
  284. cloud.get_setting_detail = AsyncMock(
  285. return_value={
  286. "filament_id": "P285e239",
  287. "name": "Jayo PETG HF @P1S",
  288. "base_id": "GFSG02",
  289. "setting": {"filament_id": "P999aaaa"},
  290. }
  291. )
  292. cloud.close = AsyncMock()
  293. with patch(
  294. "backend.app.api.routes.cloud.build_authenticated_cloud",
  295. AsyncMock(return_value=cloud),
  296. ):
  297. idx, _sid, _brand, _type = await resolve_slicer_filament(
  298. db=db,
  299. current_user=None,
  300. slicer_filament="PFUS992454068158eb",
  301. slicer_filament_name=None,
  302. material="PETG",
  303. )
  304. assert idx == "P285e239"
  305. @pytest.mark.asyncio
  306. async def test_base_id_is_still_the_fallback_when_neither_place_has_one(self):
  307. """A preset with no filament_id of its own genuinely is its base, and
  308. the base id is storable, so it is the right answer -- just not one to
  309. reach for while the preset's own id is sitting under ``setting``."""
  310. db = MagicMock()
  311. cloud = MagicMock()
  312. cloud.is_authenticated = True
  313. cloud.get_setting_detail = AsyncMock(
  314. return_value={"name": "My PLA @BBL A1", "base_id": "GFSNLS03", "setting": {}}
  315. )
  316. cloud.close = AsyncMock()
  317. with patch(
  318. "backend.app.api.routes.cloud.build_authenticated_cloud",
  319. AsyncMock(return_value=cloud),
  320. ):
  321. idx, sid, _brand, _type = await resolve_slicer_filament(
  322. db=db,
  323. current_user=None,
  324. slicer_filament="PFUSfb87cd50b76616",
  325. slicer_filament_name=None,
  326. material="PLA",
  327. )
  328. assert idx == "GFNLS03"
  329. assert sid == "PFUSfb87cd50b76616"
  330. class TestOrcaCloudIsTheFirstSource:
  331. """Source order is Orca Cloud, Bambu Cloud, local import, generic.
  332. Orca was absent from the resolver entirely: a spool referencing an Orca
  333. profile stores the bare UUID, which matched no branch and fell through
  334. ``normalize_slicer_filament`` -- a function that passes anything it does
  335. not recognise straight through. The UUID reached tray_info_idx, a field
  336. the printer truncates to 8 characters (#3003).
  337. """
  338. ORCA_ID = "3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30"
  339. @staticmethod
  340. def _svc(profile):
  341. svc = MagicMock()
  342. svc.get_profile = AsyncMock(return_value=profile)
  343. svc.close = AsyncMock()
  344. return svc
  345. @pytest.mark.asyncio
  346. async def test_the_profiles_own_filament_id_is_used(self):
  347. db = MagicMock()
  348. svc = self._svc(
  349. {
  350. "id": self.ORCA_ID,
  351. "name": "Overture Matte PLA @Orca",
  352. "content": {"filament_id": "P56e1be0", "filament_type": ["PLA"]},
  353. }
  354. )
  355. with patch(
  356. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  357. AsyncMock(return_value=svc),
  358. ):
  359. idx, sid, brand, type_override = await resolve_slicer_filament(
  360. db=db,
  361. current_user=None,
  362. slicer_filament=self.ORCA_ID,
  363. slicer_filament_name=None,
  364. material="PLA",
  365. )
  366. assert idx == "P56e1be0"
  367. # The UUID is foreign to the slicer in either field, so nothing carries it.
  368. assert sid == ""
  369. assert brand == "Overture Matte PLA"
  370. assert type_override == "PLA"
  371. svc.close.assert_awaited()
  372. @pytest.mark.asyncio
  373. async def test_a_profile_with_no_filament_id_leaves_the_caller_its_fallback(self):
  374. db = MagicMock()
  375. svc = self._svc({"id": self.ORCA_ID, "name": "My PLA", "content": {"filament_type": ["PLA"]}})
  376. with patch(
  377. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  378. AsyncMock(return_value=svc),
  379. ):
  380. idx, sid, _brand, _type = await resolve_slicer_filament(
  381. db=db,
  382. current_user=None,
  383. slicer_filament=self.ORCA_ID,
  384. slicer_filament_name=None,
  385. material="PLA",
  386. )
  387. assert idx == ""
  388. assert sid == ""
  389. @pytest.mark.asyncio
  390. async def test_an_unreachable_orca_never_leaks_the_uuid(self):
  391. """No pairing, dead token, Orca down -- all the same answer. The UUID
  392. must not reach tray_info_idx, which is what happened before the branch
  393. existed at all."""
  394. db = MagicMock()
  395. with patch(
  396. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  397. AsyncMock(side_effect=RuntimeError("Orca Cloud is not connected")),
  398. ):
  399. idx, sid, _brand, _type = await resolve_slicer_filament(
  400. db=db,
  401. current_user=None,
  402. slicer_filament=self.ORCA_ID,
  403. slicer_filament_name=None,
  404. material="PLA",
  405. )
  406. assert idx == ""
  407. assert sid == ""
  408. @pytest.mark.asyncio
  409. async def test_a_caller_without_the_permission_skips_the_lookup(self):
  410. db = MagicMock()
  411. user = MagicMock()
  412. user.has_permission = MagicMock(return_value=False)
  413. with patch(
  414. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  415. AsyncMock(side_effect=AssertionError("must not be called")),
  416. ):
  417. idx, _sid, _brand, _type = await resolve_slicer_filament(
  418. db=db,
  419. current_user=user,
  420. slicer_filament=self.ORCA_ID,
  421. slicer_filament_name=None,
  422. material="PLA",
  423. )
  424. assert idx == ""
  425. @pytest.mark.asyncio
  426. async def test_a_uuid_is_refused_as_tray_info_idx_by_the_closing_guard(self):
  427. """Belt and braces: a profile whose content names itself by UUID still
  428. does not put one in the field."""
  429. db = MagicMock()
  430. svc = self._svc({"id": self.ORCA_ID, "name": "Odd", "content": {"filament_id": self.ORCA_ID}})
  431. with patch(
  432. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  433. AsyncMock(return_value=svc),
  434. ):
  435. idx, sid, _brand, _type = await resolve_slicer_filament(
  436. db=db,
  437. current_user=None,
  438. slicer_filament=self.ORCA_ID,
  439. slicer_filament_name=None,
  440. material="PLA",
  441. )
  442. assert idx == ""
  443. assert sid == ""