test_inventory_assign.py 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123
  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. """SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
  484. Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for
  485. unloaded slots — there's no filament context for the cali_idx to attach to.
  486. The endpoint persists the SpoolAssignment row with an empty fingerprint_type
  487. (the "pending config" marker) and skips the MQTT publish; on_ams_change
  488. re-fires the full configuration when filament is later inserted.
  489. """
  490. @pytest.mark.asyncio
  491. @pytest.mark.integration
  492. async def test_empty_slot_skips_mqtt_but_persists_assignment(
  493. self, async_client: AsyncClient, printer_factory, spool_factory
  494. ):
  495. """Assigning to an empty slot skips MQTT and returns pending_config=True."""
  496. printer = await printer_factory(name="H2D")
  497. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  498. mock_client = MagicMock()
  499. mock_client.ams_set_filament_setting.return_value = True
  500. mock_client.extrusion_cali_sel.return_value = True
  501. # Slot found but empty (tray_type=""): the SpoolBuddy scenario
  502. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
  503. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  504. mock_pm.get_client.return_value = mock_client
  505. mock_pm.get_status.return_value = status
  506. response = await async_client.post(
  507. "/api/v1/inventory/assignments",
  508. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  509. )
  510. assert response.status_code == 200
  511. body = response.json()
  512. assert body["pending_config"] is True
  513. assert body["configured"] is False
  514. # Critical: no MQTT was published (firmware would drop it)
  515. mock_client.ams_set_filament_setting.assert_not_called()
  516. mock_client.extrusion_cali_sel.assert_not_called()
  517. @pytest.mark.asyncio
  518. @pytest.mark.integration
  519. async def test_empty_slot_no_ams_data_skips_mqtt(self, async_client: AsyncClient, printer_factory, spool_factory):
  520. """No AMS data at all (printer offline, no telemetry yet) → still pre-config."""
  521. printer = await printer_factory(name="X1C")
  522. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  523. mock_client = MagicMock()
  524. # No AMS data — fingerprint_type stays None, treated as empty
  525. status = _make_mock_status(ams_data=[])
  526. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  527. mock_pm.get_client.return_value = mock_client
  528. mock_pm.get_status.return_value = status
  529. response = await async_client.post(
  530. "/api/v1/inventory/assignments",
  531. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  532. )
  533. assert response.status_code == 200
  534. assert response.json()["pending_config"] is True
  535. mock_client.ams_set_filament_setting.assert_not_called()
  536. @pytest.mark.asyncio
  537. @pytest.mark.integration
  538. async def test_loaded_slot_publishes_mqtt_immediately(
  539. self, async_client: AsyncClient, printer_factory, spool_factory
  540. ):
  541. """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
  542. printer = await printer_factory(name="X1C")
  543. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  544. mock_client = MagicMock()
  545. mock_client.ams_set_filament_setting.return_value = True
  546. mock_client.extrusion_cali_sel.return_value = True
  547. status = _make_mock_status(
  548. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
  549. )
  550. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  551. mock_pm.get_client.return_value = mock_client
  552. mock_pm.get_status.return_value = status
  553. response = await async_client.post(
  554. "/api/v1/inventory/assignments",
  555. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  556. )
  557. assert response.status_code == 200
  558. body = response.json()
  559. assert body["pending_config"] is False
  560. assert body["configured"] is True
  561. mock_client.ams_set_filament_setting.assert_called_once()
  562. @pytest.mark.asyncio
  563. @pytest.mark.integration
  564. async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
  565. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  566. ):
  567. """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
  568. from unittest.mock import AsyncMock
  569. from backend.app.main import on_ams_change
  570. from backend.app.models.spool_assignment import SpoolAssignment
  571. printer = await printer_factory(name="H2D")
  572. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  573. # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
  574. pre_assignment = SpoolAssignment(
  575. spool_id=spool.id,
  576. printer_id=printer.id,
  577. ams_id=2,
  578. tray_id=3,
  579. fingerprint_color=None,
  580. fingerprint_type=None,
  581. )
  582. db_session.add(pre_assignment)
  583. await db_session.commit()
  584. # Filament has now been physically inserted into the slot.
  585. # state=11 ("filament fed to extruder") is the load signal we trigger on.
  586. ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  587. mock_client = MagicMock()
  588. mock_client.ams_set_filament_setting.return_value = True
  589. mock_client.extrusion_cali_sel.return_value = True
  590. status = _make_mock_status(ams_data=ams_data)
  591. printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
  592. with (
  593. patch("backend.app.main.printer_manager") as mock_pm_main,
  594. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  595. patch("backend.app.main.mqtt_relay") as mock_relay,
  596. patch("backend.app.main.ws_manager") as mock_ws,
  597. ):
  598. mock_pm_main.get_printer.return_value = printer_info
  599. mock_pm_main.get_status.return_value = status
  600. mock_pm_main.get_client.return_value = mock_client
  601. mock_pm_main.get_model.return_value = "H2D"
  602. mock_pm_inv.get_client.return_value = mock_client
  603. mock_pm_inv.get_status.return_value = status
  604. mock_relay.on_ams_change = AsyncMock()
  605. mock_ws.send_printer_status = AsyncMock()
  606. mock_ws.broadcast = AsyncMock()
  607. await on_ams_change(printer.id, ams_data)
  608. # Full filament setting was published when the slot transitioned to loaded
  609. mock_client.ams_set_filament_setting.assert_called_once()
  610. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  611. assert call_kwargs["ams_id"] == 2
  612. assert call_kwargs["tray_id"] == 3
  613. assert call_kwargs["tray_info_idx"] == "GFL05"
  614. # Fingerprint was updated so the next push doesn't re-fire
  615. await db_session.refresh(pre_assignment)
  616. assert pre_assignment.fingerprint_type == "PLA"
  617. assert pre_assignment.fingerprint_color == "FF0000FF"
  618. @pytest.mark.asyncio
  619. @pytest.mark.integration
  620. async def test_on_ams_change_does_not_refire_for_already_configured_slot(
  621. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  622. ):
  623. """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
  624. from unittest.mock import AsyncMock
  625. from backend.app.main import on_ams_change
  626. from backend.app.models.spool_assignment import SpoolAssignment
  627. printer = await printer_factory(name="X1C")
  628. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  629. # Assignment already configured (fingerprint stamped)
  630. configured_assignment = SpoolAssignment(
  631. spool_id=spool.id,
  632. printer_id=printer.id,
  633. ams_id=0,
  634. tray_id=0,
  635. fingerprint_color="FF0000FF",
  636. fingerprint_type="PLA",
  637. )
  638. db_session.add(configured_assignment)
  639. await db_session.commit()
  640. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  641. mock_client = MagicMock()
  642. mock_client.ams_set_filament_setting.return_value = True
  643. mock_client.extrusion_cali_sel.return_value = True
  644. status = _make_mock_status(ams_data=ams_data)
  645. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  646. with (
  647. patch("backend.app.main.printer_manager") as mock_pm_main,
  648. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  649. patch("backend.app.main.mqtt_relay") as mock_relay,
  650. patch("backend.app.main.ws_manager") as mock_ws,
  651. ):
  652. mock_pm_main.get_printer.return_value = printer_info
  653. mock_pm_main.get_status.return_value = status
  654. mock_pm_main.get_client.return_value = mock_client
  655. mock_pm_main.get_model.return_value = "X1C"
  656. mock_pm_inv.get_client.return_value = mock_client
  657. mock_pm_inv.get_status.return_value = status
  658. mock_relay.on_ams_change = AsyncMock()
  659. mock_ws.send_printer_status = AsyncMock()
  660. mock_ws.broadcast = AsyncMock()
  661. await on_ams_change(printer.id, ams_data)
  662. # Fingerprint was already set — re-fire path skipped
  663. mock_client.ams_set_filament_setting.assert_not_called()
  664. @pytest.mark.asyncio
  665. @pytest.mark.integration
  666. async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
  667. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  668. ):
  669. """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
  670. (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
  671. configured the slot manually in Bambu Studio so tray_type went from ''
  672. to 'PLA' — but state stays at 3 because these firmwares never set it
  673. to 11. With state-only detection the replay never fired."""
  674. from unittest.mock import AsyncMock
  675. from backend.app.main import on_ams_change
  676. from backend.app.models.spool_assignment import SpoolAssignment
  677. printer = await printer_factory(name="A1 mini")
  678. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  679. pre_assignment = SpoolAssignment(
  680. spool_id=spool.id,
  681. printer_id=printer.id,
  682. ams_id=0,
  683. tray_id=3,
  684. fingerprint_color=None,
  685. fingerprint_type=None,
  686. )
  687. db_session.add(pre_assignment)
  688. await db_session.commit()
  689. # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
  690. # is now configured — the replay must fire on this transition too.
  691. ams_data = [
  692. {
  693. "id": 0,
  694. "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
  695. }
  696. ]
  697. mock_client = MagicMock()
  698. mock_client.ams_set_filament_setting.return_value = True
  699. mock_client.extrusion_cali_sel.return_value = True
  700. status = _make_mock_status(ams_data=ams_data)
  701. printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
  702. with (
  703. patch("backend.app.main.printer_manager") as mock_pm_main,
  704. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  705. patch("backend.app.main.mqtt_relay") as mock_relay,
  706. patch("backend.app.main.ws_manager") as mock_ws,
  707. ):
  708. mock_pm_main.get_printer.return_value = printer_info
  709. mock_pm_main.get_status.return_value = status
  710. mock_pm_main.get_client.return_value = mock_client
  711. mock_pm_main.get_model.return_value = "A1 mini"
  712. mock_pm_inv.get_client.return_value = mock_client
  713. mock_pm_inv.get_status.return_value = status
  714. mock_relay.on_ams_change = AsyncMock()
  715. mock_ws.send_printer_status = AsyncMock()
  716. mock_ws.broadcast = AsyncMock()
  717. await on_ams_change(printer.id, ams_data)
  718. # Replay fired despite state never being 11 — the disjunction picked
  719. # up tray_type going non-empty.
  720. mock_client.ams_set_filament_setting.assert_called_once()
  721. await db_session.refresh(pre_assignment)
  722. assert pre_assignment.fingerprint_type == "PLA"
  723. class TestAssignSpoolEmptyDetection:
  724. """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
  725. but filament not in feeder. The assign route must prefer that signal over
  726. tray_type for the empty-vs-loaded check, because a manual "Reset slot"
  727. clears tray_type to "" while leaving filament physically loaded — the
  728. legacy heuristic would route to the pending-config path and skip MQTT
  729. forever, since on_ams_change replay only fires on an empty→loaded
  730. transition that never comes when the slot is already loaded.
  731. """
  732. @pytest.mark.asyncio
  733. @pytest.mark.integration
  734. async def test_state_loaded_with_empty_tray_type_fires_mqtt(
  735. self, async_client: AsyncClient, printer_factory, spool_factory
  736. ):
  737. """Post-reset case: state=11 (loaded) but tray_type='' — MQTT must fire."""
  738. printer = await printer_factory()
  739. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  740. mock_client = MagicMock()
  741. mock_client.ams_set_filament_setting.return_value = True
  742. mock_client.extrusion_cali_sel.return_value = True
  743. # Simulates the "reset slot" aftermath: filament physically loaded
  744. # (state=11) but tray_type/tray_color/tray_info_idx have been cleared.
  745. tray_data = {"id": 3, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  746. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  747. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  748. mock_pm.get_client.return_value = mock_client
  749. mock_pm.get_status.return_value = status
  750. response = await async_client.post(
  751. "/api/v1/inventory/assignments",
  752. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  753. )
  754. assert response.status_code == 200
  755. # MQTT must have fired — the bug was that legacy detection saw the
  756. # empty tray_type and skipped this entirely.
  757. mock_client.ams_set_filament_setting.assert_called_once()
  758. # Response must report configured=True, pending_config=False — the
  759. # slot is loaded, just had stale metadata cleared.
  760. body = response.json()
  761. assert body["pending_config"] is False
  762. assert body["configured"] is True
  763. @pytest.mark.asyncio
  764. @pytest.mark.integration
  765. async def test_state_empty_skips_mqtt_and_marks_pending(
  766. self, async_client: AsyncClient, printer_factory, spool_factory
  767. ):
  768. """Genuinely empty slot: state=9 — MQTT skipped, pending_config=True."""
  769. printer = await printer_factory()
  770. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  771. mock_client = MagicMock()
  772. mock_client.ams_set_filament_setting.return_value = True
  773. tray_data = {"id": 3, "state": 9, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  774. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  775. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  776. mock_pm.get_client.return_value = mock_client
  777. mock_pm.get_status.return_value = status
  778. response = await async_client.post(
  779. "/api/v1/inventory/assignments",
  780. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  781. )
  782. assert response.status_code == 200
  783. # SpoolBuddy weigh-then-assign workflow: firmware drops MQTT for
  784. # unloaded slots, so we don't bother sending it.
  785. mock_client.ams_set_filament_setting.assert_not_called()
  786. body = response.json()
  787. assert body["pending_config"] is True
  788. assert body["configured"] is False
  789. @pytest.mark.asyncio
  790. @pytest.mark.integration
  791. async def test_state_missing_falls_back_to_tray_type_loaded(
  792. self, async_client: AsyncClient, printer_factory, spool_factory
  793. ):
  794. """Older firmware without state field: tray_type='PLA' → treated as loaded."""
  795. printer = await printer_factory()
  796. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  797. mock_client = MagicMock()
  798. mock_client.ams_set_filament_setting.return_value = True
  799. # No 'state' key at all — older firmware behaviour.
  800. tray_data = {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF"}
  801. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  802. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  803. mock_pm.get_client.return_value = mock_client
  804. mock_pm.get_status.return_value = status
  805. response = await async_client.post(
  806. "/api/v1/inventory/assignments",
  807. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  808. )
  809. assert response.status_code == 200
  810. # Legacy fallback: tray_type non-empty → treated as loaded → MQTT fires.
  811. mock_client.ams_set_filament_setting.assert_called_once()
  812. body = response.json()
  813. assert body["pending_config"] is False
  814. @pytest.mark.asyncio
  815. @pytest.mark.integration
  816. async def test_state_missing_falls_back_to_tray_type_empty(
  817. self, async_client: AsyncClient, printer_factory, spool_factory
  818. ):
  819. """Older firmware without state field + empty tray_type → pending."""
  820. printer = await printer_factory()
  821. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  822. mock_client = MagicMock()
  823. mock_client.ams_set_filament_setting.return_value = True
  824. tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
  825. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  826. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  827. mock_pm.get_client.return_value = mock_client
  828. mock_pm.get_status.return_value = status
  829. response = await async_client.post(
  830. "/api/v1/inventory/assignments",
  831. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  832. )
  833. assert response.status_code == 200
  834. # Legacy fallback: empty tray_type + no state → treated as empty.
  835. mock_client.ams_set_filament_setting.assert_not_called()
  836. body = response.json()
  837. assert body["pending_config"] is True
  838. @pytest.mark.asyncio
  839. @pytest.mark.integration
  840. async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
  841. self, async_client: AsyncClient, printer_factory, spool_factory
  842. ):
  843. """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
  844. report tray.state=3, never 11 — even for fully-loaded configured slots.
  845. A state-only check classified those as empty and skipped MQTT (#1322).
  846. With the disjunctive check, tray_type='PLA' alone is enough to fire."""
  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. mock_client.extrusion_cali_sel.return_value = True
  852. # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
  853. tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
  854. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  855. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  856. mock_pm.get_client.return_value = mock_client
  857. mock_pm.get_status.return_value = status
  858. response = await async_client.post(
  859. "/api/v1/inventory/assignments",
  860. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  861. )
  862. assert response.status_code == 200
  863. mock_client.ams_set_filament_setting.assert_called_once()
  864. body = response.json()
  865. assert body["pending_config"] is False
  866. assert body["configured"] is True
  867. @pytest.mark.asyncio
  868. @pytest.mark.integration
  869. async def test_state_never_eleven_firmware_with_empty_tray_marks_pending(
  870. self, async_client: AsyncClient, printer_factory, spool_factory
  871. ):
  872. """Same firmwares as above, but the slot is truly unconfigured
  873. (tray_type=''). Neither signal points to 'loaded', so this should
  874. still pending-config — the user has to configure or insert filament
  875. before MQTT can fire. Pins that the disjunction didn't accidentally
  876. flip empty slots into the loaded branch."""
  877. printer = await printer_factory()
  878. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  879. mock_client = MagicMock()
  880. mock_client.ams_set_filament_setting.return_value = True
  881. tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
  882. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  883. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  884. mock_pm.get_client.return_value = mock_client
  885. mock_pm.get_status.return_value = status
  886. response = await async_client.post(
  887. "/api/v1/inventory/assignments",
  888. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  889. )
  890. assert response.status_code == 200
  891. mock_client.ams_set_filament_setting.assert_not_called()
  892. body = response.json()
  893. assert body["pending_config"] is True
  894. @pytest.mark.asyncio
  895. @pytest.mark.integration
  896. async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(
  897. self, async_client: AsyncClient, printer_factory, spool_factory
  898. ):
  899. """External (vt_tray) slot post-reset: same fix applies for ams_id=255."""
  900. printer = await printer_factory(name="X1C")
  901. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  902. mock_client = MagicMock()
  903. mock_client.ams_set_filament_setting.return_value = True
  904. # External slot tray_id=0 → vt_tray id=254. state=11 (loaded), tray_type
  905. # cleared by reset.
  906. vt_data = [{"id": 254, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}]
  907. status = _make_mock_status(ams_data=[], vt_tray=vt_data)
  908. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  909. mock_pm.get_client.return_value = mock_client
  910. mock_pm.get_status.return_value = status
  911. response = await async_client.post(
  912. "/api/v1/inventory/assignments",
  913. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 255, "tray_id": 0},
  914. )
  915. assert response.status_code == 200
  916. mock_client.ams_set_filament_setting.assert_called_once()
  917. body = response.json()
  918. assert body["pending_config"] is False
  919. assert body["configured"] is True