test_inventory_assign.py 49 KB

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