test_inventory_assign.py 71 KB

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