test_inventory_assign.py 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360
  1. """Integration tests for inventory spool assignment — tray_info_idx resolution.
  2. Tests that the spool's own slicer_filament (including PFUS* cloud-synced
  3. custom presets) takes priority, with slot reuse and generic fallback as
  4. lower-priority fallbacks.
  5. """
  6. from unittest.mock import MagicMock, patch
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.models.spool import Spool
  11. @pytest.fixture
  12. async def spool_factory(db_session: AsyncSession):
  13. """Factory to create test spools."""
  14. _counter = [0]
  15. async def _create_spool(**kwargs):
  16. _counter[0] += 1
  17. defaults = {
  18. "material": "PLA",
  19. "subtype": "Basic",
  20. "brand": "Devil Design",
  21. "color_name": "Red",
  22. "rgba": "FF0000FF",
  23. "label_weight": 1000,
  24. "weight_used": 0,
  25. "slicer_filament": "PFUS9ac902733670a9",
  26. }
  27. defaults.update(kwargs)
  28. spool = Spool(**defaults)
  29. db_session.add(spool)
  30. await db_session.commit()
  31. await db_session.refresh(spool)
  32. return spool
  33. return _create_spool
  34. def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
  35. """Build a mock printer status with optional AMS/nozzle data."""
  36. status = MagicMock()
  37. raw = {}
  38. if ams_data is not None:
  39. raw["ams"] = {"ams": ams_data}
  40. if vt_tray is not None:
  41. raw["vt_tray"] = vt_tray
  42. status.raw_data = raw
  43. status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
  44. status.ams_extruder_map = ams_extruder_map
  45. return status
  46. class TestAssignSpoolTrayInfoIdx:
  47. """Tests for tray_info_idx resolution during spool assignment."""
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_pfus_slicer_filament_falls_back_to_generic(
  51. self, async_client: AsyncClient, printer_factory, spool_factory
  52. ):
  53. """PFUS* cloud setting_ids are rejected by the slicer as tray_info_idx, so the
  54. no-kp path falls back to the generic material id (PLA → GFL99). The K-profile
  55. realignment path translates PFUS → P-prefix when a stored kp exists; that's
  56. covered separately."""
  57. printer = await printer_factory(name="H2D")
  58. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  59. mock_client = MagicMock()
  60. mock_client.ams_set_filament_setting.return_value = True
  61. mock_client.extrusion_cali_sel.return_value = True
  62. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  63. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  64. mock_pm.get_client.return_value = mock_client
  65. mock_pm.get_status.return_value = status
  66. response = await async_client.post(
  67. "/api/v1/inventory/assignments",
  68. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  69. )
  70. assert response.status_code == 200
  71. call_kwargs = mock_client.ams_set_filament_setting.call_args
  72. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  73. @pytest.mark.asyncio
  74. @pytest.mark.integration
  75. async def test_pfus_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
  76. """When the spool's PFUS gets discarded as slicer-invalid, the slot's existing
  77. valid P-prefix preset is reused if it matches the spool's material — preserves
  78. the printer's calibration context rather than resetting to generic."""
  79. printer = await printer_factory(name="H2D")
  80. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  81. mock_client = MagicMock()
  82. mock_client.ams_set_filament_setting.return_value = True
  83. mock_client.extrusion_cali_sel.return_value = True
  84. # Slot already configured by slicer with cloud-synced preset
  85. status = _make_mock_status(
  86. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  87. )
  88. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  89. mock_pm.get_client.return_value = mock_client
  90. mock_pm.get_status.return_value = status
  91. response = await async_client.post(
  92. "/api/v1/inventory/assignments",
  93. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  94. )
  95. assert response.status_code == 200
  96. call_kwargs = mock_client.ams_set_filament_setting.call_args
  97. assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_spool_preset_used_even_if_different_material_on_slot(
  101. self, async_client: AsyncClient, printer_factory, spool_factory
  102. ):
  103. """Spool's material drives the fallback generic id. Slot's existing PLA preset
  104. is overridden because the spool is PETG → GFG99."""
  105. printer = await printer_factory(name="H2D")
  106. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
  107. mock_client = MagicMock()
  108. mock_client.ams_set_filament_setting.return_value = True
  109. mock_client.extrusion_cali_sel.return_value = True
  110. # Slot currently has PLA but spool is PETG
  111. status = _make_mock_status(
  112. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  113. )
  114. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  115. mock_pm.get_client.return_value = mock_client
  116. mock_pm.get_status.return_value = status
  117. response = await async_client.post(
  118. "/api/v1/inventory/assignments",
  119. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  120. )
  121. assert response.status_code == 200
  122. call_kwargs = mock_client.ams_set_filament_setting.call_args
  123. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  124. @pytest.mark.asyncio
  125. @pytest.mark.integration
  126. async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
  127. """Standard GF* IDs from spool.slicer_filament are used directly."""
  128. printer = await printer_factory(name="X1C")
  129. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  130. mock_client = MagicMock()
  131. mock_client.ams_set_filament_setting.return_value = True
  132. mock_client.extrusion_cali_sel.return_value = True
  133. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  134. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  135. mock_pm.get_client.return_value = mock_client
  136. mock_pm.get_status.return_value = status
  137. response = await async_client.post(
  138. "/api/v1/inventory/assignments",
  139. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  140. )
  141. assert response.status_code == 200
  142. call_kwargs = mock_client.ams_set_filament_setting.call_args
  143. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
  147. """Spool with no slicer_filament gets a generic ID from material type."""
  148. printer = await printer_factory(name="X1C")
  149. spool = await spool_factory(slicer_filament=None, material="ABS")
  150. mock_client = MagicMock()
  151. mock_client.ams_set_filament_setting.return_value = True
  152. mock_client.extrusion_cali_sel.return_value = True
  153. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "ABS"}]}])
  154. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  155. mock_pm.get_client.return_value = mock_client
  156. mock_pm.get_status.return_value = status
  157. response = await async_client.post(
  158. "/api/v1/inventory/assignments",
  159. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  160. )
  161. assert response.status_code == 200
  162. call_kwargs = mock_client.ams_set_filament_setting.call_args
  163. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_spool_pfus_falls_back_to_generic_over_slot_pfus(
  167. self, async_client: AsyncClient, printer_factory, spool_factory
  168. ):
  169. """Both spool and slot have PFUS values — both rejected as tray_info_idx —
  170. falls back to generic material id (PLA → GFL99)."""
  171. printer = await printer_factory(name="H2D")
  172. spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
  173. mock_client = MagicMock()
  174. mock_client.ams_set_filament_setting.return_value = True
  175. mock_client.extrusion_cali_sel.return_value = True
  176. # Slot has a PFUS* ID from some previous config
  177. status = _make_mock_status(
  178. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
  179. )
  180. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  181. mock_pm.get_client.return_value = mock_client
  182. mock_pm.get_status.return_value = status
  183. response = await async_client.post(
  184. "/api/v1/inventory/assignments",
  185. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  186. )
  187. assert response.status_code == 200
  188. call_kwargs = mock_client.ams_set_filament_setting.call_args
  189. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_generic_on_slot_falls_back_to_material_generic(
  193. self, async_client: AsyncClient, printer_factory, spool_factory
  194. ):
  195. """When spool's PFUS is discarded and slot only has a generic ID, the result
  196. comes from the spool's material (ABS → GFB99) — not from the slot. Important
  197. because the generic-id check (`not in _generic_id_values`) prevents stale
  198. generic reuse and routes the decision through the material fallback."""
  199. printer = await printer_factory(name="P2S")
  200. spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
  201. mock_client = MagicMock()
  202. mock_client.ams_set_filament_setting.return_value = True
  203. mock_client.extrusion_cali_sel.return_value = True
  204. # Slot stuck on generic ABS from a previous assignment
  205. status = _make_mock_status(
  206. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  207. )
  208. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  209. mock_pm.get_client.return_value = mock_client
  210. mock_pm.get_status.return_value = status
  211. response = await async_client.post(
  212. "/api/v1/inventory/assignments",
  213. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  214. )
  215. assert response.status_code == 200
  216. call_kwargs = mock_client.ams_set_filament_setting.call_args
  217. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_no_preset_with_generic_on_slot_still_uses_generic(
  221. self, async_client: AsyncClient, printer_factory, spool_factory
  222. ):
  223. """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
  224. printer = await printer_factory(name="P2S")
  225. spool = await spool_factory(slicer_filament=None, material="ABS")
  226. mock_client = MagicMock()
  227. mock_client.ams_set_filament_setting.return_value = True
  228. mock_client.extrusion_cali_sel.return_value = True
  229. # Slot has generic ABS
  230. status = _make_mock_status(
  231. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  232. )
  233. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  234. mock_pm.get_client.return_value = mock_client
  235. mock_pm.get_status.return_value = status
  236. response = await async_client.post(
  237. "/api/v1/inventory/assignments",
  238. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  239. )
  240. assert response.status_code == 200
  241. call_kwargs = mock_client.ams_set_filament_setting.call_args
  242. # Still gets generic, but via fallback — not via sticky reuse
  243. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_no_preset_reuses_specific_slot_preset(
  247. self, async_client: AsyncClient, printer_factory, spool_factory
  248. ):
  249. """Spool without preset + specific preset on slot → reuse slot's preset."""
  250. printer = await printer_factory(name="X1C")
  251. spool = await spool_factory(slicer_filament=None, material="PLA")
  252. mock_client = MagicMock()
  253. mock_client.ams_set_filament_setting.return_value = True
  254. mock_client.extrusion_cali_sel.return_value = True
  255. # Slot has a specific Bambu PLA preset (not generic)
  256. status = _make_mock_status(
  257. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
  258. )
  259. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  260. mock_pm.get_client.return_value = mock_client
  261. mock_pm.get_status.return_value = status
  262. response = await async_client.post(
  263. "/api/v1/inventory/assignments",
  264. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  265. )
  266. assert response.status_code == 200
  267. call_kwargs = mock_client.ams_set_filament_setting.call_args
  268. # Slot's specific preset is reused when spool has no own preset
  269. assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
  270. class TestAssignSpoolPresetMapping:
  271. """Tests that assign_spool saves the slot preset mapping for correct UI display."""
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_preset_mapping_saved_with_slicer_filament_name(
  275. self, async_client: AsyncClient, printer_factory, spool_factory
  276. ):
  277. """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
  278. printer = await printer_factory(name="X1C")
  279. spool = await spool_factory(
  280. slicer_filament="GFA05",
  281. slicer_filament_name="Bambu PLA Silk",
  282. material="PLA",
  283. subtype="Silk",
  284. brand="Bambu",
  285. )
  286. mock_client = MagicMock()
  287. mock_client.ams_set_filament_setting.return_value = True
  288. mock_client.extrusion_cali_sel.return_value = True
  289. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
  290. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  291. mock_pm.get_client.return_value = mock_client
  292. mock_pm.get_status.return_value = status
  293. response = await async_client.post(
  294. "/api/v1/inventory/assignments",
  295. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  296. )
  297. assert response.status_code == 200
  298. # Verify via the slot presets API
  299. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  300. assert presets_resp.status_code == 200
  301. presets = presets_resp.json()
  302. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
  303. assert "1" in presets
  304. # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
  305. assert presets["1"]["preset_name"] == "Bambu PLA Silk"
  306. assert presets["1"]["preset_id"] == "GFSA05"
  307. @pytest.mark.asyncio
  308. @pytest.mark.integration
  309. async def test_preset_mapping_overwrites_old_mapping(
  310. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  311. ):
  312. """Assigning a new spool overwrites the old slot preset mapping."""
  313. from backend.app.models.slot_preset import SlotPresetMapping
  314. printer = await printer_factory(name="X1C")
  315. # Pre-existing mapping (e.g. from previous manual configuration)
  316. old_mapping = SlotPresetMapping(
  317. printer_id=printer.id,
  318. ams_id=0,
  319. tray_id=2,
  320. preset_id="GFSA01",
  321. preset_name="Bambu PLA Matte",
  322. preset_source="cloud",
  323. )
  324. db_session.add(old_mapping)
  325. await db_session.commit()
  326. # Assign a "Generic PLA Silk" spool to same slot
  327. spool = await spool_factory(
  328. slicer_filament="GFL96",
  329. slicer_filament_name="Generic PLA Silk",
  330. material="PLA",
  331. subtype="Silk",
  332. )
  333. mock_client = MagicMock()
  334. mock_client.ams_set_filament_setting.return_value = True
  335. mock_client.extrusion_cali_sel.return_value = True
  336. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
  337. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  338. mock_pm.get_client.return_value = mock_client
  339. mock_pm.get_status.return_value = status
  340. response = await async_client.post(
  341. "/api/v1/inventory/assignments",
  342. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  343. )
  344. assert response.status_code == 200
  345. # Verify via the slot presets API to avoid stale session cache
  346. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  347. assert presets_resp.status_code == 200
  348. presets = presets_resp.json()
  349. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
  350. assert "2" in presets
  351. # Old "Bambu PLA Matte" must be overwritten
  352. assert presets["2"]["preset_name"] == "Generic PLA Silk"
  353. assert presets["2"]["preset_id"] == "GFSL96"
  354. @pytest.mark.asyncio
  355. @pytest.mark.integration
  356. async def test_preset_mapping_fallback_to_tray_sub_brands(
  357. self, async_client: AsyncClient, printer_factory, spool_factory
  358. ):
  359. """When slicer_filament_name is null, falls back to tray_sub_brands."""
  360. from backend.app.models.slot_preset import SlotPresetMapping
  361. printer = await printer_factory(name="A1M")
  362. spool = await spool_factory(
  363. slicer_filament="GFL05",
  364. slicer_filament_name=None,
  365. material="PLA",
  366. subtype="Matte",
  367. brand="Overture",
  368. )
  369. mock_client = MagicMock()
  370. mock_client.ams_set_filament_setting.return_value = True
  371. mock_client.extrusion_cali_sel.return_value = True
  372. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  373. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  374. mock_pm.get_client.return_value = mock_client
  375. mock_pm.get_status.return_value = status
  376. response = await async_client.post(
  377. "/api/v1/inventory/assignments",
  378. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  379. )
  380. assert response.status_code == 200
  381. # Verify via the slot presets API
  382. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  383. assert presets_resp.status_code == 200
  384. presets = presets_resp.json()
  385. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
  386. assert "0" in presets
  387. # Falls back to tray_sub_brands ("Overture PLA Matte")
  388. assert presets["0"]["preset_name"] == "Overture PLA Matte"
  389. class TestAssignSpoolLiveCaliIdx:
  390. """assign_spool always resets the slot to Default K when the spool has no stored K-profile."""
  391. @pytest.mark.asyncio
  392. @pytest.mark.integration
  393. async def test_no_kprofile_resets_to_default_k(self, async_client: AsyncClient, printer_factory, spool_factory):
  394. """When no KProfile row exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
  395. printer = await printer_factory()
  396. spool = await spool_factory()
  397. mock_client = MagicMock()
  398. mock_client.ams_set_filament_setting.return_value = True
  399. mock_client.extrusion_cali_sel.return_value = True
  400. # Live cali_idx=42 belongs to whatever filament was previously calibrated
  401. # in this slot. Applying it to a different spool would use the wrong K
  402. # value, so the assign flow must override it with Default K (-1).
  403. tray_data = {
  404. "id": 1,
  405. "cali_idx": 42,
  406. "tray_color": "FF0000FF",
  407. "tray_type": "PLA",
  408. "tray_sub_brands": "PLA Basic",
  409. "tray_id_name": "GFL99",
  410. }
  411. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  412. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  413. mock_pm.get_client.return_value = mock_client
  414. mock_pm.get_status.return_value = status
  415. response = await async_client.post(
  416. "/api/v1/inventory/assignments",
  417. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  418. )
  419. assert response.status_code == 200
  420. mock_client.extrusion_cali_sel.assert_called_once()
  421. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_no_kprofile_no_live_cali_idx_sends_default(
  425. self, async_client: AsyncClient, printer_factory, spool_factory
  426. ):
  427. """When tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
  428. printer = await printer_factory()
  429. spool = await spool_factory()
  430. mock_client = MagicMock()
  431. mock_client.ams_set_filament_setting.return_value = True
  432. mock_client.extrusion_cali_sel.return_value = True
  433. tray_data = {
  434. "id": 0,
  435. "cali_idx": None,
  436. "tray_color": "FF0000FF",
  437. "tray_type": "PLA",
  438. "tray_sub_brands": "PLA Basic",
  439. "tray_id_name": "GFL99",
  440. }
  441. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  442. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  443. mock_pm.get_client.return_value = mock_client
  444. mock_pm.get_status.return_value = status
  445. response = await async_client.post(
  446. "/api/v1/inventory/assignments",
  447. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  448. )
  449. assert response.status_code == 200
  450. mock_client.extrusion_cali_sel.assert_called_once()
  451. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  452. @pytest.mark.asyncio
  453. @pytest.mark.integration
  454. async def test_negative_live_cali_idx_sends_default(
  455. self, async_client: AsyncClient, printer_factory, spool_factory
  456. ):
  457. """A negative live cali_idx (-1) falls through and is sent as Default (cali_idx=-1)."""
  458. printer = await printer_factory()
  459. spool = await spool_factory()
  460. mock_client = MagicMock()
  461. mock_client.ams_set_filament_setting.return_value = True
  462. mock_client.extrusion_cali_sel.return_value = True
  463. tray_data = {
  464. "id": 0,
  465. "cali_idx": -1,
  466. "tray_color": "FF0000FF",
  467. "tray_type": "PLA",
  468. "tray_sub_brands": "PLA Basic",
  469. "tray_id_name": "GFL99",
  470. }
  471. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  472. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  473. mock_pm.get_client.return_value = mock_client
  474. mock_pm.get_status.return_value = status
  475. response = await async_client.post(
  476. "/api/v1/inventory/assignments",
  477. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  478. )
  479. assert response.status_code == 200
  480. mock_client.extrusion_cali_sel.assert_called_once()
  481. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  482. class TestAssignSpoolEmptySlotPreConfig:
  483. """Assign path under ambiguous / explicit-empty AMS state.
  484. Updated for the #1322 follow-up: only the firmware's *explicit* empty
  485. signal (state ∈ {9, 10}) skips MQTT. Anything else — including the
  486. SpoolBuddy weigh-then-assign-before-insert case where state/tray_type
  487. can't tell us whether a spool is loaded — attempts MQTT. The deferred-
  488. config workflow still works because on_ams_change at main.py:1031-1054
  489. re-fires when an AMS push eventually reports the loaded slot.
  490. """
  491. @pytest.mark.asyncio
  492. @pytest.mark.integration
  493. async def test_empty_tray_type_without_state_still_fires_mqtt(
  494. self, async_client: AsyncClient, printer_factory, spool_factory
  495. ):
  496. """tray_type='' with no state field: AMS can't tell us whether a
  497. spool is loaded. Trust the user's Assign click and fire MQTT —
  498. firmware accepts it when a spool is physically there, drops it
  499. silently otherwise (no harm)."""
  500. printer = await printer_factory(name="H2D")
  501. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  502. mock_client = MagicMock()
  503. mock_client.ams_set_filament_setting.return_value = True
  504. mock_client.extrusion_cali_sel.return_value = True
  505. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
  506. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  507. mock_pm.get_client.return_value = mock_client
  508. mock_pm.get_status.return_value = status
  509. response = await async_client.post(
  510. "/api/v1/inventory/assignments",
  511. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  512. )
  513. assert response.status_code == 200
  514. mock_client.ams_set_filament_setting.assert_called_once()
  515. body = response.json()
  516. assert body["pending_config"] is False
  517. assert body["configured"] is True
  518. @pytest.mark.asyncio
  519. @pytest.mark.integration
  520. async def test_no_ams_data_with_no_client_marks_pending(
  521. self, async_client: AsyncClient, printer_factory, spool_factory
  522. ):
  523. """No AMS data + no MQTT client (printer offline, no telemetry):
  524. publish can't happen, so configured=False and pending_config=True so
  525. on_ams_change replay picks it up when the printer comes online."""
  526. printer = await printer_factory(name="X1C")
  527. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  528. # No AMS data — fingerprint_type stays None.
  529. status = _make_mock_status(ams_data=[])
  530. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  531. mock_pm.get_client.return_value = None # Printer offline, no MQTT client.
  532. mock_pm.get_status.return_value = status
  533. response = await async_client.post(
  534. "/api/v1/inventory/assignments",
  535. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  536. )
  537. assert response.status_code == 200
  538. body = response.json()
  539. assert body["pending_config"] is True
  540. assert body["configured"] is False
  541. @pytest.mark.asyncio
  542. @pytest.mark.integration
  543. async def test_loaded_slot_publishes_mqtt_immediately(
  544. self, async_client: AsyncClient, printer_factory, spool_factory
  545. ):
  546. """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
  547. printer = await printer_factory(name="X1C")
  548. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  549. mock_client = MagicMock()
  550. mock_client.ams_set_filament_setting.return_value = True
  551. mock_client.extrusion_cali_sel.return_value = True
  552. status = _make_mock_status(
  553. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
  554. )
  555. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  556. mock_pm.get_client.return_value = mock_client
  557. mock_pm.get_status.return_value = status
  558. response = await async_client.post(
  559. "/api/v1/inventory/assignments",
  560. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  561. )
  562. assert response.status_code == 200
  563. body = response.json()
  564. assert body["pending_config"] is False
  565. assert body["configured"] is True
  566. mock_client.ams_set_filament_setting.assert_called_once()
  567. @pytest.mark.asyncio
  568. @pytest.mark.integration
  569. async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
  570. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  571. ):
  572. """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
  573. from unittest.mock import AsyncMock
  574. from backend.app.main import on_ams_change
  575. from backend.app.models.spool_assignment import SpoolAssignment
  576. printer = await printer_factory(name="H2D")
  577. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  578. # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
  579. pre_assignment = SpoolAssignment(
  580. spool_id=spool.id,
  581. printer_id=printer.id,
  582. ams_id=2,
  583. tray_id=3,
  584. fingerprint_color=None,
  585. fingerprint_type=None,
  586. )
  587. db_session.add(pre_assignment)
  588. await db_session.commit()
  589. # Filament has now been physically inserted into the slot.
  590. # state=11 ("filament fed to extruder") is the load signal we trigger on.
  591. ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  592. mock_client = MagicMock()
  593. mock_client.ams_set_filament_setting.return_value = True
  594. mock_client.extrusion_cali_sel.return_value = True
  595. status = _make_mock_status(ams_data=ams_data)
  596. printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
  597. with (
  598. patch("backend.app.main.printer_manager") as mock_pm_main,
  599. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  600. patch("backend.app.main.mqtt_relay") as mock_relay,
  601. patch("backend.app.main.ws_manager") as mock_ws,
  602. ):
  603. mock_pm_main.get_printer.return_value = printer_info
  604. mock_pm_main.get_status.return_value = status
  605. mock_pm_main.get_client.return_value = mock_client
  606. mock_pm_main.get_model.return_value = "H2D"
  607. mock_pm_inv.get_client.return_value = mock_client
  608. mock_pm_inv.get_status.return_value = status
  609. mock_relay.on_ams_change = AsyncMock()
  610. mock_ws.send_printer_status = AsyncMock()
  611. mock_ws.broadcast = AsyncMock()
  612. await on_ams_change(printer.id, ams_data)
  613. # Full filament setting was published when the slot transitioned to loaded
  614. mock_client.ams_set_filament_setting.assert_called_once()
  615. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  616. assert call_kwargs["ams_id"] == 2
  617. assert call_kwargs["tray_id"] == 3
  618. assert call_kwargs["tray_info_idx"] == "GFL05"
  619. # Fingerprint was updated so the next push doesn't re-fire
  620. await db_session.refresh(pre_assignment)
  621. assert pre_assignment.fingerprint_type == "PLA"
  622. assert pre_assignment.fingerprint_color == "FF0000FF"
  623. @pytest.mark.asyncio
  624. @pytest.mark.integration
  625. async def test_on_ams_change_does_not_refire_for_already_configured_slot(
  626. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  627. ):
  628. """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
  629. from unittest.mock import AsyncMock
  630. from backend.app.main import on_ams_change
  631. from backend.app.models.spool_assignment import SpoolAssignment
  632. printer = await printer_factory(name="X1C")
  633. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  634. # Assignment already configured (fingerprint stamped)
  635. configured_assignment = SpoolAssignment(
  636. spool_id=spool.id,
  637. printer_id=printer.id,
  638. ams_id=0,
  639. tray_id=0,
  640. fingerprint_color="FF0000FF",
  641. fingerprint_type="PLA",
  642. )
  643. db_session.add(configured_assignment)
  644. await db_session.commit()
  645. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  646. mock_client = MagicMock()
  647. mock_client.ams_set_filament_setting.return_value = True
  648. mock_client.extrusion_cali_sel.return_value = True
  649. status = _make_mock_status(ams_data=ams_data)
  650. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  651. with (
  652. patch("backend.app.main.printer_manager") as mock_pm_main,
  653. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  654. patch("backend.app.main.mqtt_relay") as mock_relay,
  655. patch("backend.app.main.ws_manager") as mock_ws,
  656. ):
  657. mock_pm_main.get_printer.return_value = printer_info
  658. mock_pm_main.get_status.return_value = status
  659. mock_pm_main.get_client.return_value = mock_client
  660. mock_pm_main.get_model.return_value = "X1C"
  661. mock_pm_inv.get_client.return_value = mock_client
  662. mock_pm_inv.get_status.return_value = status
  663. mock_relay.on_ams_change = AsyncMock()
  664. mock_ws.send_printer_status = AsyncMock()
  665. mock_ws.broadcast = AsyncMock()
  666. await on_ams_change(printer.id, ams_data)
  667. # Fingerprint was already set — re-fire path skipped
  668. mock_client.ams_set_filament_setting.assert_not_called()
  669. @pytest.mark.asyncio
  670. @pytest.mark.integration
  671. async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
  672. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  673. ):
  674. """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
  675. (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
  676. configured the slot manually in Bambu Studio so tray_type went from ''
  677. to 'PLA' — but state stays at 3 because these firmwares never set it
  678. to 11. With state-only detection the replay never fired."""
  679. from unittest.mock import AsyncMock
  680. from backend.app.main import on_ams_change
  681. from backend.app.models.spool_assignment import SpoolAssignment
  682. printer = await printer_factory(name="A1 mini")
  683. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  684. pre_assignment = SpoolAssignment(
  685. spool_id=spool.id,
  686. printer_id=printer.id,
  687. ams_id=0,
  688. tray_id=3,
  689. fingerprint_color=None,
  690. fingerprint_type=None,
  691. )
  692. db_session.add(pre_assignment)
  693. await db_session.commit()
  694. # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
  695. # is now configured — the replay must fire on this transition too.
  696. ams_data = [
  697. {
  698. "id": 0,
  699. "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
  700. }
  701. ]
  702. mock_client = MagicMock()
  703. mock_client.ams_set_filament_setting.return_value = True
  704. mock_client.extrusion_cali_sel.return_value = True
  705. status = _make_mock_status(ams_data=ams_data)
  706. printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
  707. with (
  708. patch("backend.app.main.printer_manager") as mock_pm_main,
  709. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  710. patch("backend.app.main.mqtt_relay") as mock_relay,
  711. patch("backend.app.main.ws_manager") as mock_ws,
  712. ):
  713. mock_pm_main.get_printer.return_value = printer_info
  714. mock_pm_main.get_status.return_value = status
  715. mock_pm_main.get_client.return_value = mock_client
  716. mock_pm_main.get_model.return_value = "A1 mini"
  717. mock_pm_inv.get_client.return_value = mock_client
  718. mock_pm_inv.get_status.return_value = status
  719. mock_relay.on_ams_change = AsyncMock()
  720. mock_ws.send_printer_status = AsyncMock()
  721. mock_ws.broadcast = AsyncMock()
  722. await on_ams_change(printer.id, ams_data)
  723. # Replay fired despite state never being 11 — the disjunction picked
  724. # up tray_type going non-empty.
  725. mock_client.ams_set_filament_setting.assert_called_once()
  726. await db_session.refresh(pre_assignment)
  727. assert pre_assignment.fingerprint_type == "PLA"
  728. @pytest.mark.asyncio
  729. @pytest.mark.integration
  730. async def test_auto_unlink_broadcasts_assignment_change(
  731. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  732. ):
  733. """#2575 follow-up: when on_ams_change auto-unlinks a stale external-spool
  734. assignment, it must broadcast spool_assignment_changed. Only the manual
  735. REST endpoints did, so open browsers kept rendering the unlinked spool —
  736. the reporter read that as "the fix didn't work" when the DB was correct."""
  737. from unittest.mock import AsyncMock
  738. from sqlalchemy import select
  739. from backend.app.main import on_ams_change
  740. from backend.app.models.spool_assignment import SpoolAssignment
  741. printer = await printer_factory(name="X1C")
  742. spool = await spool_factory(slicer_filament="GFU01", material="TPU")
  743. # TPU inventory spool assigned to the external slot (ams_id=255, tray 0)
  744. assignment = SpoolAssignment(
  745. spool_id=spool.id,
  746. printer_id=printer.id,
  747. ams_id=255,
  748. tray_id=0,
  749. fingerprint_color="000000FF",
  750. fingerprint_type="TPU",
  751. )
  752. db_session.add(assignment)
  753. await db_session.commit()
  754. # The printer's external spool now reports ABS — the assignment is stale.
  755. vt_tray = [
  756. {
  757. "id": "254",
  758. "tray_type": "ABS",
  759. "tray_color": "000000FF",
  760. "tag_uid": "0000000000000000",
  761. "tray_uuid": "00000000000000000000000000000000",
  762. }
  763. ]
  764. status = _make_mock_status(ams_data=[], vt_tray=vt_tray)
  765. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  766. with (
  767. patch("backend.app.main.printer_manager") as mock_pm_main,
  768. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  769. patch("backend.app.main.mqtt_relay") as mock_relay,
  770. patch("backend.app.main.ws_manager") as mock_ws,
  771. ):
  772. mock_pm_main.get_printer.return_value = printer_info
  773. mock_pm_main.get_status.return_value = status
  774. mock_pm_main.get_client.return_value = MagicMock()
  775. mock_pm_main.get_model.return_value = "X1C"
  776. mock_pm_inv.get_client.return_value = MagicMock()
  777. mock_pm_inv.get_status.return_value = status
  778. mock_relay.on_ams_change = AsyncMock()
  779. mock_ws.send_printer_status = AsyncMock()
  780. mock_ws.broadcast = AsyncMock()
  781. await on_ams_change(printer.id, [])
  782. # The stale TPU assignment on the now-ABS external slot was unlinked...
  783. gone = await db_session.execute(
  784. select(SpoolAssignment).where(
  785. SpoolAssignment.printer_id == printer.id,
  786. SpoolAssignment.ams_id == 255,
  787. SpoolAssignment.tray_id == 0,
  788. )
  789. )
  790. assert gone.scalar_one_or_none() is None
  791. # ...and the frontend was told about it.
  792. change_events = [
  793. c.args[0]
  794. for c in mock_ws.broadcast.await_args_list
  795. if c.args and isinstance(c.args[0], dict) and c.args[0].get("type") == "spool_assignment_changed"
  796. ]
  797. assert change_events, "auto-unlink must broadcast spool_assignment_changed"
  798. assert change_events[0]["printer_id"] == printer.id
  799. assert change_events[0]["ams_id"] == 255
  800. assert change_events[0]["tray_id"] == 0
  801. class TestAssignSpoolEmptyDetection:
  802. """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
  803. but filament not in feeder. The assign route must prefer that signal over
  804. tray_type for the empty-vs-loaded check, because a manual "Reset slot"
  805. clears tray_type to "" while leaving filament physically loaded — the
  806. legacy heuristic would route to the pending-config path and skip MQTT
  807. forever, since on_ams_change replay only fires on an empty→loaded
  808. transition that never comes when the slot is already loaded.
  809. """
  810. @pytest.mark.asyncio
  811. @pytest.mark.integration
  812. async def test_state_loaded_with_empty_tray_type_fires_mqtt(
  813. self, async_client: AsyncClient, printer_factory, spool_factory
  814. ):
  815. """Post-reset case: state=11 (loaded) but tray_type='' — MQTT must fire."""
  816. printer = await printer_factory()
  817. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  818. mock_client = MagicMock()
  819. mock_client.ams_set_filament_setting.return_value = True
  820. mock_client.extrusion_cali_sel.return_value = True
  821. # Simulates the "reset slot" aftermath: filament physically loaded
  822. # (state=11) but tray_type/tray_color/tray_info_idx have been cleared.
  823. tray_data = {"id": 3, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  824. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  825. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  826. mock_pm.get_client.return_value = mock_client
  827. mock_pm.get_status.return_value = status
  828. response = await async_client.post(
  829. "/api/v1/inventory/assignments",
  830. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  831. )
  832. assert response.status_code == 200
  833. # MQTT must have fired — the bug was that legacy detection saw the
  834. # empty tray_type and skipped this entirely.
  835. mock_client.ams_set_filament_setting.assert_called_once()
  836. # Response must report configured=True, pending_config=False — the
  837. # slot is loaded, just had stale metadata cleared.
  838. body = response.json()
  839. assert body["pending_config"] is False
  840. assert body["configured"] is True
  841. @pytest.mark.asyncio
  842. @pytest.mark.integration
  843. async def test_state_empty_skips_mqtt_and_marks_pending(
  844. self, async_client: AsyncClient, printer_factory, spool_factory
  845. ):
  846. """Genuinely empty slot: state=9 — MQTT skipped, pending_config=True."""
  847. printer = await printer_factory()
  848. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  849. mock_client = MagicMock()
  850. mock_client.ams_set_filament_setting.return_value = True
  851. tray_data = {"id": 3, "state": 9, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  852. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  853. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  854. mock_pm.get_client.return_value = mock_client
  855. mock_pm.get_status.return_value = status
  856. response = await async_client.post(
  857. "/api/v1/inventory/assignments",
  858. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  859. )
  860. assert response.status_code == 200
  861. # SpoolBuddy weigh-then-assign workflow: firmware drops MQTT for
  862. # unloaded slots, so we don't bother sending it.
  863. mock_client.ams_set_filament_setting.assert_not_called()
  864. body = response.json()
  865. assert body["pending_config"] is True
  866. assert body["configured"] is False
  867. @pytest.mark.asyncio
  868. @pytest.mark.integration
  869. async def test_state_missing_falls_back_to_tray_type_loaded(
  870. self, async_client: AsyncClient, printer_factory, spool_factory
  871. ):
  872. """Older firmware without state field: tray_type='PLA' → treated as loaded."""
  873. printer = await printer_factory()
  874. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  875. mock_client = MagicMock()
  876. mock_client.ams_set_filament_setting.return_value = True
  877. # No 'state' key at all — older firmware behaviour.
  878. tray_data = {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF"}
  879. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  880. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  881. mock_pm.get_client.return_value = mock_client
  882. mock_pm.get_status.return_value = status
  883. response = await async_client.post(
  884. "/api/v1/inventory/assignments",
  885. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  886. )
  887. assert response.status_code == 200
  888. # Legacy fallback: tray_type non-empty → treated as loaded → MQTT fires.
  889. mock_client.ams_set_filament_setting.assert_called_once()
  890. body = response.json()
  891. assert body["pending_config"] is False
  892. @pytest.mark.asyncio
  893. @pytest.mark.integration
  894. async def test_state_missing_with_empty_tray_type_still_fires_mqtt(
  895. self, async_client: AsyncClient, printer_factory, spool_factory
  896. ):
  897. """Older firmware without state field + empty tray_type still fires MQTT.
  898. The AMS doesn't tell us whether a spool is physically loaded in this
  899. case (no state, no tray_type), so the assign click is the user's
  900. assertion that a spool is there. Firmware silently drops the push on
  901. a truly empty slot — no harm done, and on_ams_change replay handles
  902. the deferred-config case (#1322 follow-up).
  903. """
  904. printer = await printer_factory()
  905. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  906. mock_client = MagicMock()
  907. mock_client.ams_set_filament_setting.return_value = True
  908. mock_client.extrusion_cali_sel.return_value = True
  909. tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
  910. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  911. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  912. mock_pm.get_client.return_value = mock_client
  913. mock_pm.get_status.return_value = status
  914. response = await async_client.post(
  915. "/api/v1/inventory/assignments",
  916. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  917. )
  918. assert response.status_code == 200
  919. mock_client.ams_set_filament_setting.assert_called_once()
  920. body = response.json()
  921. assert body["pending_config"] is False
  922. assert body["configured"] is True
  923. @pytest.mark.asyncio
  924. @pytest.mark.integration
  925. async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
  926. self, async_client: AsyncClient, printer_factory, spool_factory
  927. ):
  928. """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
  929. report tray.state=3, never 11 — even for fully-loaded configured slots.
  930. A state-only check classified those as empty and skipped MQTT (#1322).
  931. With the disjunctive check, tray_type='PLA' alone is enough to fire."""
  932. printer = await printer_factory()
  933. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  934. mock_client = MagicMock()
  935. mock_client.ams_set_filament_setting.return_value = True
  936. mock_client.extrusion_cali_sel.return_value = True
  937. # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
  938. tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
  939. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  940. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  941. mock_pm.get_client.return_value = mock_client
  942. mock_pm.get_status.return_value = status
  943. response = await async_client.post(
  944. "/api/v1/inventory/assignments",
  945. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  946. )
  947. assert response.status_code == 200
  948. mock_client.ams_set_filament_setting.assert_called_once()
  949. body = response.json()
  950. assert body["pending_config"] is False
  951. assert body["configured"] is True
  952. @pytest.mark.asyncio
  953. @pytest.mark.integration
  954. async def test_post_reset_slot_with_state_3_still_fires_mqtt(
  955. self, async_client: AsyncClient, printer_factory, spool_factory
  956. ):
  957. """A1 Mini BMCU / P1S Standard AMS post-"Reset Slot" with spool still
  958. inserted: state=3, tray_type="". The AMS gives us no signal to tell
  959. this apart from a truly-empty slot. We trust the user's Assign click
  960. and fire MQTT — firmware accepts the push because a spool is
  961. physically there (#1322 follow-up by @RosdasHH).
  962. Replaces the previous "marks_pending" assertion which was the bug:
  963. that gate created a deadlock because the AMS would never report a
  964. state change (nothing physically changed), so on_ams_change replay
  965. never re-fired the deferred config either.
  966. """
  967. printer = await printer_factory()
  968. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  969. mock_client = MagicMock()
  970. mock_client.ams_set_filament_setting.return_value = True
  971. mock_client.extrusion_cali_sel.return_value = True
  972. tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
  973. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  974. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  975. mock_pm.get_client.return_value = mock_client
  976. mock_pm.get_status.return_value = status
  977. response = await async_client.post(
  978. "/api/v1/inventory/assignments",
  979. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  980. )
  981. assert response.status_code == 200
  982. mock_client.ams_set_filament_setting.assert_called_once()
  983. body = response.json()
  984. assert body["pending_config"] is False
  985. assert body["configured"] is True
  986. @pytest.mark.asyncio
  987. @pytest.mark.integration
  988. async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(
  989. self, async_client: AsyncClient, printer_factory, spool_factory
  990. ):
  991. """External (vt_tray) slot post-reset: same fix applies for ams_id=255."""
  992. printer = await printer_factory(name="X1C")
  993. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  994. mock_client = MagicMock()
  995. mock_client.ams_set_filament_setting.return_value = True
  996. # External slot tray_id=0 → vt_tray id=254. state=11 (loaded), tray_type
  997. # cleared by reset.
  998. vt_data = [{"id": 254, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}]
  999. status = _make_mock_status(ams_data=[], vt_tray=vt_data)
  1000. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1001. mock_pm.get_client.return_value = mock_client
  1002. mock_pm.get_status.return_value = status
  1003. response = await async_client.post(
  1004. "/api/v1/inventory/assignments",
  1005. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 255, "tray_id": 0},
  1006. )
  1007. assert response.status_code == 200
  1008. mock_client.ams_set_filament_setting.assert_called_once()
  1009. body = response.json()
  1010. assert body["pending_config"] is False
  1011. assert body["configured"] is True
  1012. class TestAssignSpoolPfcnCloudPreset:
  1013. """Assign path for PFCN-prefix cloud presets (#1648).
  1014. PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
  1015. and GFS (Bambu official) — used for cloud-shared / partner-uploaded
  1016. presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
  1017. the assign path skipped the cloud-detail lookup and left the raw PFCN
  1018. string in tray_info_idx, which the printer's calibration table can't
  1019. resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
  1020. itself, making "Configure" feel like a mandatory follow-up step.
  1021. """
  1022. @pytest.mark.asyncio
  1023. @pytest.mark.integration
  1024. async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
  1025. self, async_client: AsyncClient, printer_factory, spool_factory
  1026. ):
  1027. """When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
  1028. the raw PFCN must be discarded as slicer-invalid and the slot configures
  1029. with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
  1030. was to leak the raw PFCN, which the slicer can't resolve."""
  1031. printer = await printer_factory(name="H2D")
  1032. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1033. mock_client = MagicMock()
  1034. mock_client.ams_set_filament_setting.return_value = True
  1035. mock_client.extrusion_cali_sel.return_value = True
  1036. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  1037. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1038. mock_pm.get_client.return_value = mock_client
  1039. mock_pm.get_status.return_value = status
  1040. response = await async_client.post(
  1041. "/api/v1/inventory/assignments",
  1042. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1043. )
  1044. assert response.status_code == 200
  1045. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1046. # PFCN never leaks into tray_info_idx — must resolve to the
  1047. # generic-material fallback when cloud lookup couldn't.
  1048. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  1049. assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
  1050. @pytest.mark.asyncio
  1051. @pytest.mark.integration
  1052. async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
  1053. """Symmetry with the PFUS case: when the spool's PFCN is discarded as
  1054. slicer-invalid, the slot's existing valid P-prefix preset is reused
  1055. if material matches — preserves calibration context instead of
  1056. resetting to generic."""
  1057. printer = await printer_factory(name="H2D")
  1058. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1059. mock_client = MagicMock()
  1060. mock_client.ams_set_filament_setting.return_value = True
  1061. mock_client.extrusion_cali_sel.return_value = True
  1062. status = _make_mock_status(
  1063. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  1064. )
  1065. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1066. mock_pm.get_client.return_value = mock_client
  1067. mock_pm.get_status.return_value = status
  1068. response = await async_client.post(
  1069. "/api/v1/inventory/assignments",
  1070. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1071. )
  1072. assert response.status_code == 200
  1073. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1074. assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
  1075. @pytest.mark.asyncio
  1076. @pytest.mark.integration
  1077. async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
  1078. self, async_client: AsyncClient, printer_factory, spool_factory
  1079. ):
  1080. """When the user is authenticated against Bambu Cloud, the PFCN setting_id
  1081. triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
  1082. filament_id from `detail["filament_id"]` and ships that as
  1083. tray_info_idx. This is the happy path the Configure modal already had
  1084. but the assign path didn't, #1648."""
  1085. printer = await printer_factory(name="H2D")
  1086. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1087. mock_client = MagicMock()
  1088. mock_client.ams_set_filament_setting.return_value = True
  1089. mock_client.extrusion_cali_sel.return_value = True
  1090. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  1091. # Cloud responds with a real filament_id for the PFCN preset — exactly
  1092. # what the Configure modal already exploits.
  1093. mock_cloud = MagicMock()
  1094. mock_cloud.is_authenticated = True
  1095. async def fake_get_detail(setting_id):
  1096. assert setting_id == "PFCN80e80c1f79db85"
  1097. return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
  1098. async def fake_close():
  1099. return None
  1100. mock_cloud.get_setting_detail = fake_get_detail
  1101. mock_cloud.close = fake_close
  1102. async def fake_build_cloud(_db, _user):
  1103. return mock_cloud
  1104. with (
  1105. patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
  1106. patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
  1107. ):
  1108. mock_pm.get_client.return_value = mock_client
  1109. mock_pm.get_status.return_value = status
  1110. response = await async_client.post(
  1111. "/api/v1/inventory/assignments",
  1112. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1113. )
  1114. assert response.status_code == 200
  1115. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1116. # tray_info_idx is the resolved cloud filament_id; setting_id is the
  1117. # original PFCN (which the slicer needs separately).
  1118. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  1119. assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"