test_inventory_assign.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  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_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):
  51. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  52. printer = await printer_factory(name="H2D")
  53. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  54. mock_client = MagicMock()
  55. mock_client.ams_set_filament_setting.return_value = True
  56. mock_client.extrusion_cali_sel.return_value = True
  57. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  58. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  59. mock_pm.get_client.return_value = mock_client
  60. mock_pm.get_status.return_value = status
  61. response = await async_client.post(
  62. "/api/v1/inventory/assignments",
  63. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  64. )
  65. assert response.status_code == 200
  66. call_kwargs = mock_client.ams_set_filament_setting.call_args
  67. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_spool_preset_takes_priority_over_slot(
  71. self, async_client: AsyncClient, printer_factory, spool_factory
  72. ):
  73. """Spool's own slicer_filament takes priority over slot's existing preset."""
  74. printer = await printer_factory(name="H2D")
  75. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  76. mock_client = MagicMock()
  77. mock_client.ams_set_filament_setting.return_value = True
  78. mock_client.extrusion_cali_sel.return_value = True
  79. # Slot already configured by slicer with cloud-synced preset
  80. status = _make_mock_status(
  81. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  82. )
  83. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  84. mock_pm.get_client.return_value = mock_client
  85. mock_pm.get_status.return_value = status
  86. response = await async_client.post(
  87. "/api/v1/inventory/assignments",
  88. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  89. )
  90. assert response.status_code == 200
  91. call_kwargs = mock_client.ams_set_filament_setting.call_args
  92. # Spool's own preset wins over slot's existing one
  93. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_spool_preset_used_even_if_different_material_on_slot(
  97. self, async_client: AsyncClient, printer_factory, spool_factory
  98. ):
  99. """Spool's own slicer_filament is used regardless of what's on the slot."""
  100. printer = await printer_factory(name="H2D")
  101. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
  102. mock_client = MagicMock()
  103. mock_client.ams_set_filament_setting.return_value = True
  104. mock_client.extrusion_cali_sel.return_value = True
  105. # Slot currently has PLA but spool is PETG
  106. status = _make_mock_status(
  107. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  108. )
  109. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  110. mock_pm.get_client.return_value = mock_client
  111. mock_pm.get_status.return_value = status
  112. response = await async_client.post(
  113. "/api/v1/inventory/assignments",
  114. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  115. )
  116. assert response.status_code == 200
  117. call_kwargs = mock_client.ams_set_filament_setting.call_args
  118. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
  122. """Standard GF* IDs from spool.slicer_filament are used directly."""
  123. printer = await printer_factory(name="X1C")
  124. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  125. mock_client = MagicMock()
  126. mock_client.ams_set_filament_setting.return_value = True
  127. mock_client.extrusion_cali_sel.return_value = True
  128. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  129. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  130. mock_pm.get_client.return_value = mock_client
  131. mock_pm.get_status.return_value = status
  132. response = await async_client.post(
  133. "/api/v1/inventory/assignments",
  134. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  135. )
  136. assert response.status_code == 200
  137. call_kwargs = mock_client.ams_set_filament_setting.call_args
  138. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
  142. """Spool with no slicer_filament gets a generic ID from material type."""
  143. printer = await printer_factory(name="X1C")
  144. spool = await spool_factory(slicer_filament=None, material="ABS")
  145. mock_client = MagicMock()
  146. mock_client.ams_set_filament_setting.return_value = True
  147. mock_client.extrusion_cali_sel.return_value = True
  148. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "ABS"}]}])
  149. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  150. mock_pm.get_client.return_value = mock_client
  151. mock_pm.get_status.return_value = status
  152. response = await async_client.post(
  153. "/api/v1/inventory/assignments",
  154. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  155. )
  156. assert response.status_code == 200
  157. call_kwargs = mock_client.ams_set_filament_setting.call_args
  158. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):
  162. """Spool's own PFUS preset is used even when slot has a different PFUS."""
  163. printer = await printer_factory(name="H2D")
  164. spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
  165. mock_client = MagicMock()
  166. mock_client.ams_set_filament_setting.return_value = True
  167. mock_client.extrusion_cali_sel.return_value = True
  168. # Slot has a PFUS* ID from some previous config
  169. status = _make_mock_status(
  170. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
  171. )
  172. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  173. mock_pm.get_client.return_value = mock_client
  174. mock_pm.get_status.return_value = status
  175. response = await async_client.post(
  176. "/api/v1/inventory/assignments",
  177. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  178. )
  179. assert response.status_code == 200
  180. call_kwargs = mock_client.ams_set_filament_setting.call_args
  181. # Spool's own preset wins
  182. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS1111111111"
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_generic_on_slot_not_reused_over_spool_preset(
  186. self, async_client: AsyncClient, printer_factory, spool_factory
  187. ):
  188. """Generic ID on slot (e.g. GFB99) must not override spool's own preset."""
  189. printer = await printer_factory(name="P2S")
  190. spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
  191. mock_client = MagicMock()
  192. mock_client.ams_set_filament_setting.return_value = True
  193. mock_client.extrusion_cali_sel.return_value = True
  194. # Slot stuck on generic ABS from a previous assignment
  195. status = _make_mock_status(
  196. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  197. )
  198. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  199. mock_pm.get_client.return_value = mock_client
  200. mock_pm.get_status.return_value = status
  201. response = await async_client.post(
  202. "/api/v1/inventory/assignments",
  203. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  204. )
  205. assert response.status_code == 200
  206. call_kwargs = mock_client.ams_set_filament_setting.call_args
  207. # Spool's preset wins — generic on slot must not be sticky
  208. assert call_kwargs.kwargs["tray_info_idx"] == "PFUScda4c46fc9031"
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_no_preset_with_generic_on_slot_still_uses_generic(
  212. self, async_client: AsyncClient, printer_factory, spool_factory
  213. ):
  214. """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
  215. printer = await printer_factory(name="P2S")
  216. spool = await spool_factory(slicer_filament=None, material="ABS")
  217. mock_client = MagicMock()
  218. mock_client.ams_set_filament_setting.return_value = True
  219. mock_client.extrusion_cali_sel.return_value = True
  220. # Slot has generic ABS
  221. status = _make_mock_status(
  222. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  223. )
  224. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  225. mock_pm.get_client.return_value = mock_client
  226. mock_pm.get_status.return_value = status
  227. response = await async_client.post(
  228. "/api/v1/inventory/assignments",
  229. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  230. )
  231. assert response.status_code == 200
  232. call_kwargs = mock_client.ams_set_filament_setting.call_args
  233. # Still gets generic, but via fallback — not via sticky reuse
  234. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_no_preset_reuses_specific_slot_preset(
  238. self, async_client: AsyncClient, printer_factory, spool_factory
  239. ):
  240. """Spool without preset + specific preset on slot → reuse slot's preset."""
  241. printer = await printer_factory(name="X1C")
  242. spool = await spool_factory(slicer_filament=None, material="PLA")
  243. mock_client = MagicMock()
  244. mock_client.ams_set_filament_setting.return_value = True
  245. mock_client.extrusion_cali_sel.return_value = True
  246. # Slot has a specific Bambu PLA preset (not generic)
  247. status = _make_mock_status(
  248. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
  249. )
  250. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  251. mock_pm.get_client.return_value = mock_client
  252. mock_pm.get_status.return_value = status
  253. response = await async_client.post(
  254. "/api/v1/inventory/assignments",
  255. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  256. )
  257. assert response.status_code == 200
  258. call_kwargs = mock_client.ams_set_filament_setting.call_args
  259. # Slot's specific preset is reused when spool has no own preset
  260. assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
  261. class TestAssignSpoolPresetMapping:
  262. """Tests that assign_spool saves the slot preset mapping for correct UI display."""
  263. @pytest.mark.asyncio
  264. @pytest.mark.integration
  265. async def test_preset_mapping_saved_with_slicer_filament_name(
  266. self, async_client: AsyncClient, printer_factory, spool_factory
  267. ):
  268. """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
  269. printer = await printer_factory(name="X1C")
  270. spool = await spool_factory(
  271. slicer_filament="GFA05",
  272. slicer_filament_name="Bambu PLA Silk",
  273. material="PLA",
  274. subtype="Silk",
  275. brand="Bambu",
  276. )
  277. mock_client = MagicMock()
  278. mock_client.ams_set_filament_setting.return_value = True
  279. mock_client.extrusion_cali_sel.return_value = True
  280. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
  281. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  282. mock_pm.get_client.return_value = mock_client
  283. mock_pm.get_status.return_value = status
  284. response = await async_client.post(
  285. "/api/v1/inventory/assignments",
  286. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  287. )
  288. assert response.status_code == 200
  289. # Verify via the slot presets API
  290. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  291. assert presets_resp.status_code == 200
  292. presets = presets_resp.json()
  293. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
  294. assert "1" in presets
  295. # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
  296. assert presets["1"]["preset_name"] == "Bambu PLA Silk"
  297. assert presets["1"]["preset_id"] == "GFSA05"
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_preset_mapping_overwrites_old_mapping(
  301. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  302. ):
  303. """Assigning a new spool overwrites the old slot preset mapping."""
  304. from backend.app.models.slot_preset import SlotPresetMapping
  305. printer = await printer_factory(name="X1C")
  306. # Pre-existing mapping (e.g. from previous manual configuration)
  307. old_mapping = SlotPresetMapping(
  308. printer_id=printer.id,
  309. ams_id=0,
  310. tray_id=2,
  311. preset_id="GFSA01",
  312. preset_name="Bambu PLA Matte",
  313. preset_source="cloud",
  314. )
  315. db_session.add(old_mapping)
  316. await db_session.commit()
  317. # Assign a "Generic PLA Silk" spool to same slot
  318. spool = await spool_factory(
  319. slicer_filament="GFL96",
  320. slicer_filament_name="Generic PLA Silk",
  321. material="PLA",
  322. subtype="Silk",
  323. )
  324. mock_client = MagicMock()
  325. mock_client.ams_set_filament_setting.return_value = True
  326. mock_client.extrusion_cali_sel.return_value = True
  327. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
  328. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  329. mock_pm.get_client.return_value = mock_client
  330. mock_pm.get_status.return_value = status
  331. response = await async_client.post(
  332. "/api/v1/inventory/assignments",
  333. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  334. )
  335. assert response.status_code == 200
  336. # Verify via the slot presets API to avoid stale session cache
  337. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  338. assert presets_resp.status_code == 200
  339. presets = presets_resp.json()
  340. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
  341. assert "2" in presets
  342. # Old "Bambu PLA Matte" must be overwritten
  343. assert presets["2"]["preset_name"] == "Generic PLA Silk"
  344. assert presets["2"]["preset_id"] == "GFSL96"
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_preset_mapping_fallback_to_tray_sub_brands(
  348. self, async_client: AsyncClient, printer_factory, spool_factory
  349. ):
  350. """When slicer_filament_name is null, falls back to tray_sub_brands."""
  351. from backend.app.models.slot_preset import SlotPresetMapping
  352. printer = await printer_factory(name="A1M")
  353. spool = await spool_factory(
  354. slicer_filament="GFL05",
  355. slicer_filament_name=None,
  356. material="PLA",
  357. subtype="Matte",
  358. brand="Overture",
  359. )
  360. mock_client = MagicMock()
  361. mock_client.ams_set_filament_setting.return_value = True
  362. mock_client.extrusion_cali_sel.return_value = True
  363. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  364. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  365. mock_pm.get_client.return_value = mock_client
  366. mock_pm.get_status.return_value = status
  367. response = await async_client.post(
  368. "/api/v1/inventory/assignments",
  369. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  370. )
  371. assert response.status_code == 200
  372. # Verify via the slot presets API
  373. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  374. assert presets_resp.status_code == 200
  375. presets = presets_resp.json()
  376. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
  377. assert "0" in presets
  378. # Falls back to tray_sub_brands ("Overture PLA Matte")
  379. assert presets["0"]["preset_name"] == "Overture PLA Matte"
  380. class TestAssignSpoolLiveCaliIdx:
  381. """P9-TEST-BE-3: assign_spool falls back to live tray cali_idx when no K-profile stored."""
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_no_kprofile_uses_live_cali_idx(self, async_client: AsyncClient, printer_factory, spool_factory):
  385. """When no KProfile row exists, live tray cali_idx is sent via extrusion_cali_sel."""
  386. printer = await printer_factory()
  387. spool = await spool_factory()
  388. mock_client = MagicMock()
  389. mock_client.ams_set_filament_setting.return_value = True
  390. mock_client.extrusion_cali_sel.return_value = True
  391. tray_data = {
  392. "id": 1,
  393. "cali_idx": 42,
  394. "tray_color": "FF0000FF",
  395. "tray_type": "PLA",
  396. "tray_sub_brands": "PLA Basic",
  397. "tray_id_name": "GFL99",
  398. }
  399. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  400. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  401. mock_pm.get_client.return_value = mock_client
  402. mock_pm.get_status.return_value = status
  403. response = await async_client.post(
  404. "/api/v1/inventory/assignments",
  405. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  406. )
  407. assert response.status_code == 200
  408. mock_client.extrusion_cali_sel.assert_called_once()
  409. call_kwargs = mock_client.extrusion_cali_sel.call_args[1]
  410. assert call_kwargs["cali_idx"] == 42
  411. @pytest.mark.asyncio
  412. @pytest.mark.integration
  413. async def test_no_kprofile_no_live_cali_idx_nothing_sent(
  414. self, async_client: AsyncClient, printer_factory, spool_factory
  415. ):
  416. """When tray has no cali_idx, extrusion_cali_sel is not called."""
  417. printer = await printer_factory()
  418. spool = await spool_factory()
  419. mock_client = MagicMock()
  420. mock_client.ams_set_filament_setting.return_value = True
  421. mock_client.extrusion_cali_sel.return_value = True
  422. tray_data = {
  423. "id": 0,
  424. "cali_idx": None,
  425. "tray_color": "FF0000FF",
  426. "tray_type": "PLA",
  427. "tray_sub_brands": "PLA Basic",
  428. "tray_id_name": "GFL99",
  429. }
  430. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  431. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  432. mock_pm.get_client.return_value = mock_client
  433. mock_pm.get_status.return_value = status
  434. response = await async_client.post(
  435. "/api/v1/inventory/assignments",
  436. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  437. )
  438. assert response.status_code == 200
  439. mock_client.extrusion_cali_sel.assert_not_called()
  440. @pytest.mark.asyncio
  441. @pytest.mark.integration
  442. async def test_negative_live_cali_idx_not_sent(self, async_client: AsyncClient, printer_factory, spool_factory):
  443. """A negative live cali_idx (-1) is invalid and must not be sent."""
  444. printer = await printer_factory()
  445. spool = await spool_factory()
  446. mock_client = MagicMock()
  447. mock_client.ams_set_filament_setting.return_value = True
  448. mock_client.extrusion_cali_sel.return_value = True
  449. tray_data = {
  450. "id": 0,
  451. "cali_idx": -1,
  452. "tray_color": "FF0000FF",
  453. "tray_type": "PLA",
  454. "tray_sub_brands": "PLA Basic",
  455. "tray_id_name": "GFL99",
  456. }
  457. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  458. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  459. mock_pm.get_client.return_value = mock_client
  460. mock_pm.get_status.return_value = status
  461. response = await async_client.post(
  462. "/api/v1/inventory/assignments",
  463. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  464. )
  465. assert response.status_code == 200
  466. mock_client.extrusion_cali_sel.assert_not_called()
  467. class TestAssignSpoolEmptySlotPreConfig:
  468. """SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
  469. Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for
  470. unloaded slots — there's no filament context for the cali_idx to attach to.
  471. The endpoint persists the SpoolAssignment row with an empty fingerprint_type
  472. (the "pending config" marker) and skips the MQTT publish; on_ams_change
  473. re-fires the full configuration when filament is later inserted.
  474. """
  475. @pytest.mark.asyncio
  476. @pytest.mark.integration
  477. async def test_empty_slot_skips_mqtt_but_persists_assignment(
  478. self, async_client: AsyncClient, printer_factory, spool_factory
  479. ):
  480. """Assigning to an empty slot skips MQTT and returns pending_config=True."""
  481. printer = await printer_factory(name="H2D")
  482. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  483. mock_client = MagicMock()
  484. mock_client.ams_set_filament_setting.return_value = True
  485. mock_client.extrusion_cali_sel.return_value = True
  486. # Slot found but empty (tray_type=""): the SpoolBuddy scenario
  487. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
  488. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  489. mock_pm.get_client.return_value = mock_client
  490. mock_pm.get_status.return_value = status
  491. response = await async_client.post(
  492. "/api/v1/inventory/assignments",
  493. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  494. )
  495. assert response.status_code == 200
  496. body = response.json()
  497. assert body["pending_config"] is True
  498. assert body["configured"] is False
  499. # Critical: no MQTT was published (firmware would drop it)
  500. mock_client.ams_set_filament_setting.assert_not_called()
  501. mock_client.extrusion_cali_sel.assert_not_called()
  502. @pytest.mark.asyncio
  503. @pytest.mark.integration
  504. async def test_empty_slot_no_ams_data_skips_mqtt(self, async_client: AsyncClient, printer_factory, spool_factory):
  505. """No AMS data at all (printer offline, no telemetry yet) → still pre-config."""
  506. printer = await printer_factory(name="X1C")
  507. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  508. mock_client = MagicMock()
  509. # No AMS data — fingerprint_type stays None, treated as empty
  510. status = _make_mock_status(ams_data=[])
  511. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  512. mock_pm.get_client.return_value = mock_client
  513. mock_pm.get_status.return_value = status
  514. response = await async_client.post(
  515. "/api/v1/inventory/assignments",
  516. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  517. )
  518. assert response.status_code == 200
  519. assert response.json()["pending_config"] is True
  520. mock_client.ams_set_filament_setting.assert_not_called()
  521. @pytest.mark.asyncio
  522. @pytest.mark.integration
  523. async def test_loaded_slot_publishes_mqtt_immediately(
  524. self, async_client: AsyncClient, printer_factory, spool_factory
  525. ):
  526. """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
  527. printer = await printer_factory(name="X1C")
  528. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  529. mock_client = MagicMock()
  530. mock_client.ams_set_filament_setting.return_value = True
  531. mock_client.extrusion_cali_sel.return_value = True
  532. status = _make_mock_status(
  533. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
  534. )
  535. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  536. mock_pm.get_client.return_value = mock_client
  537. mock_pm.get_status.return_value = status
  538. response = await async_client.post(
  539. "/api/v1/inventory/assignments",
  540. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  541. )
  542. assert response.status_code == 200
  543. body = response.json()
  544. assert body["pending_config"] is False
  545. assert body["configured"] is True
  546. mock_client.ams_set_filament_setting.assert_called_once()
  547. @pytest.mark.asyncio
  548. @pytest.mark.integration
  549. async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
  550. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  551. ):
  552. """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
  553. from unittest.mock import AsyncMock
  554. from backend.app.main import on_ams_change
  555. from backend.app.models.spool_assignment import SpoolAssignment
  556. printer = await printer_factory(name="H2D")
  557. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  558. # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
  559. pre_assignment = SpoolAssignment(
  560. spool_id=spool.id,
  561. printer_id=printer.id,
  562. ams_id=2,
  563. tray_id=3,
  564. fingerprint_color=None,
  565. fingerprint_type=None,
  566. )
  567. db_session.add(pre_assignment)
  568. await db_session.commit()
  569. # Filament has now been physically inserted into the slot.
  570. # state=11 ("filament fed to extruder") is the load signal we trigger on.
  571. ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  572. mock_client = MagicMock()
  573. mock_client.ams_set_filament_setting.return_value = True
  574. mock_client.extrusion_cali_sel.return_value = True
  575. status = _make_mock_status(ams_data=ams_data)
  576. printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
  577. with (
  578. patch("backend.app.main.printer_manager") as mock_pm_main,
  579. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  580. patch("backend.app.main.mqtt_relay") as mock_relay,
  581. patch("backend.app.main.ws_manager") as mock_ws,
  582. ):
  583. mock_pm_main.get_printer.return_value = printer_info
  584. mock_pm_main.get_status.return_value = status
  585. mock_pm_main.get_client.return_value = mock_client
  586. mock_pm_main.get_model.return_value = "H2D"
  587. mock_pm_inv.get_client.return_value = mock_client
  588. mock_pm_inv.get_status.return_value = status
  589. mock_relay.on_ams_change = AsyncMock()
  590. mock_ws.send_printer_status = AsyncMock()
  591. mock_ws.broadcast = AsyncMock()
  592. await on_ams_change(printer.id, ams_data)
  593. # Full filament setting was published when the slot transitioned to loaded
  594. mock_client.ams_set_filament_setting.assert_called_once()
  595. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  596. assert call_kwargs["ams_id"] == 2
  597. assert call_kwargs["tray_id"] == 3
  598. assert call_kwargs["tray_info_idx"] == "GFL05"
  599. # Fingerprint was updated so the next push doesn't re-fire
  600. await db_session.refresh(pre_assignment)
  601. assert pre_assignment.fingerprint_type == "PLA"
  602. assert pre_assignment.fingerprint_color == "FF0000FF"
  603. @pytest.mark.asyncio
  604. @pytest.mark.integration
  605. async def test_on_ams_change_does_not_refire_for_already_configured_slot(
  606. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  607. ):
  608. """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
  609. from unittest.mock import AsyncMock
  610. from backend.app.main import on_ams_change
  611. from backend.app.models.spool_assignment import SpoolAssignment
  612. printer = await printer_factory(name="X1C")
  613. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  614. # Assignment already configured (fingerprint stamped)
  615. configured_assignment = SpoolAssignment(
  616. spool_id=spool.id,
  617. printer_id=printer.id,
  618. ams_id=0,
  619. tray_id=0,
  620. fingerprint_color="FF0000FF",
  621. fingerprint_type="PLA",
  622. )
  623. db_session.add(configured_assignment)
  624. await db_session.commit()
  625. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  626. mock_client = MagicMock()
  627. mock_client.ams_set_filament_setting.return_value = True
  628. mock_client.extrusion_cali_sel.return_value = True
  629. status = _make_mock_status(ams_data=ams_data)
  630. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  631. with (
  632. patch("backend.app.main.printer_manager") as mock_pm_main,
  633. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  634. patch("backend.app.main.mqtt_relay") as mock_relay,
  635. patch("backend.app.main.ws_manager") as mock_ws,
  636. ):
  637. mock_pm_main.get_printer.return_value = printer_info
  638. mock_pm_main.get_status.return_value = status
  639. mock_pm_main.get_client.return_value = mock_client
  640. mock_pm_main.get_model.return_value = "X1C"
  641. mock_pm_inv.get_client.return_value = mock_client
  642. mock_pm_inv.get_status.return_value = status
  643. mock_relay.on_ams_change = AsyncMock()
  644. mock_ws.send_printer_status = AsyncMock()
  645. mock_ws.broadcast = AsyncMock()
  646. await on_ams_change(printer.id, ams_data)
  647. # Fingerprint was already set — re-fire path skipped
  648. mock_client.ams_set_filament_setting.assert_not_called()
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
  652. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  653. ):
  654. """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
  655. (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
  656. configured the slot manually in Bambu Studio so tray_type went from ''
  657. to 'PLA' — but state stays at 3 because these firmwares never set it
  658. to 11. With state-only detection the replay never fired."""
  659. from unittest.mock import AsyncMock
  660. from backend.app.main import on_ams_change
  661. from backend.app.models.spool_assignment import SpoolAssignment
  662. printer = await printer_factory(name="A1 mini")
  663. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  664. pre_assignment = SpoolAssignment(
  665. spool_id=spool.id,
  666. printer_id=printer.id,
  667. ams_id=0,
  668. tray_id=3,
  669. fingerprint_color=None,
  670. fingerprint_type=None,
  671. )
  672. db_session.add(pre_assignment)
  673. await db_session.commit()
  674. # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
  675. # is now configured — the replay must fire on this transition too.
  676. ams_data = [
  677. {
  678. "id": 0,
  679. "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
  680. }
  681. ]
  682. mock_client = MagicMock()
  683. mock_client.ams_set_filament_setting.return_value = True
  684. mock_client.extrusion_cali_sel.return_value = True
  685. status = _make_mock_status(ams_data=ams_data)
  686. printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
  687. with (
  688. patch("backend.app.main.printer_manager") as mock_pm_main,
  689. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  690. patch("backend.app.main.mqtt_relay") as mock_relay,
  691. patch("backend.app.main.ws_manager") as mock_ws,
  692. ):
  693. mock_pm_main.get_printer.return_value = printer_info
  694. mock_pm_main.get_status.return_value = status
  695. mock_pm_main.get_client.return_value = mock_client
  696. mock_pm_main.get_model.return_value = "A1 mini"
  697. mock_pm_inv.get_client.return_value = mock_client
  698. mock_pm_inv.get_status.return_value = status
  699. mock_relay.on_ams_change = AsyncMock()
  700. mock_ws.send_printer_status = AsyncMock()
  701. mock_ws.broadcast = AsyncMock()
  702. await on_ams_change(printer.id, ams_data)
  703. # Replay fired despite state never being 11 — the disjunction picked
  704. # up tray_type going non-empty.
  705. mock_client.ams_set_filament_setting.assert_called_once()
  706. await db_session.refresh(pre_assignment)
  707. assert pre_assignment.fingerprint_type == "PLA"
  708. class TestAssignSpoolEmptyDetection:
  709. """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
  710. but filament not in feeder. The assign route must prefer that signal over
  711. tray_type for the empty-vs-loaded check, because a manual "Reset slot"
  712. clears tray_type to "" while leaving filament physically loaded — the
  713. legacy heuristic would route to the pending-config path and skip MQTT
  714. forever, since on_ams_change replay only fires on an empty→loaded
  715. transition that never comes when the slot is already loaded.
  716. """
  717. @pytest.mark.asyncio
  718. @pytest.mark.integration
  719. async def test_state_loaded_with_empty_tray_type_fires_mqtt(
  720. self, async_client: AsyncClient, printer_factory, spool_factory
  721. ):
  722. """Post-reset case: state=11 (loaded) but tray_type='' — MQTT must fire."""
  723. printer = await printer_factory()
  724. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  725. mock_client = MagicMock()
  726. mock_client.ams_set_filament_setting.return_value = True
  727. mock_client.extrusion_cali_sel.return_value = True
  728. # Simulates the "reset slot" aftermath: filament physically loaded
  729. # (state=11) but tray_type/tray_color/tray_info_idx have been cleared.
  730. tray_data = {"id": 3, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  731. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  732. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  733. mock_pm.get_client.return_value = mock_client
  734. mock_pm.get_status.return_value = status
  735. response = await async_client.post(
  736. "/api/v1/inventory/assignments",
  737. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  738. )
  739. assert response.status_code == 200
  740. # MQTT must have fired — the bug was that legacy detection saw the
  741. # empty tray_type and skipped this entirely.
  742. mock_client.ams_set_filament_setting.assert_called_once()
  743. # Response must report configured=True, pending_config=False — the
  744. # slot is loaded, just had stale metadata cleared.
  745. body = response.json()
  746. assert body["pending_config"] is False
  747. assert body["configured"] is True
  748. @pytest.mark.asyncio
  749. @pytest.mark.integration
  750. async def test_state_empty_skips_mqtt_and_marks_pending(
  751. self, async_client: AsyncClient, printer_factory, spool_factory
  752. ):
  753. """Genuinely empty slot: state=9 — MQTT skipped, pending_config=True."""
  754. printer = await printer_factory()
  755. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  756. mock_client = MagicMock()
  757. mock_client.ams_set_filament_setting.return_value = True
  758. tray_data = {"id": 3, "state": 9, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  759. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  760. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  761. mock_pm.get_client.return_value = mock_client
  762. mock_pm.get_status.return_value = status
  763. response = await async_client.post(
  764. "/api/v1/inventory/assignments",
  765. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  766. )
  767. assert response.status_code == 200
  768. # SpoolBuddy weigh-then-assign workflow: firmware drops MQTT for
  769. # unloaded slots, so we don't bother sending it.
  770. mock_client.ams_set_filament_setting.assert_not_called()
  771. body = response.json()
  772. assert body["pending_config"] is True
  773. assert body["configured"] is False
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_state_missing_falls_back_to_tray_type_loaded(
  777. self, async_client: AsyncClient, printer_factory, spool_factory
  778. ):
  779. """Older firmware without state field: tray_type='PLA' → treated as loaded."""
  780. printer = await printer_factory()
  781. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  782. mock_client = MagicMock()
  783. mock_client.ams_set_filament_setting.return_value = True
  784. # No 'state' key at all — older firmware behaviour.
  785. tray_data = {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF"}
  786. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  787. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  788. mock_pm.get_client.return_value = mock_client
  789. mock_pm.get_status.return_value = status
  790. response = await async_client.post(
  791. "/api/v1/inventory/assignments",
  792. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  793. )
  794. assert response.status_code == 200
  795. # Legacy fallback: tray_type non-empty → treated as loaded → MQTT fires.
  796. mock_client.ams_set_filament_setting.assert_called_once()
  797. body = response.json()
  798. assert body["pending_config"] is False
  799. @pytest.mark.asyncio
  800. @pytest.mark.integration
  801. async def test_state_missing_falls_back_to_tray_type_empty(
  802. self, async_client: AsyncClient, printer_factory, spool_factory
  803. ):
  804. """Older firmware without state field + empty tray_type → pending."""
  805. printer = await printer_factory()
  806. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  807. mock_client = MagicMock()
  808. mock_client.ams_set_filament_setting.return_value = True
  809. tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
  810. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  811. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  812. mock_pm.get_client.return_value = mock_client
  813. mock_pm.get_status.return_value = status
  814. response = await async_client.post(
  815. "/api/v1/inventory/assignments",
  816. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  817. )
  818. assert response.status_code == 200
  819. # Legacy fallback: empty tray_type + no state → treated as empty.
  820. mock_client.ams_set_filament_setting.assert_not_called()
  821. body = response.json()
  822. assert body["pending_config"] is True
  823. @pytest.mark.asyncio
  824. @pytest.mark.integration
  825. async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
  826. self, async_client: AsyncClient, printer_factory, spool_factory
  827. ):
  828. """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
  829. report tray.state=3, never 11 — even for fully-loaded configured slots.
  830. A state-only check classified those as empty and skipped MQTT (#1322).
  831. With the disjunctive check, tray_type='PLA' alone is enough to fire."""
  832. printer = await printer_factory()
  833. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  834. mock_client = MagicMock()
  835. mock_client.ams_set_filament_setting.return_value = True
  836. mock_client.extrusion_cali_sel.return_value = True
  837. # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
  838. tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
  839. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  840. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  841. mock_pm.get_client.return_value = mock_client
  842. mock_pm.get_status.return_value = status
  843. response = await async_client.post(
  844. "/api/v1/inventory/assignments",
  845. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  846. )
  847. assert response.status_code == 200
  848. mock_client.ams_set_filament_setting.assert_called_once()
  849. body = response.json()
  850. assert body["pending_config"] is False
  851. assert body["configured"] is True
  852. @pytest.mark.asyncio
  853. @pytest.mark.integration
  854. async def test_state_never_eleven_firmware_with_empty_tray_marks_pending(
  855. self, async_client: AsyncClient, printer_factory, spool_factory
  856. ):
  857. """Same firmwares as above, but the slot is truly unconfigured
  858. (tray_type=''). Neither signal points to 'loaded', so this should
  859. still pending-config — the user has to configure or insert filament
  860. before MQTT can fire. Pins that the disjunction didn't accidentally
  861. flip empty slots into the loaded branch."""
  862. printer = await printer_factory()
  863. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  864. mock_client = MagicMock()
  865. mock_client.ams_set_filament_setting.return_value = True
  866. tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
  867. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  868. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  869. mock_pm.get_client.return_value = mock_client
  870. mock_pm.get_status.return_value = status
  871. response = await async_client.post(
  872. "/api/v1/inventory/assignments",
  873. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  874. )
  875. assert response.status_code == 200
  876. mock_client.ams_set_filament_setting.assert_not_called()
  877. body = response.json()
  878. assert body["pending_config"] is True
  879. @pytest.mark.asyncio
  880. @pytest.mark.integration
  881. async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(
  882. self, async_client: AsyncClient, printer_factory, spool_factory
  883. ):
  884. """External (vt_tray) slot post-reset: same fix applies for ams_id=255."""
  885. printer = await printer_factory(name="X1C")
  886. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  887. mock_client = MagicMock()
  888. mock_client.ams_set_filament_setting.return_value = True
  889. # External slot tray_id=0 → vt_tray id=254. state=11 (loaded), tray_type
  890. # cleared by reset.
  891. vt_data = [{"id": 254, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}]
  892. status = _make_mock_status(ams_data=[], vt_tray=vt_data)
  893. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  894. mock_pm.get_client.return_value = mock_client
  895. mock_pm.get_status.return_value = status
  896. response = await async_client.post(
  897. "/api/v1/inventory/assignments",
  898. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 255, "tray_id": 0},
  899. )
  900. assert response.status_code == 200
  901. mock_client.ams_set_filament_setting.assert_called_once()
  902. body = response.json()
  903. assert body["pending_config"] is False
  904. assert body["configured"] is True