test_slicer_presets.py 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200
  1. """Tests for the unified slicer-presets endpoint helpers.
  2. The endpoint stitches together four preset sources (local / orca_cloud /
  3. cloud / standard). It does NOT dedup across tiers — every tier surfaces
  4. its full list so the user can pick any source. Bambu Cloud filament
  5. metadata is enriched from same-named entries in the other tiers so it
  6. can still score in the SliceModal's auto-pick. These tests pin the
  7. enrich behaviour, the cloud-status mapping, and the per-user / sidecar
  8. caches at the helper level — full HTTP integration is covered by the
  9. routes test.
  10. """
  11. from __future__ import annotations
  12. import time
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from backend.app.api.routes import slicer_presets as sp
  16. from backend.app.schemas.slicer_presets import UnifiedPreset
  17. def _slot(items: list[tuple[str, str, str]]) -> dict[str, list[UnifiedPreset]]:
  18. """Helper: build a single-slot dict from (id, name, source) tuples placed
  19. on the printer slot. Process / filament default to empty so each test
  20. only exercises the slot it cares about."""
  21. return {
  22. "printer": [UnifiedPreset(id=i, name=n, source=s) for i, n, s in items],
  23. "process": [],
  24. "filament": [],
  25. }
  26. class TestEnrichCloudMetadata:
  27. """No cross-tier dedup — every tier's full list comes back; Bambu Cloud
  28. filament metadata is enriched from same-named entries in other tiers."""
  29. def test_same_name_in_all_tiers_appears_in_every_tier(self):
  30. """Critical regression guard for #1712: a user who has imported a
  31. local profile AND signed in to Orca AND has Bambu Cloud with the
  32. same name should see it under EACH source, not just the highest-
  33. priority tier. The order is used for auto-pick + group rendering;
  34. it is NOT used to hide profiles."""
  35. orca = _slot([("oid1", "Bambu PLA Basic", "orca_cloud")])
  36. cloud = _slot([("cid1", "Bambu PLA Basic", "cloud")])
  37. local = _slot([("lid1", "Bambu PLA Basic", "local")])
  38. standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
  39. oc, c, l_, s = sp._enrich_cloud_metadata(orca, cloud, local, standard)
  40. assert [p.source for p in l_["printer"]] == ["local"]
  41. assert [p.source for p in oc["printer"]] == ["orca_cloud"]
  42. assert [p.source for p in c["printer"]] == ["cloud"]
  43. assert [p.source for p in s["printer"]] == ["standard"]
  44. def test_preserves_order_within_tier(self):
  45. """A tier's input order must be preserved — nothing in the enrich
  46. pass should sort, reverse, or otherwise reorder entries."""
  47. cloud = _slot(
  48. [
  49. ("c1", "Z-First", "cloud"),
  50. ("c2", "A-Second", "cloud"),
  51. ("c3", "M-Third", "cloud"),
  52. ]
  53. )
  54. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
  55. assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
  56. def test_bambu_cloud_filament_metadata_backfilled_from_local(self):
  57. """Bambu Cloud's list response omits filament_type/colour for
  58. rate-limit reasons. A same-named local entry's metadata fills in
  59. so the cloud entry can still score in pickFilamentForSlot."""
  60. local = {
  61. "printer": [],
  62. "process": [],
  63. "filament": [
  64. UnifiedPreset(
  65. id="lp1",
  66. name="Bambu PLA Basic",
  67. source="local",
  68. filament_type="PLA",
  69. filament_colour="#FF0000",
  70. )
  71. ],
  72. }
  73. cloud = {
  74. "printer": [],
  75. "process": [],
  76. "filament": [UnifiedPreset(id="cp1", name="Bambu PLA Basic", source="cloud")],
  77. }
  78. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  79. # Cloud entry now carries the local entry's metadata.
  80. assert c["filament"][0].filament_type == "PLA"
  81. assert c["filament"][0].filament_colour == "#FF0000"
  82. # Local entry is untouched.
  83. assert local["filament"][0].filament_type == "PLA"
  84. def test_bambu_cloud_metadata_falls_back_through_orca_and_standard(self):
  85. """When local doesn't carry the name, orca_cloud / standard fill in."""
  86. orca = {
  87. "printer": [],
  88. "process": [],
  89. "filament": [
  90. UnifiedPreset(
  91. id="o1",
  92. name="Bambu PLA Basic",
  93. source="orca_cloud",
  94. filament_type="PLA",
  95. filament_colour="#00FF00",
  96. )
  97. ],
  98. }
  99. cloud = {
  100. "printer": [],
  101. "process": [],
  102. "filament": [UnifiedPreset(id="cp1", name="Bambu PLA Basic", source="cloud")],
  103. }
  104. _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
  105. assert c["filament"][0].filament_type == "PLA"
  106. assert c["filament"][0].filament_colour == "#00FF00"
  107. def test_bambu_cloud_keeps_its_own_metadata_when_present(self):
  108. """If Bambu Cloud already has filament_type / filament_colour the
  109. enrich pass must not overwrite them with a different same-named
  110. entry's values."""
  111. local = {
  112. "printer": [],
  113. "process": [],
  114. "filament": [
  115. UnifiedPreset(
  116. id="lp1",
  117. name="Bambu PLA Basic",
  118. source="local",
  119. filament_type="PETG",
  120. filament_colour="#000000",
  121. )
  122. ],
  123. }
  124. cloud = {
  125. "printer": [],
  126. "process": [],
  127. "filament": [
  128. UnifiedPreset(
  129. id="cp1",
  130. name="Bambu PLA Basic",
  131. source="cloud",
  132. filament_type="PLA",
  133. filament_colour="#FFFFFF",
  134. )
  135. ],
  136. }
  137. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  138. assert c["filament"][0].filament_type == "PLA"
  139. assert c["filament"][0].filament_colour == "#FFFFFF"
  140. def _filaments(source: str, *items: tuple[str, str | None]) -> dict[str, list[UnifiedPreset]]:
  141. """A tier with only filament presets, from (name, filament_type) pairs."""
  142. return {
  143. "printer": [],
  144. "process": [],
  145. "filament": [UnifiedPreset(id=n, name=n, source=source, filament_type=t) for n, t in items],
  146. }
  147. class TestEnrichMaterialWithoutTwin:
  148. """#3403: with OrcaSlicer preferred, the bundle has no H2C profiles, so a
  149. Bambu Cloud "@BBL H2C" filament has no same-named twin and used to stay
  150. untyped -- a PETG-CF slot then auto-picked Generic PETG."""
  151. def _bundled(self) -> dict[str, list[UnifiedPreset]]:
  152. # The OrcaSlicer 2.4.2 bundle as it lists these: every printer's
  153. # profile but the H2C, and Orca's own "@System" generics.
  154. return _filaments(
  155. "standard",
  156. ("Generic PETG-CF @BBL X1C", "PETG-CF"),
  157. ("Generic PETG-CF @BBL H2D", "PETG-CF"),
  158. ("Generic PETG-CF @System", "PETG"),
  159. ("Generic PETG @BBL X1C", "PETG"),
  160. ("Generic PE-CF @BBL X1C", "PE-CF"),
  161. ("Generic PE-CF @System", "PE"),
  162. )
  163. def test_bambu_cloud_stock_profile_takes_the_material_of_its_other_printers(self):
  164. cloud = _filaments(
  165. "cloud",
  166. ("Generic PETG @BBL H2C", None),
  167. ("Generic PETG-CF @BBL H2C", None),
  168. ("Generic PETG-CF @BBL H2C 0.4 nozzle", None),
  169. ("Generic PE-CF @BBL H2C", None),
  170. )
  171. _oc, c, _l, _s = sp._enrich_cloud_metadata(
  172. _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
  173. )
  174. assert [p.filament_type for p in c["filament"]] == ["PETG", "PETG-CF", "PETG-CF", "PE-CF"]
  175. # Material only: the colour of another printer's profile isn't this one's.
  176. assert all(p.filament_colour is None for p in c["filament"])
  177. def test_disagreeing_bundled_materials_leave_it_unknown(self):
  178. bundled = _filaments("standard", ("Odd PLA @BBL X1C", "PLA"), ("Odd PLA @BBL A1", "PLA-CF"))
  179. cloud = _filaments("cloud", ("Odd PLA @BBL H2C", None))
  180. _oc, c, _l, _s = sp._enrich_cloud_metadata(_filaments("orca_cloud"), cloud, _filaments("local"), bundled)
  181. assert c["filament"][0].filament_type is None
  182. def test_only_bundled_bbl_names_count(self):
  183. """A name without "@BBL", or a user's own "@BBL"-named profile, is no
  184. source of another profile's material."""
  185. local = _filaments("local", ("Generic PLA @BBL X1C", "PETG"))
  186. cloud = _filaments("cloud", ("Generic PETG-CF", None), ("Generic PLA @BBL H2C", None))
  187. _oc, c, _l, _s = sp._enrich_cloud_metadata(_filaments("orca_cloud"), cloud, local, self._bundled())
  188. assert [p.filament_type for p in c["filament"]] == [None, None]
  189. def test_a_users_copy_of_a_stock_profile_is_left_unknown(self):
  190. """Its material may be the one the user changed it to."""
  191. cloud = _filaments("cloud", ("Generic PETG @BBL H2C - Copy", None), ("Generic PETG @BBL H2C 0.4 nozzle", None))
  192. _oc, c, _l, _s = sp._enrich_cloud_metadata(
  193. _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
  194. )
  195. assert [p.filament_type for p in c["filament"]] == [None, "PETG"]
  196. def test_a_stated_material_is_kept(self):
  197. cloud = _filaments("cloud", ("Generic PETG-CF @BBL H2C", "PETG"))
  198. _oc, c, _l, _s = sp._enrich_cloud_metadata(
  199. _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
  200. )
  201. assert c["filament"][0].filament_type == "PETG"
  202. def test_orca_cloud_profile_takes_the_material_of_its_parent(self):
  203. orca = _filaments(
  204. "orca_cloud",
  205. ("My PETG-CF", None),
  206. ("My PETG-CF fast", None),
  207. ("My H2C PETG-CF", None),
  208. ("Loop A", None),
  209. ("Loop B", None),
  210. ("Typed", "PLA"),
  211. ("From System", None),
  212. )
  213. parents = {
  214. "My PETG-CF": "Generic PETG-CF @BBL X1C", # bundled, same name
  215. "My PETG-CF fast": "My PETG-CF", # through another user profile
  216. "My H2C PETG-CF": "Generic PETG-CF @BBL H2C", # not bundled: by filament
  217. "Loop A": "Loop B",
  218. "Loop B": "Loop A",
  219. "Typed": "Generic PETG @BBL X1C",
  220. # Orca's own generic states the coarser PETG: no source.
  221. "From System": "Generic PETG-CF @System",
  222. }
  223. for p in orca["filament"]:
  224. p.inherits = parents[p.name]
  225. oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _filaments("cloud"), _filaments("local"), self._bundled())
  226. assert [p.filament_type for p in oc["filament"]] == ["PETG-CF", "PETG-CF", "PETG-CF", None, None, "PLA", None]
  227. class TestEnrichCompatiblePrinters:
  228. """#2628: the same name bridge carries ``compatible_printers`` onto the
  229. tiers that don't ship one. Bambu Cloud never does — so a profile whose
  230. name carries no printer model reads as "compatibility unknown", which the
  231. SliceModal treats as usable and auto-picks for the wrong printer."""
  232. COMPAT = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
  233. def _tier(self, source: str, slot: str, compat: list[str] | None) -> dict[str, list[UnifiedPreset]]:
  234. empty: dict[str, list[UnifiedPreset]] = {"printer": [], "process": [], "filament": []}
  235. empty[slot] = [
  236. UnifiedPreset(id=f"{source}1", name="Overture PLA Matte @0.2", source=source, compatible_printers=compat)
  237. ]
  238. return empty
  239. def test_bambu_cloud_borrows_the_list_from_a_same_named_local_import(self):
  240. local = self._tier("local", "filament", self.COMPAT)
  241. cloud = self._tier("cloud", "filament", None)
  242. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  243. assert c["filament"][0].compatible_printers == self.COMPAT
  244. def test_bambu_cloud_borrows_from_orca_cloud_too(self):
  245. orca = self._tier("orca_cloud", "filament", self.COMPAT)
  246. cloud = self._tier("cloud", "filament", None)
  247. _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
  248. assert c["filament"][0].compatible_printers == self.COMPAT
  249. def test_orca_cloud_borrows_when_its_own_content_had_no_list(self):
  250. orca = self._tier("orca_cloud", "filament", None)
  251. local = self._tier("local", "filament", self.COMPAT)
  252. oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _slot([]), local, _slot([]))
  253. assert oc["filament"][0].compatible_printers == self.COMPAT
  254. def test_process_slot_is_bridged_as_well(self):
  255. local = self._tier("local", "process", self.COMPAT)
  256. cloud = self._tier("cloud", "process", None)
  257. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  258. assert c["process"][0].compatible_printers == self.COMPAT
  259. def test_never_overwrites_a_list_the_entry_already_has(self):
  260. own = ["Bambu Lab P2S 0.4 nozzle"]
  261. local = self._tier("local", "filament", self.COMPAT)
  262. cloud = self._tier("cloud", "filament", own)
  263. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  264. assert c["filament"][0].compatible_printers == own
  265. def test_no_donor_leaves_the_entry_unclassified(self):
  266. """Absent evidence the entry must stay None — the SliceModal then
  267. falls back to the name matcher instead of hiding the profile."""
  268. cloud = self._tier("cloud", "filament", None)
  269. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
  270. assert c["filament"][0].compatible_printers is None
  271. def test_borrowed_list_is_copied_not_shared(self):
  272. """A later mutation of one tier's list must not reach through to the
  273. other — these objects are cached per user between requests."""
  274. local = self._tier("local", "filament", list(self.COMPAT))
  275. cloud = self._tier("cloud", "filament", None)
  276. _oc, c, l_, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  277. l_["filament"][0].compatible_printers.append("Bambu Lab H2D 0.4 nozzle")
  278. assert c["filament"][0].compatible_printers == self.COMPAT
  279. def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
  280. """Construct a mock User that passes the CLOUD_AUTH permission check.
  281. `MagicMock` defaults `.has_permission(...)` to a truthy MagicMock object,
  282. which would coincidentally pass the gate — but explicit is better than
  283. accidental. Setting `.return_value = True` documents the intent."""
  284. user = MagicMock(id=user_id)
  285. user.has_permission = MagicMock(return_value=True)
  286. return user
  287. class TestFetchOrcaCloudPresets:
  288. """``_fetch_orca_cloud_presets`` mirrors the Bambu Cloud fetcher's status
  289. vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``)
  290. and the same permission-shortcut + caching behaviour. Tests pin the
  291. contract so a future bug in either fetcher doesn't silently desync them."""
  292. def _orca_creds(self, token: str | None = "tok") -> MagicMock:
  293. creds = MagicMock()
  294. creds.token = token
  295. return creds
  296. @pytest.mark.asyncio
  297. async def test_no_token_returns_not_authenticated(self):
  298. sp._orca_cloud_cache.clear()
  299. with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds(None))):
  300. user = MagicMock(id=1)
  301. user.has_permission = MagicMock(return_value=True)
  302. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  303. assert status == "not_authenticated"
  304. assert slots == {"printer": [], "process": [], "filament": []}
  305. @pytest.mark.asyncio
  306. async def test_user_without_orca_cloud_auth_returns_not_authenticated(self):
  307. """Defence-in-depth — a user lacking ORCA_CLOUD_AUTH must not see Orca
  308. presets even if their User row carries a stale token. Credentials
  309. lookup must short-circuit ahead of the token read."""
  310. sp._orca_cloud_cache.clear()
  311. user = MagicMock(id=1)
  312. user.has_permission = MagicMock(return_value=False)
  313. with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))) as load:
  314. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  315. assert status == "not_authenticated"
  316. assert slots["printer"] == []
  317. load.assert_not_called()
  318. @pytest.mark.asyncio
  319. async def test_auth_error_returns_expired(self):
  320. sp._orca_cloud_cache.clear()
  321. svc_mock = MagicMock()
  322. svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudAuthError("expired"))
  323. svc_mock.close = AsyncMock()
  324. user = MagicMock(id=1)
  325. user.has_permission = MagicMock(return_value=True)
  326. with (
  327. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  328. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  329. ):
  330. _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  331. assert status == "expired"
  332. svc_mock.close.assert_awaited_once()
  333. @pytest.mark.asyncio
  334. async def test_orca_error_returns_unreachable(self):
  335. sp._orca_cloud_cache.clear()
  336. svc_mock = MagicMock()
  337. svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudError("net down"))
  338. svc_mock.close = AsyncMock()
  339. user = MagicMock(id=1)
  340. user.has_permission = MagicMock(return_value=True)
  341. with (
  342. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  343. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  344. ):
  345. _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  346. assert status == "unreachable"
  347. @pytest.mark.asyncio
  348. async def test_happy_path_shapes_grouped_by_type(self):
  349. """Orca content.type values map onto Bambu Cloud's preset type vocab
  350. (``printer`` / ``print`` → ``process`` / ``filament``). Verify the
  351. full mapping by feeding one of each shape."""
  352. sp._orca_cloud_cache.clear()
  353. svc_mock = MagicMock()
  354. svc_mock.list_profiles = AsyncMock(
  355. return_value=[
  356. {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
  357. {"id": "p1", "name": "Orca 0.20mm", "content": {"type": "print"}},
  358. {
  359. "id": "f1",
  360. "name": "Orca PLA",
  361. "content": {
  362. "type": "filament",
  363. "filament_type": ["PLA"],
  364. "default_filament_colour": ["#000000"],
  365. },
  366. },
  367. ]
  368. )
  369. svc_mock.close = AsyncMock()
  370. user = MagicMock(id=1)
  371. user.has_permission = MagicMock(return_value=True)
  372. with (
  373. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  374. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  375. ):
  376. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  377. assert status == "ok"
  378. assert [p.name for p in slots["printer"]] == ["Orca X1C"]
  379. assert [p.name for p in slots["process"]] == ["Orca 0.20mm"]
  380. filament = slots["filament"]
  381. assert [p.name for p in filament] == ["Orca PLA"]
  382. # Inline metadata extracted from the content blob (Orca's sync_pull
  383. # returns full content, so unlike Bambu Cloud we don't need a second
  384. # per-preset fetch to enrich filament_type / filament_colour).
  385. assert filament[0].filament_type == "PLA"
  386. assert filament[0].filament_colour == "#000000"
  387. @pytest.mark.asyncio
  388. async def test_extracts_compatible_printers_from_content(self):
  389. """#2628: Orca's sync_pull already carries the profile's own
  390. compatible-printer list. Surfacing it lets the SliceModal reject a
  391. profile built for another printer instead of falling back to reading
  392. the model out of the NAME — which fails outright for names that carry
  393. no model ("Overture PLA Matte @0.2")."""
  394. sp._orca_cloud_cache.clear()
  395. compat = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
  396. svc_mock = MagicMock()
  397. svc_mock.list_profiles = AsyncMock(
  398. return_value=[
  399. {
  400. "id": "f1",
  401. "name": "Overture PLA Matte @0.2",
  402. "content": {
  403. "type": "filament",
  404. "filament_type": ["PLA"],
  405. "compatible_printers": compat,
  406. },
  407. },
  408. {
  409. "id": "p1",
  410. "name": "Orca 0.20mm",
  411. "content": {"type": "print", "compatible_printers": "Bambu Lab P2S 0.4 nozzle"},
  412. },
  413. {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
  414. ]
  415. )
  416. svc_mock.close = AsyncMock()
  417. user = MagicMock(id=1)
  418. user.has_permission = MagicMock(return_value=True)
  419. with (
  420. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  421. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  422. ):
  423. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  424. assert status == "ok"
  425. assert slots["filament"][0].compatible_printers == compat
  426. # A single-printer profile may store a bare string — normalised to a list.
  427. assert slots["process"][0].compatible_printers == ["Bambu Lab P2S 0.4 nozzle"]
  428. # Printer presets have nothing to be compatible with.
  429. assert slots["printer"][0].compatible_printers is None
  430. @pytest.mark.asyncio
  431. async def test_printer_preset_carries_the_preset_it_was_saved_from(self):
  432. """#3250: a printer preset saved under a name of its own is in no
  433. process's compatible_printers; the SliceModal needs its parent."""
  434. sp._orca_cloud_cache.clear()
  435. svc_mock = MagicMock()
  436. svc_mock.list_profiles = AsyncMock(
  437. return_value=[
  438. {
  439. "id": "m1",
  440. "name": "Bambu Lab H2D 0.4 nozzle - Skew",
  441. "content": {"type": "printer", "inherits": " Bambu Lab H2D 0.4 nozzle "},
  442. },
  443. {"id": "m2", "name": "Orca X1C", "content": {"type": "printer", "inherits": ""}},
  444. {"id": "m3", "name": "Odd", "content": {"type": "printer", "inherits": ["x"]}},
  445. ]
  446. )
  447. svc_mock.close = AsyncMock()
  448. user = MagicMock(id=1)
  449. user.has_permission = MagicMock(return_value=True)
  450. with (
  451. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  452. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  453. ):
  454. slots, _ = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  455. assert [p.inherits for p in slots["printer"]] == ["Bambu Lab H2D 0.4 nozzle", None, None]
  456. @pytest.mark.asyncio
  457. async def test_quoted_compatible_printers_are_unquoted(self):
  458. """#3380: Orca Cloud returns list settings INI-style — one string of
  459. quoted items joined by ``;``. Left as-is, the names never equal the
  460. selected printer preset and the SliceModal hides the profile."""
  461. sp._orca_cloud_cache.clear()
  462. svc_mock = MagicMock()
  463. svc_mock.list_profiles = AsyncMock(
  464. return_value=[
  465. {
  466. "id": "f1",
  467. "name": "SUNLU PLA Meta @Bambu Lab X1 Carbon 0.4 nozzle",
  468. "content": {
  469. "type": "filament",
  470. "filament_type": "PLA",
  471. "default_filament_colour": '""',
  472. "compatible_printers": '"Bambu Lab X1 Carbon 0.4 nozzle";"Bambu Lab H2C 0.4 nozzle"',
  473. },
  474. },
  475. {"id": "f2", "name": "Only quotes", "content": {"type": "filament", "compatible_printers": '""'}},
  476. {
  477. "id": "f3",
  478. "name": "Escaped",
  479. "content": {
  480. "type": "filament",
  481. "default_filament_colour": '"#FF0000"',
  482. "compatible_printers": '"Printer \\"A\\"";"Bambu Lab A1 0.4 nozzle"',
  483. },
  484. },
  485. {
  486. "id": "f4",
  487. "name": "Text after a closing quote",
  488. "content": {"type": "filament", "filament_type": "PLA", "compatible_printers": '"X1C" P1S'},
  489. },
  490. ]
  491. )
  492. svc_mock.close = AsyncMock()
  493. user = MagicMock(id=1)
  494. user.has_permission = MagicMock(return_value=True)
  495. with (
  496. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  497. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  498. ):
  499. slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  500. meta, only_quotes, escaped, bad = slots["filament"]
  501. assert meta.compatible_printers == ["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab H2C 0.4 nozzle"]
  502. assert meta.filament_type == "PLA"
  503. assert meta.filament_colour is None
  504. assert only_quotes.compatible_printers is None
  505. assert escaped.compatible_printers == ['Printer "A"', "Bambu Lab A1 0.4 nozzle"]
  506. assert escaped.filament_colour == "#FF0000"
  507. # One unparseable value reads as "no data" and the rest of the listing survives.
  508. assert bad.compatible_printers is None
  509. assert bad.filament_type == "PLA"
  510. @pytest.mark.asyncio
  511. async def test_filament_preset_carries_the_preset_it_was_saved_from(self):
  512. """#3403: a filament saved with only the keys the user changed states no
  513. material of its own; the enrich pass reads it off this parent."""
  514. sp._orca_cloud_cache.clear()
  515. svc_mock = MagicMock()
  516. svc_mock.list_profiles = AsyncMock(
  517. return_value=[
  518. {
  519. "id": "f1",
  520. "name": "My PETG-CF",
  521. "content": {"type": "filament", "inherits": "Generic PETG-CF @BBL X1C"},
  522. },
  523. {
  524. "id": "p1",
  525. "name": "Orca 0.20mm",
  526. "content": {"type": "print", "inherits": "0.20mm Standard @BBL X1C"},
  527. },
  528. ]
  529. )
  530. svc_mock.close = AsyncMock()
  531. user = MagicMock(id=1)
  532. user.has_permission = MagicMock(return_value=True)
  533. with (
  534. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  535. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  536. ):
  537. slots, _ = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  538. assert slots["filament"][0].inherits == "Generic PETG-CF @BBL X1C"
  539. assert slots["filament"][0].filament_type is None
  540. assert slots["process"][0].inherits is None
  541. @pytest.mark.asyncio
  542. async def test_missing_or_malformed_compatible_printers_stays_none(self):
  543. """No data must read as "unknown", never as "compatible with nothing" —
  544. the SliceModal falls back to the name matcher for those."""
  545. sp._orca_cloud_cache.clear()
  546. svc_mock = MagicMock()
  547. svc_mock.list_profiles = AsyncMock(
  548. return_value=[
  549. {"id": "f1", "name": "No list", "content": {"type": "filament"}},
  550. {"id": "f2", "name": "Empty list", "content": {"type": "filament", "compatible_printers": []}},
  551. {"id": "f3", "name": "Blanks", "content": {"type": "filament", "compatible_printers": ["", " "]}},
  552. {"id": "f4", "name": "Wrong type", "content": {"type": "filament", "compatible_printers": {"a": 1}}},
  553. ]
  554. )
  555. svc_mock.close = AsyncMock()
  556. user = MagicMock(id=1)
  557. user.has_permission = MagicMock(return_value=True)
  558. with (
  559. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  560. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  561. ):
  562. slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  563. assert [p.compatible_printers for p in slots["filament"]] == [None, None, None, None]
  564. @pytest.mark.asyncio
  565. async def test_cache_hit_skips_orca_call(self):
  566. """A second call within TTL must reuse the cached slots and NOT
  567. hit the Orca service again — same TTL as Bambu Cloud (5 min)."""
  568. sp._orca_cloud_cache.clear()
  569. svc_mock = MagicMock()
  570. svc_mock.list_profiles = AsyncMock(return_value=[])
  571. svc_mock.close = AsyncMock()
  572. user = MagicMock(id=1)
  573. user.has_permission = MagicMock(return_value=True)
  574. with (
  575. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  576. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build,
  577. ):
  578. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  579. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  580. # Build is the cache miss signal — second call reused the cache.
  581. build.assert_awaited_once()
  582. class TestFetchCloudPresets:
  583. """`_fetch_cloud_presets` translates token state and cloud errors into
  584. the four ``cloud_status`` values the SliceModal banner consumes."""
  585. @pytest.mark.asyncio
  586. async def test_no_token_returns_not_authenticated(self):
  587. sp._cloud_cache.clear()
  588. with patch.object(sp, "get_stored_token", AsyncMock(return_value=(None, None, None))):
  589. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  590. assert status == "not_authenticated"
  591. assert slots == {"printer": [], "process": [], "filament": []}
  592. @pytest.mark.asyncio
  593. async def test_user_without_cloud_auth_returns_not_authenticated(self):
  594. """Defence-in-depth: a user lacking CLOUD_AUTH must NOT see cloud
  595. presets even if their User row carries a stale cloud_token from a
  596. previous permission state. Token lookup is skipped entirely."""
  597. sp._cloud_cache.clear()
  598. user = MagicMock(id=1)
  599. user.has_permission = MagicMock(return_value=False)
  600. with patch.object(sp, "get_stored_token", AsyncMock(return_value=("leftover-token", None, None))) as get_tok:
  601. slots, status = await sp._fetch_cloud_presets(MagicMock(), user)
  602. assert status == "not_authenticated"
  603. assert slots["printer"] == []
  604. # Token was never read — the perm check short-circuits ahead of it.
  605. get_tok.assert_not_called()
  606. @pytest.mark.asyncio
  607. async def test_auth_error_returns_expired(self):
  608. sp._cloud_cache.clear()
  609. cloud_mock = MagicMock()
  610. cloud_mock.set_token = MagicMock()
  611. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudAuthError("expired"))
  612. cloud_mock.close = AsyncMock()
  613. with (
  614. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", "e@x", None))),
  615. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  616. ):
  617. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  618. assert status == "expired"
  619. assert slots["printer"] == []
  620. cloud_mock.close.assert_awaited_once()
  621. @pytest.mark.asyncio
  622. async def test_cloud_error_returns_unreachable(self):
  623. sp._cloud_cache.clear()
  624. cloud_mock = MagicMock()
  625. cloud_mock.set_token = MagicMock()
  626. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudError("net down"))
  627. cloud_mock.close = AsyncMock()
  628. with (
  629. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  630. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  631. ):
  632. _slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  633. assert status == "unreachable"
  634. @pytest.mark.asyncio
  635. async def test_happy_path_shapes_private_then_public(self):
  636. """Cloud presets split into private (user-custom) + public (Bambu's
  637. stock cloud presets). Private should sort before public so a user's
  638. own customisations sit at the top of the dropdown."""
  639. sp._cloud_cache.clear()
  640. cloud_mock = MagicMock()
  641. cloud_mock.set_token = MagicMock()
  642. cloud_mock.get_slicer_settings = AsyncMock(
  643. return_value={
  644. "printer": {
  645. "private": [{"setting_id": "PFUprivate1", "name": "My X1C"}],
  646. "public": [{"setting_id": "PFUpublic1", "name": "Bambu X1C Stock"}],
  647. },
  648. "print": {"private": [], "public": []},
  649. "filament": {"private": [], "public": []},
  650. }
  651. )
  652. cloud_mock.close = AsyncMock()
  653. with (
  654. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  655. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  656. ):
  657. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  658. assert status == "ok"
  659. names = [p.name for p in slots["printer"]]
  660. assert names == ["My X1C", "Bambu X1C Stock"]
  661. @pytest.mark.asyncio
  662. async def test_cache_hit_skips_cloud_call(self):
  663. """A second call within TTL must reuse the cached slots and NOT
  664. hit Bambu Cloud again."""
  665. sp._cloud_cache.clear()
  666. cloud_mock = MagicMock()
  667. cloud_mock.set_token = MagicMock()
  668. cloud_mock.get_slicer_settings = AsyncMock(
  669. return_value={
  670. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  671. "print": {"private": [], "public": []},
  672. "filament": {"private": [], "public": []},
  673. }
  674. )
  675. cloud_mock.close = AsyncMock()
  676. user = _user_with_cloud_auth(user_id=42)
  677. with (
  678. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  679. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  680. ):
  681. await sp._fetch_cloud_presets(MagicMock(), user)
  682. await sp._fetch_cloud_presets(MagicMock(), user)
  683. cloud_mock.get_slicer_settings.assert_awaited_once()
  684. @pytest.mark.asyncio
  685. async def test_cache_is_per_user(self):
  686. """User A's cached cloud presets must not surface for user B."""
  687. sp._cloud_cache.clear()
  688. def make_mock(name: str):
  689. m = MagicMock()
  690. m.set_token = MagicMock()
  691. m.get_slicer_settings = AsyncMock(
  692. return_value={
  693. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  694. "print": {"private": [], "public": []},
  695. "filament": {"private": [], "public": []},
  696. }
  697. )
  698. m.close = AsyncMock()
  699. return m
  700. sequence = [make_mock("AliceX1C"), make_mock("BobX1C")]
  701. with (
  702. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  703. patch.object(sp, "BambuCloudService", side_effect=sequence),
  704. ):
  705. alice_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(1))
  706. bob_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(2))
  707. assert alice_slots["printer"][0].name == "AliceX1C"
  708. assert bob_slots["printer"][0].name == "BobX1C"
  709. @pytest.mark.asyncio
  710. async def test_cache_invalidates_on_token_change(self):
  711. """A token change (logout + login, admin reset, region switch) must
  712. bypass the cache for that user — pinning a real-world auth bug
  713. where user re-login + cache-stuck-on-old-cloud-account would
  714. silently serve a different account's preset list for ~5 minutes."""
  715. sp._cloud_cache.clear()
  716. def make_mock(name: str):
  717. m = MagicMock()
  718. m.set_token = MagicMock()
  719. m.get_slicer_settings = AsyncMock(
  720. return_value={
  721. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  722. "print": {"private": [], "public": []},
  723. "filament": {"private": [], "public": []},
  724. }
  725. )
  726. m.close = AsyncMock()
  727. return m
  728. # Same user_id, different token between calls — the second call must
  729. # NOT serve the first call's cached slots.
  730. services = [make_mock("OldAccountX1C"), make_mock("NewAccountX1C")]
  731. token_sequence = [("tok-old", None, None), ("tok-new", None, None)]
  732. user = _user_with_cloud_auth(user_id=7)
  733. with (
  734. patch.object(sp, "get_stored_token", AsyncMock(side_effect=token_sequence)),
  735. patch.object(sp, "BambuCloudService", side_effect=services),
  736. ):
  737. first, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  738. second, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  739. assert first["printer"][0].name == "OldAccountX1C"
  740. assert second["printer"][0].name == "NewAccountX1C"
  741. @pytest.mark.asyncio
  742. async def test_refresh_bypasses_cloud_cache(self):
  743. """``refresh=True`` must skip an otherwise-warm cache entry and hit
  744. Bambu Cloud again — wiring for the SliceModal's Refresh button so a
  745. user who deletes a cloud preset in Bambu Studio / Handy doesn't have
  746. to wait for the 5-minute TTL to expire (#1581)."""
  747. sp._cloud_cache.clear()
  748. cloud_mock = MagicMock()
  749. cloud_mock.set_token = MagicMock()
  750. cloud_mock.get_slicer_settings = AsyncMock(
  751. return_value={
  752. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  753. "print": {"private": [], "public": []},
  754. "filament": {"private": [], "public": []},
  755. }
  756. )
  757. cloud_mock.close = AsyncMock()
  758. user = _user_with_cloud_auth(user_id=99)
  759. with (
  760. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  761. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  762. ):
  763. await sp._fetch_cloud_presets(MagicMock(), user)
  764. # Without refresh, the second call hits cache (covered by
  765. # test_cache_hit_skips_cloud_call). With refresh=True it MUST
  766. # re-fetch.
  767. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  768. assert cloud_mock.get_slicer_settings.await_count == 2
  769. @pytest.mark.asyncio
  770. async def test_refresh_writes_back_to_cache(self):
  771. """A refresh call must still update the cache so a subsequent normal
  772. call doesn't re-hit the cloud immediately afterwards."""
  773. sp._cloud_cache.clear()
  774. cloud_mock = MagicMock()
  775. cloud_mock.set_token = MagicMock()
  776. cloud_mock.get_slicer_settings = AsyncMock(
  777. return_value={
  778. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  779. "print": {"private": [], "public": []},
  780. "filament": {"private": [], "public": []},
  781. }
  782. )
  783. cloud_mock.close = AsyncMock()
  784. user = _user_with_cloud_auth(user_id=101)
  785. with (
  786. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  787. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  788. ):
  789. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  790. await sp._fetch_cloud_presets(MagicMock(), user)
  791. # Two calls — first refresh, second a normal cache hit.
  792. assert cloud_mock.get_slicer_settings.await_count == 1
  793. class TestFetchBundledPresets:
  794. """Standard tier reaches out to the slicer-api sidecar; tolerate the
  795. sidecar being absent / unreachable so the modal still works."""
  796. @pytest.mark.asyncio
  797. async def test_no_sidecar_url_returns_empty(self):
  798. sp._bundled_cache = None
  799. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  800. slots = await sp._fetch_bundled_presets(MagicMock())
  801. assert slots == {"printer": [], "process": [], "filament": []}
  802. # No URL means no useful cache result either — second call should
  803. # try again (so users who configure a URL mid-session see results).
  804. assert sp._bundled_cache is None
  805. @pytest.mark.asyncio
  806. async def test_sidecar_error_returns_empty(self):
  807. sp._bundled_cache = None
  808. svc_mock = MagicMock()
  809. svc_mock.list_bundled_profiles = AsyncMock(side_effect=sp.SlicerApiError("boom"))
  810. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  811. svc_mock.__aexit__ = AsyncMock(return_value=False)
  812. with (
  813. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://nope")),
  814. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  815. ):
  816. slots = await sp._fetch_bundled_presets(MagicMock())
  817. assert slots == {"printer": [], "process": [], "filament": []}
  818. @pytest.mark.asyncio
  819. async def test_happy_path_shapes_response(self):
  820. sp._bundled_cache = None
  821. svc_mock = MagicMock()
  822. svc_mock.list_bundled_profiles = AsyncMock(
  823. return_value={
  824. "printer": [{"name": "Bambu X1C 0.4", "base_id": None}],
  825. "process": [{"name": "0.20mm Standard", "base_id": "fdm_process_common"}],
  826. "filament": [{"name": "Bambu PLA Basic", "base_id": "fdm_filament_pla"}],
  827. }
  828. )
  829. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  830. svc_mock.__aexit__ = AsyncMock(return_value=False)
  831. with (
  832. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  833. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  834. ):
  835. slots = await sp._fetch_bundled_presets(MagicMock())
  836. assert slots["printer"][0].name == "Bambu X1C 0.4"
  837. assert slots["printer"][0].source == "standard"
  838. # Bundled presets are addressed by name (the slicer's inheritance
  839. # walker resolves them by name), so id == name.
  840. assert slots["printer"][0].id == "Bambu X1C 0.4"
  841. @pytest.mark.asyncio
  842. async def test_cache_hit_skips_sidecar(self):
  843. """A second call within TTL must serve from the cached entry and not
  844. re-hit the sidecar HTTP."""
  845. sp._bundled_cache = (
  846. time.monotonic(),
  847. {
  848. "printer": [UnifiedPreset(id="Cached", name="Cached", source="standard")],
  849. "process": [],
  850. "filament": [],
  851. },
  852. )
  853. # If `SlicerApiService` is constructed at all we've missed the cache.
  854. with patch.object(sp, "SlicerApiService", side_effect=AssertionError("cache miss!")):
  855. slots = await sp._fetch_bundled_presets(MagicMock())
  856. assert slots["printer"][0].name == "Cached"
  857. @pytest.mark.asyncio
  858. async def test_refresh_bypasses_bundled_cache(self):
  859. """``refresh=True`` must re-hit the sidecar even when the in-process
  860. cache is warm — paired with the cloud-cache refresh, this is what
  861. powers the SliceModal's Refresh button (#1581)."""
  862. sp._bundled_cache = (
  863. time.monotonic(),
  864. {
  865. "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
  866. "process": [],
  867. "filament": [],
  868. },
  869. )
  870. svc_mock = MagicMock()
  871. svc_mock.list_bundled_profiles = AsyncMock(
  872. return_value={
  873. "printer": [{"name": "Fresh", "base_id": None}],
  874. "process": [],
  875. "filament": [],
  876. }
  877. )
  878. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  879. svc_mock.__aexit__ = AsyncMock(return_value=False)
  880. with (
  881. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  882. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  883. ):
  884. slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
  885. svc_mock.list_bundled_profiles.assert_awaited_once()
  886. assert [p.name for p in slots["printer"]] == ["Fresh"]
  887. # The fresh result must also be written back to the cache so a
  888. # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
  889. assert sp._bundled_cache is not None
  890. assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
  891. class TestResolveSlicerApiUrl:
  892. """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`
  893. setting just like the slice route does. The bundled-listing fetch
  894. used to be hardcoded to OrcaSlicer's URL, which left the Standard
  895. tier permanently empty for BambuStudio installs."""
  896. @pytest.mark.asyncio
  897. async def test_bambu_studio_preference_uses_bambu_url(self):
  898. """When the user prefers Bambu Studio, the listing fetch must hit
  899. the bambu-studio-api sidecar (port 3001 by default), not orca's
  900. port 3003."""
  901. async def fake_get_setting(_db, key):
  902. return {
  903. "preferred_slicer": "bambu_studio",
  904. "bambu_studio_api_url": "http://bambu-studio-api:3000",
  905. }.get(key)
  906. with patch(
  907. "backend.app.api.routes.settings.get_setting",
  908. new=fake_get_setting,
  909. ):
  910. url = await sp._resolve_slicer_api_url(MagicMock())
  911. assert url == "http://bambu-studio-api:3000"
  912. @pytest.mark.asyncio
  913. async def test_orcaslicer_preference_uses_orca_url(self):
  914. async def fake_get_setting(_db, key):
  915. return {
  916. "preferred_slicer": "orcaslicer",
  917. "orcaslicer_api_url": "http://orca-slicer-api:3000",
  918. }.get(key)
  919. with patch(
  920. "backend.app.api.routes.settings.get_setting",
  921. new=fake_get_setting,
  922. ):
  923. url = await sp._resolve_slicer_api_url(MagicMock())
  924. assert url == "http://orca-slicer-api:3000"
  925. @pytest.mark.asyncio
  926. async def test_default_preference_is_bambu_studio(self):
  927. """Empty preferred_slicer → bambu_studio (matches the slice route's
  928. default at library.py:_run_slicer_with_fallback)."""
  929. async def fake_get_setting(_db, key):
  930. return {
  931. # preferred_slicer not set
  932. "bambu_studio_api_url": "http://bambu-default:3000",
  933. }.get(key)
  934. with patch(
  935. "backend.app.api.routes.settings.get_setting",
  936. new=fake_get_setting,
  937. ):
  938. url = await sp._resolve_slicer_api_url(MagicMock())
  939. assert url == "http://bambu-default:3000"
  940. @pytest.mark.asyncio
  941. async def test_unknown_preference_returns_none(self):
  942. """An unrecognised preferred_slicer value (e.g. set out-of-band by
  943. a stale migration) returns None so the modal degrades to "no
  944. Standard tier" rather than crashing — the slice route raises 400
  945. in this case but the listing is informational, so be lenient."""
  946. async def fake_get_setting(_db, key):
  947. return {"preferred_slicer": "prusaslicer"}.get(key)
  948. with patch(
  949. "backend.app.api.routes.settings.get_setting",
  950. new=fake_get_setting,
  951. ):
  952. url = await sp._resolve_slicer_api_url(MagicMock())
  953. assert url is None
  954. class TestParseCompatiblePrinters:
  955. """``compatible_printers`` exposed for local process / filament presets so
  956. the SliceModal can filter the dropdowns by the selected printer (#1325)."""
  957. def test_parses_json_array(self):
  958. raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
  959. assert sp._parse_compatible_printers(raw) == [
  960. "Bambu Lab X1 Carbon 0.4 nozzle",
  961. "Bambu Lab X1 0.4 nozzle",
  962. ]
  963. def test_none_and_empty_return_none(self):
  964. assert sp._parse_compatible_printers(None) is None
  965. assert sp._parse_compatible_printers("") is None
  966. assert sp._parse_compatible_printers("[]") is None
  967. def test_malformed_json_returns_none(self):
  968. assert sp._parse_compatible_printers("not json") is None
  969. # A JSON value that isn't an array is treated as absent, not an error.
  970. assert sp._parse_compatible_printers('"a string"') is None
  971. def test_drops_non_string_and_blank_entries(self):
  972. assert sp._parse_compatible_printers('["X1C", 5, "", " ", "A1"]') == [
  973. "X1C",
  974. "A1",
  975. ]
  976. class TestListPrinterModels:
  977. """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
  978. frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
  979. def test_returns_canonical_printer_model_map(self):
  980. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  981. result = sp.list_printer_models()
  982. # Same shape - mapping from "Bambu Lab <model>" to short code.
  983. assert result == PRINTER_MODEL_MAP
  984. # Spot-check a few entries: the SliceModal name-fallback (#1325)
  985. # specifically depends on these resolving.
  986. assert result["Bambu Lab X1 Carbon"] == "X1C"
  987. assert result["Bambu Lab P2S"] == "P2S"
  988. assert result["Bambu Lab A1 mini"] == "A1 Mini"
  989. assert result["Bambu Lab H2D Pro"] == "H2D Pro"
  990. def test_returns_a_copy_not_the_module_dict(self):
  991. # A response handler must never hand out the live module-level dict —
  992. # accidental mutation by middleware / serialisers would silently
  993. # corrupt the registry for every subsequent request.
  994. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  995. result = sp.list_printer_models()
  996. assert result is not PRINTER_MODEL_MAP
  997. class TestNoUserWithAuthOn:
  998. """The sign-in stored without a user is the auth-off install's, and may be
  999. left over after auth was turned on. A caller with no user while auth is
  1000. on (an API key without Allow Cloud Access) must not list its presets."""
  1001. @pytest.mark.asyncio
  1002. async def test_bambu_cloud(self):
  1003. sp._cloud_cache.clear()
  1004. with (
  1005. patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
  1006. patch.object(sp, "get_stored_token", AsyncMock(return_value=("global-tok", None, None))) as get_tok,
  1007. ):
  1008. slots, status = await sp._fetch_cloud_presets(MagicMock(), None)
  1009. assert status == "not_authenticated"
  1010. assert slots == {"printer": [], "process": [], "filament": []}
  1011. get_tok.assert_not_called()
  1012. @pytest.mark.asyncio
  1013. async def test_orca_cloud(self):
  1014. sp._orca_cloud_cache.clear()
  1015. with (
  1016. patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
  1017. patch.object(sp, "_load_orca_credentials", AsyncMock()) as load,
  1018. ):
  1019. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), None)
  1020. assert status == "not_authenticated"
  1021. assert slots == {"printer": [], "process": [], "filament": []}
  1022. load.assert_not_called()
  1023. class TestLocalPrinterInherits:
  1024. """#3250: local printer presets report the preset they were saved from."""
  1025. @pytest.mark.asyncio
  1026. async def test_local_printer_preset_carries_inherits(self, db_session):
  1027. from backend.app.models.local_preset import LocalPreset
  1028. db_session.add_all(
  1029. [
  1030. LocalPreset(
  1031. name="Bambu Lab H2D 0.4 nozzle - Skew",
  1032. preset_type="printer",
  1033. source="orcaslicer",
  1034. setting="{}",
  1035. inherits="Bambu Lab H2D 0.4 nozzle",
  1036. ),
  1037. LocalPreset(name="Plain", preset_type="printer", source="orcaslicer", setting="{}", inherits=" "),
  1038. LocalPreset(
  1039. name="0.20mm Mine",
  1040. preset_type="process",
  1041. source="orcaslicer",
  1042. setting="{}",
  1043. inherits="0.20mm Standard @BBL H2D",
  1044. ),
  1045. ]
  1046. )
  1047. await db_session.commit()
  1048. slots = await sp._fetch_local_presets(db_session)
  1049. by_name = {p.name: p for p in slots["printer"]}
  1050. assert by_name["Bambu Lab H2D 0.4 nozzle - Skew"].inherits == "Bambu Lab H2D 0.4 nozzle"
  1051. assert by_name["Plain"].inherits is None
  1052. # Only printer presets need it; a process is matched by its own list.
  1053. assert slots["process"][0].inherits is None