test_filament_profile_defaults.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. """Profile defaults per product (#1326).
  2. A default holds the slicer preset, the per-model preset overrides and the K
  3. profiles for one Brand/Material/Subtype, for all colours or for one, and every
  4. way a spool is added copies it onto the new spool. Both inventory modes; the
  5. default itself is shared.
  6. """
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. from sqlalchemy import select
  11. DEFAULTS = "/api/v1/inventory/profile-defaults"
  12. INTERNAL = "/api/v1/inventory/spools"
  13. SPOOLMAN = "/api/v1/spoolman/inventory"
  14. def _k(printer_id, *, extruder=0, diameter="0.4", cali_idx=5, k_value=0.02, name="PLA HF"):
  15. return {
  16. "printer_id": printer_id,
  17. "extruder": extruder,
  18. "nozzle_diameter": diameter,
  19. "nozzle_type": None,
  20. "k_value": k_value,
  21. "name": name,
  22. "cali_idx": cali_idx,
  23. "setting_id": None,
  24. }
  25. def _preset(model="H2D", diameter="0.4", code="PFUS01", name="My PLA @BBL H2D"):
  26. return {"printer_model": model, "nozzle_diameter": diameter, "slicer_filament": code, "slicer_filament_name": name}
  27. def _default(color_name="", **overrides):
  28. body = {
  29. "brand": "Bambu Lab",
  30. "material": "PLA",
  31. "subtype": "Basic",
  32. "color_name": color_name,
  33. "slicer_filament": "PFUS00",
  34. "slicer_filament_name": "My PLA",
  35. "presets": [],
  36. "k_profiles": [],
  37. }
  38. body.update(overrides)
  39. return body
  40. def _spool_body(**overrides):
  41. body = {"material": "PLA", "subtype": "Basic", "brand": "Bambu Lab", "color_name": "Jade White"}
  42. body.update(overrides)
  43. return body
  44. @pytest.fixture
  45. async def printers(printer_factory):
  46. return [await printer_factory(model="H2D"), await printer_factory(model="X1C")]
  47. @pytest.mark.integration
  48. class TestDefaultsCrud:
  49. @pytest.mark.asyncio
  50. async def test_put_creates_then_replaces(self, async_client: AsyncClient, printers):
  51. a, b = printers
  52. response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
  53. assert response.status_code == 200, response.text
  54. first = response.json()
  55. response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(b.id)]))
  56. assert response.status_code == 200, response.text
  57. second = response.json()
  58. assert second["id"] == first["id"]
  59. assert [k["printer_id"] for k in second["k_profiles"]] == [b.id]
  60. assert second["presets"] == []
  61. listed = (await async_client.get(DEFAULTS)).json()
  62. assert len(listed) == 1
  63. @pytest.mark.asyncio
  64. async def test_product_matches_case_insensitively(self, async_client: AsyncClient):
  65. await async_client.put(DEFAULTS, json=_default())
  66. await async_client.put(DEFAULTS, json=_default(brand="bambu lab", material="pla", subtype="basic"))
  67. assert len((await async_client.get(DEFAULTS)).json()) == 1
  68. @pytest.mark.asyncio
  69. async def test_duplicate_hotend_is_refused_before_anything_changes(self, async_client: AsyncClient, printers):
  70. a, _ = printers
  71. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
  72. response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(a.id, cali_idx=7)]))
  73. assert response.status_code == 422
  74. kept = (await async_client.get(DEFAULTS)).json()[0]
  75. assert [k["cali_idx"] for k in kept["k_profiles"]] == [5]
  76. @pytest.mark.asyncio
  77. async def test_unknown_printer_is_refused(self, async_client: AsyncClient):
  78. response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(999)]))
  79. assert response.status_code == 404
  80. @pytest.mark.asyncio
  81. async def test_delete(self, async_client: AsyncClient):
  82. row = (await async_client.put(DEFAULTS, json=_default())).json()
  83. assert (await async_client.delete(f"{DEFAULTS}/{row['id']}")).status_code == 200
  84. assert (await async_client.get(DEFAULTS)).json() == []
  85. assert (await async_client.delete(f"{DEFAULTS}/{row['id']}")).status_code == 404
  86. @pytest.mark.asyncio
  87. async def test_deleting_a_printer_takes_its_k_profiles_with_it(self, async_client: AsyncClient, printers):
  88. a, b = printers
  89. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
  90. assert (await async_client.delete(f"/api/v1/printers/{a.id}")).status_code == 200
  91. kept = (await async_client.get(DEFAULTS)).json()[0]
  92. assert [k["printer_id"] for k in kept["k_profiles"]] == [b.id]
  93. @pytest.mark.integration
  94. class TestResolve:
  95. @pytest.mark.asyncio
  96. async def test_no_default(self, async_client: AsyncClient):
  97. response = await async_client.get(f"{DEFAULTS}/resolve", params={"material": "PLA"})
  98. assert response.status_code == 200
  99. assert response.json() is None
  100. @pytest.mark.asyncio
  101. async def test_all_colours_default_covers_every_colour(self, async_client: AsyncClient, printers):
  102. a, _ = printers
  103. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
  104. resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body(color_name="Never Seen"))).json()
  105. assert resolved["slicer_filament"] == "PFUS00"
  106. assert [k["cali_idx"] for k in resolved["k_profiles"]] == [5]
  107. @pytest.mark.asyncio
  108. async def test_colour_wins_per_hotend_and_keeps_the_rest(self, async_client: AsyncClient, printers):
  109. a, b = printers
  110. await async_client.put(
  111. DEFAULTS,
  112. json=_default(
  113. k_profiles=[_k(a.id, extruder=0), _k(a.id, extruder=1), _k(b.id)],
  114. presets=[_preset("H2D"), _preset("X1C", code="PFUS02")],
  115. ),
  116. )
  117. await async_client.put(
  118. DEFAULTS,
  119. json=_default(
  120. "Jade White",
  121. slicer_filament=None,
  122. k_profiles=[_k(a.id, extruder=1, cali_idx=9, k_value=0.031)],
  123. presets=[_preset("X1C", code="PFUS09")],
  124. ),
  125. )
  126. resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body())).json()
  127. by_hotend = {(k["printer_id"], k["extruder"]): k["cali_idx"] for k in resolved["k_profiles"]}
  128. assert by_hotend == {(a.id, 0): 5, (a.id, 1): 9, (b.id, 0): 5}
  129. by_model = {p["printer_model"]: p["slicer_filament"] for p in resolved["presets"]}
  130. assert by_model == {"H2D": "PFUS01", "X1C": "PFUS09"}
  131. # The colour row sets no preset of its own, so the product's stays.
  132. assert resolved["slicer_filament"] == "PFUS00"
  133. @pytest.mark.asyncio
  134. async def test_other_products_are_not_matched(self, async_client: AsyncClient):
  135. await async_client.put(DEFAULTS, json=_default())
  136. for params in (_spool_body(subtype="Matte"), _spool_body(brand="Polymaker"), _spool_body(material="PETG")):
  137. assert (await async_client.get(f"{DEFAULTS}/resolve", params=params)).json() is None
  138. @pytest.mark.integration
  139. class TestInternalCreate:
  140. @pytest.mark.asyncio
  141. async def test_new_spool_gets_the_default(self, async_client: AsyncClient, printers):
  142. a, _ = printers
  143. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
  144. spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
  145. assert spool["slicer_filament"] == "PFUS00"
  146. assert spool["slicer_filament_name"] == "My PLA"
  147. assert [(k["printer_id"], k["cali_idx"]) for k in spool["k_profiles"]] == [(a.id, 5)]
  148. presets = (await async_client.get(f"{INTERNAL}/{spool['id']}/filament-presets")).json()
  149. assert [p["slicer_filament"] for p in presets] == ["PFUS01"]
  150. @pytest.mark.asyncio
  151. async def test_a_preset_in_the_request_is_kept(self, async_client: AsyncClient):
  152. await async_client.put(DEFAULTS, json=_default())
  153. spool = (
  154. await async_client.post(
  155. INTERNAL, json=_spool_body(slicer_filament="GFA00", slicer_filament_name="Bambu PLA Basic")
  156. )
  157. ).json()
  158. assert spool["slicer_filament"] == "GFA00"
  159. @pytest.mark.asyncio
  160. async def test_bulk_create_gives_every_copy_the_default(self, async_client: AsyncClient, printers):
  161. a, _ = printers
  162. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
  163. response = await async_client.post(f"{INTERNAL}/bulk", json={"spool": _spool_body(), "quantity": 3})
  164. assert response.status_code == 200, response.text
  165. spools = response.json()
  166. assert len(spools) == 3
  167. assert all([k["cali_idx"] for k in s["k_profiles"]] == [5] for s in spools)
  168. assert all(s["slicer_filament"] == "PFUS00" for s in spools)
  169. @pytest.mark.asyncio
  170. async def test_without_a_default_nothing_is_added(self, async_client: AsyncClient):
  171. spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
  172. assert spool["slicer_filament"] is None
  173. assert spool["k_profiles"] == []
  174. @pytest.mark.asyncio
  175. async def test_rfid_auto_create_keeps_the_tag_preset_and_assigns_the_default_k(self, db_session, printers):
  176. from backend.app.models.filament_profile_default import (
  177. FilamentProfileDefault,
  178. FilamentProfileDefaultKProfile,
  179. )
  180. from backend.app.services.spool_tag_matcher import create_spool_from_tray
  181. a, _ = printers
  182. row = FilamentProfileDefault(
  183. match_key="bambu lab\x1fpla\x1fbasic\x1f",
  184. brand="Bambu Lab",
  185. material="PLA",
  186. subtype="Basic",
  187. slicer_filament="PFUS00",
  188. )
  189. db_session.add(row)
  190. await db_session.flush()
  191. db_session.add(FilamentProfileDefaultKProfile(profile_default_id=row.id, **_k(a.id)))
  192. await db_session.commit()
  193. spool = await create_spool_from_tray(
  194. db_session,
  195. {
  196. "tray_type": "PLA",
  197. "tray_sub_brands": "PLA Basic",
  198. "tray_color": "FFFFFFFF",
  199. "tray_info_idx": "GFA00",
  200. "tray_weight": "1000",
  201. "remain": 100,
  202. "tag_uid": "A1B2C3D4E5F60708",
  203. "tray_uuid": "0123456789ABCDEF0123456789ABCDEF",
  204. },
  205. )
  206. assert spool.slicer_filament == "GFA00"
  207. # In the in-memory list the RFID assign right after reads.
  208. assert [k.cali_idx for k in spool.k_profiles] == [5]
  209. @pytest.mark.integration
  210. class TestInternalApply:
  211. @pytest.mark.asyncio
  212. async def test_fills_only_what_is_missing(self, async_client: AsyncClient, printers):
  213. a, b = printers
  214. # Spools first, so the create path has no default to apply.
  215. bare = (await async_client.post(INTERNAL, json=_spool_body())).json()
  216. own = (await async_client.post(INTERNAL, json=_spool_body(slicer_filament="GFA00"))).json()
  217. await async_client.put(f"{INTERNAL}/{own['id']}/k-profiles", json=[_k(a.id, cali_idx=11, k_value=0.05)])
  218. other = (await async_client.post(INTERNAL, json=_spool_body(subtype="Matte"))).json()
  219. archived = (await async_client.post(INTERNAL, json=_spool_body())).json()
  220. await async_client.post(f"{INTERNAL}/{archived['id']}/archive")
  221. row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))).json()
  222. response = await async_client.post(f"{DEFAULTS}/{row['id']}/apply")
  223. assert response.status_code == 200, response.text
  224. assert response.json() == {"updated": 2}
  225. def k_of(spool):
  226. return {k["printer_id"]: k["cali_idx"] for k in spool["k_profiles"]}
  227. bare = (await async_client.get(f"{INTERNAL}/{bare['id']}")).json()
  228. assert bare["slicer_filament"] == "PFUS00"
  229. assert k_of(bare) == {a.id: 5, b.id: 5}
  230. own = (await async_client.get(f"{INTERNAL}/{own['id']}")).json()
  231. assert own["slicer_filament"] == "GFA00"
  232. assert k_of(own) == {a.id: 11, b.id: 5}
  233. assert (await async_client.get(f"{INTERNAL}/{other['id']}")).json()["k_profiles"] == []
  234. assert (await async_client.get(f"{INTERNAL}/{archived['id']}")).json()["k_profiles"] == []
  235. # Nothing left to fill.
  236. assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 0}
  237. @pytest.mark.asyncio
  238. async def test_all_colours_apply_gives_a_colour_its_own_values(self, async_client: AsyncClient, printers):
  239. a, _ = printers
  240. white = (await async_client.post(INTERNAL, json=_spool_body())).json()
  241. black = (await async_client.post(INTERNAL, json=_spool_body(color_name="Black"))).json()
  242. row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))).json()
  243. await async_client.put(DEFAULTS, json=_default("Jade White", k_profiles=[_k(a.id, cali_idx=9)]))
  244. assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 2}
  245. white = (await async_client.get(f"{INTERNAL}/{white['id']}")).json()
  246. black = (await async_client.get(f"{INTERNAL}/{black['id']}")).json()
  247. assert [k["cali_idx"] for k in white["k_profiles"]] == [9]
  248. assert [k["cali_idx"] for k in black["k_profiles"]] == [5]
  249. # ── Spoolman mode ────────────────────────────────────────────────────────────
  250. def _spoolman_spool(spool_id, *, extra=None, name="PLA Basic", archived=False):
  251. return {
  252. "id": spool_id,
  253. "filament": {
  254. "id": 7,
  255. "name": name,
  256. "material": "PLA",
  257. "color_hex": "FFFFFF",
  258. "weight": 1000,
  259. "vendor": {"id": 3, "name": "Bambu Lab"},
  260. },
  261. "remaining_weight": 1000.0,
  262. "used_weight": 0.0,
  263. "location": None,
  264. "comment": None,
  265. "first_used": None,
  266. "last_used": None,
  267. "registered": "2024-01-01T00:00:00+00:00",
  268. "archived": archived,
  269. "price": None,
  270. "extra": extra or {},
  271. }
  272. @pytest.fixture
  273. def spoolman_client():
  274. client = MagicMock()
  275. client.base_url = "http://localhost:7912"
  276. client.health_check = AsyncMock(return_value=True)
  277. ids = iter(range(100, 200))
  278. client.create_spool = AsyncMock(side_effect=lambda **kwargs: _spoolman_spool(next(ids)))
  279. client.find_or_create_filament = AsyncMock(return_value=7)
  280. client.find_or_create_vendor = AsyncMock(return_value=3)
  281. client.ensure_extra_field = AsyncMock(return_value=True)
  282. client.merge_spool_extra = AsyncMock(side_effect=lambda spool_id, extra: _spoolman_spool(spool_id, extra=extra))
  283. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)):
  284. yield client
  285. async def _spoolman_rows(db_session, spool_id):
  286. from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
  287. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  288. k = (
  289. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id)))
  290. .scalars()
  291. .all()
  292. )
  293. presets = (
  294. (
  295. await db_session.execute(
  296. select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)
  297. )
  298. )
  299. .scalars()
  300. .all()
  301. )
  302. return [r.cali_idx for r in k], [p.slicer_filament for p in presets]
  303. @pytest.mark.integration
  304. class TestSpoolman:
  305. @pytest.mark.asyncio
  306. async def test_create_gets_the_default(self, async_client: AsyncClient, db_session, printers, spoolman_client):
  307. a, _ = printers
  308. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
  309. response = await async_client.post(f"{SPOOLMAN}/spools", json=_spool_body())
  310. assert response.status_code == 200, response.text
  311. assert response.json()["slicer_filament"] == "PFUS00"
  312. extra = spoolman_client.merge_spool_extra.call_args.args[1]
  313. assert extra["bambu_slicer_filament"] == '"PFUS00"'
  314. assert extra["bambu_color_name"] == '"Jade White"'
  315. assert await _spoolman_rows(db_session, 100) == ([5], ["PFUS01"])
  316. @pytest.mark.asyncio
  317. async def test_bulk_create_writes_preset_colour_and_default_on_every_copy(
  318. self, async_client: AsyncClient, db_session, printers, spoolman_client
  319. ):
  320. a, _ = printers
  321. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
  322. response = await async_client.post(f"{SPOOLMAN}/spools/bulk", json={"spool": _spool_body(), "quantity": 2})
  323. assert response.status_code == 200, response.text
  324. # Bulk wrote neither the preset nor the colour name before.
  325. written = {c.args[0]: c.args[1] for c in spoolman_client.merge_spool_extra.call_args_list}
  326. assert set(written) == {100, 101}
  327. assert all(e["bambu_color_name"] == '"Jade White"' for e in written.values())
  328. assert all(e["bambu_slicer_filament"] == '"PFUS00"' for e in written.values())
  329. for spool_id in (100, 101):
  330. assert (await _spoolman_rows(db_session, spool_id))[0] == [5]
  331. @pytest.mark.asyncio
  332. async def test_apply_fills_spoolman_spools(self, async_client: AsyncClient, db_session, printers, spoolman_client):
  333. a, _ = printers
  334. spoolman_client.get_all_spools = AsyncMock(
  335. return_value=[
  336. _spoolman_spool(1, extra={"bambu_color_name": '"Jade White"'}),
  337. _spoolman_spool(2, extra={"bambu_slicer_filament": '"GFA00"'}),
  338. _spoolman_spool(3, name="PLA Matte"),
  339. _spoolman_spool(4, archived=True),
  340. ]
  341. )
  342. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  343. # Spool 2 has its own K profile for the hotend the default covers.
  344. db_session.add(SpoolmanKProfile(spoolman_spool_id=2, **_k(a.id, cali_idx=11)))
  345. await db_session.commit()
  346. row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))).json()
  347. response = await async_client.post(f"{SPOOLMAN}/profile-defaults/{row['id']}/apply")
  348. assert response.status_code == 200, response.text
  349. # Spool 2 has both its preset and its K profile: nothing to fill.
  350. assert response.json() == {"updated": 1}
  351. assert (await _spoolman_rows(db_session, 1))[0] == [5]
  352. assert (await _spoolman_rows(db_session, 2))[0] == [11]
  353. assert (await _spoolman_rows(db_session, 3))[0] == []
  354. assert (await _spoolman_rows(db_session, 4))[0] == []
  355. # Only the spool without a preset of its own gets the default's.
  356. assert [c.args[0] for c in spoolman_client.merge_spool_extra.call_args_list] == [1]
  357. @pytest.mark.asyncio
  358. async def test_ams_auto_add_gets_k_profiles(self, async_client: AsyncClient, db_session, printers):
  359. from backend.app.services.filament_profile_default import fill_auto_added_spoolman_spool
  360. a, _ = printers
  361. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
  362. await fill_auto_added_spoolman_spool(_spoolman_spool(55))
  363. assert await _spoolman_rows(db_session, 55) == ([5], ["PFUS01"])
  364. @pytest.fixture
  365. def limited_to(request):
  366. """Run the request as a caller limited to some printers (#1727)."""
  367. from backend.app.core.auth import get_printer_scope_if_auth_enabled
  368. from backend.app.core.printer_scope import PrinterScope
  369. from backend.app.main import app
  370. def _limit(*printer_ids):
  371. app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(frozenset(printer_ids))
  372. yield _limit
  373. app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
  374. @pytest.mark.integration
  375. class TestPrinterScope:
  376. @pytest.mark.asyncio
  377. async def test_a_limited_caller_sees_only_its_printers(self, async_client: AsyncClient, printers, limited_to):
  378. a, b = printers
  379. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
  380. limited_to(b.id)
  381. listed = (await async_client.get(DEFAULTS)).json()[0]
  382. assert [k["printer_id"] for k in listed["k_profiles"]] == [b.id]
  383. resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body())).json()
  384. assert [k["printer_id"] for k in resolved["k_profiles"]] == [b.id]
  385. @pytest.mark.asyncio
  386. async def test_a_limited_save_keeps_the_printers_it_cannot_see(
  387. self, async_client: AsyncClient, printers, limited_to
  388. ):
  389. a, b = printers
  390. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
  391. limited_to(b.id)
  392. response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(b.id, cali_idx=8)]))
  393. assert response.status_code == 200, response.text
  394. limited_to(a.id, b.id)
  395. kept = (await async_client.get(DEFAULTS)).json()[0]
  396. assert {k["printer_id"]: k["cali_idx"] for k in kept["k_profiles"]} == {a.id: 5, b.id: 8}
  397. @pytest.mark.asyncio
  398. async def test_a_limited_caller_cannot_write_another_printer(self, async_client: AsyncClient, printers, limited_to):
  399. # The row is ignored, not refused: a form opened on a spool carries
  400. # every printer's profile, and saving it as the default must work.
  401. a, b = printers
  402. limited_to(b.id)
  403. response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
  404. assert response.status_code == 200, response.text
  405. limited_to(a.id, b.id)
  406. kept = (await async_client.get(DEFAULTS)).json()[0]
  407. assert [k["printer_id"] for k in kept["k_profiles"]] == [b.id]
  408. @pytest.mark.asyncio
  409. async def test_a_limited_caller_cannot_delete_another_printers_profiles(
  410. self, async_client: AsyncClient, printers, limited_to
  411. ):
  412. a, b = printers
  413. row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))).json()
  414. limited_to(b.id)
  415. assert (await async_client.delete(f"{DEFAULTS}/{row['id']}")).status_code == 403
  416. @pytest.mark.integration
  417. class TestInternalPresetFill:
  418. @pytest.mark.asyncio
  419. async def test_a_spool_keeps_its_own_preset_for_a_model(self, async_client: AsyncClient):
  420. spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
  421. await async_client.put(f"{INTERNAL}/{spool['id']}/filament-presets", json=[_preset("H2D", code="OWN01")])
  422. row = (
  423. await async_client.put(DEFAULTS, json=_default(presets=[_preset("H2D"), _preset("X1C", code="PFUS02")]))
  424. ).json()
  425. assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 1}
  426. presets = (await async_client.get(f"{INTERNAL}/{spool['id']}/filament-presets")).json()
  427. assert {p["printer_model"]: p["slicer_filament"] for p in presets} == {"H2D": "OWN01", "X1C": "PFUS02"}
  428. @pytest.mark.integration
  429. class TestNullKeyFields:
  430. @pytest.mark.asyncio
  431. async def test_null_brand_subtype_and_colour_read_as_empty(self, async_client: AsyncClient):
  432. response = await async_client.put(DEFAULTS, json=_default(brand=None, subtype=None, color_name=None))
  433. assert response.status_code == 200, response.text
  434. assert {k: response.json()[k] for k in ("brand", "subtype", "color_name")} == {
  435. "brand": "",
  436. "subtype": "",
  437. "color_name": "",
  438. }
  439. @pytest.mark.asyncio
  440. async def test_null_material_is_refused_not_a_500(self, async_client: AsyncClient):
  441. assert (await async_client.put(DEFAULTS, json=_default(material=None))).status_code == 422
  442. @pytest.mark.integration
  443. class TestReviewFixes:
  444. @pytest.mark.asyncio
  445. async def test_a_non_ascii_colour_matches_its_own_row(self, async_client: AsyncClient, printers):
  446. # SQLite's lower() folds ASCII only.
  447. a, _ = printers
  448. await async_client.put(DEFAULTS, json=_default("Красный", k_profiles=[_k(a.id, cali_idx=9)]))
  449. response = await async_client.put(DEFAULTS, json=_default("Красный", k_profiles=[_k(a.id, cali_idx=8)]))
  450. assert response.status_code == 200, response.text
  451. assert len((await async_client.get(DEFAULTS)).json()) == 1
  452. resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body(color_name="Красный"))).json()
  453. assert [k["cali_idx"] for k in resolved["k_profiles"]] == [8]
  454. @pytest.mark.asyncio
  455. async def test_a_limited_k_profile_save_keeps_the_other_printers(
  456. self, async_client: AsyncClient, printers, limited_to
  457. ):
  458. a, b = printers
  459. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
  460. spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
  461. limited_to(b.id)
  462. # The limited form only ever saw printer b.
  463. response = await async_client.put(f"{INTERNAL}/{spool['id']}/k-profiles", json=[_k(b.id, cali_idx=7)])
  464. assert response.status_code == 200, response.text
  465. limited_to(a.id, b.id)
  466. spool = (await async_client.get(f"{INTERNAL}/{spool['id']}")).json()
  467. assert {k["printer_id"]: k["cali_idx"] for k in spool["k_profiles"]} == {a.id: 5, b.id: 7}
  468. @pytest.mark.asyncio
  469. async def test_a_limited_spoolman_k_profile_save_keeps_the_other_printers(
  470. self, async_client: AsyncClient, db_session, printers, limited_to, spoolman_client
  471. ):
  472. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  473. a, b = printers
  474. spoolman_client.get_spool = AsyncMock(return_value=_spoolman_spool(9))
  475. db_session.add(SpoolmanKProfile(spoolman_spool_id=9, **_k(a.id)))
  476. db_session.add(SpoolmanKProfile(spoolman_spool_id=9, **_k(b.id)))
  477. await db_session.commit()
  478. limited_to(b.id)
  479. response = await async_client.put(f"{SPOOLMAN}/spools/9/k-profiles", json=[_k(b.id, cali_idx=7)])
  480. assert response.status_code == 200, response.text
  481. rows = (
  482. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 9)))
  483. .scalars()
  484. .all()
  485. )
  486. assert {r.printer_id: r.cali_idx for r in rows} == {a.id: 5, b.id: 7}
  487. @pytest.mark.asyncio
  488. @pytest.mark.parametrize("include_slicer", [True, False])
  489. async def test_an_auto_added_spoolman_spool_gets_the_preset_only_without_one_of_its_own(
  490. self, async_client: AsyncClient, include_slicer
  491. ):
  492. from backend.app.services.filament_profile_default import fill_auto_added_spoolman_spool
  493. await async_client.put(DEFAULTS, json=_default())
  494. client = MagicMock()
  495. client.ensure_extra_field = AsyncMock(return_value=True)
  496. client.merge_spool_extra = AsyncMock(return_value={})
  497. await fill_auto_added_spoolman_spool(_spoolman_spool(56), client, include_slicer=include_slicer)
  498. if include_slicer:
  499. client.merge_spool_extra.assert_awaited_once()
  500. assert client.merge_spool_extra.call_args.args[1]["bambu_slicer_filament"] == '"PFUS00"'
  501. else:
  502. client.merge_spool_extra.assert_not_awaited()
  503. @pytest.mark.asyncio
  504. async def test_apply_folds_non_ascii_case_the_same_way_as_the_lookup(self, async_client: AsyncClient, printers):
  505. a, _ = printers
  506. spool = (await async_client.post(INTERNAL, json=_spool_body(color_name="красный"))).json()
  507. row = (await async_client.put(DEFAULTS, json=_default("Красный", k_profiles=[_k(a.id, cali_idx=9)]))).json()
  508. await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
  509. assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 1}
  510. spool = (await async_client.get(f"{INTERNAL}/{spool['id']}")).json()
  511. assert [k["cali_idx"] for k in spool["k_profiles"]] == [9]