test_inventory_assign.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. """Integration tests for inventory spool assignment — tray_info_idx resolution.
  2. Tests that the spool's own slicer_filament (including PFUS* cloud-synced
  3. custom presets) takes priority, with slot reuse and generic fallback as
  4. lower-priority fallbacks.
  5. """
  6. from unittest.mock import MagicMock, patch
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.models.spool import Spool
  11. @pytest.fixture
  12. async def spool_factory(db_session: AsyncSession):
  13. """Factory to create test spools."""
  14. _counter = [0]
  15. async def _create_spool(**kwargs):
  16. _counter[0] += 1
  17. defaults = {
  18. "material": "PLA",
  19. "subtype": "Basic",
  20. "brand": "Devil Design",
  21. "color_name": "Red",
  22. "rgba": "FF0000FF",
  23. "label_weight": 1000,
  24. "weight_used": 0,
  25. "slicer_filament": "PFUS9ac902733670a9",
  26. }
  27. defaults.update(kwargs)
  28. spool = Spool(**defaults)
  29. db_session.add(spool)
  30. await db_session.commit()
  31. await db_session.refresh(spool)
  32. return spool
  33. return _create_spool
  34. def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
  35. """Build a mock printer status with optional AMS/nozzle data."""
  36. status = MagicMock()
  37. raw = {}
  38. if ams_data is not None:
  39. raw["ams"] = {"ams": ams_data}
  40. if vt_tray is not None:
  41. raw["vt_tray"] = vt_tray
  42. status.raw_data = raw
  43. status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
  44. status.ams_extruder_map = ams_extruder_map
  45. return status
  46. class TestAssignSpoolTrayInfoIdx:
  47. """Tests for tray_info_idx resolution during spool assignment."""
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_pfus_slicer_filament_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):
  51. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  52. printer = await printer_factory(name="H2D")
  53. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  54. mock_client = MagicMock()
  55. mock_client.ams_set_filament_setting.return_value = True
  56. mock_client.extrusion_cali_sel.return_value = True
  57. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  58. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  59. mock_pm.get_client.return_value = mock_client
  60. mock_pm.get_status.return_value = status
  61. response = await async_client.post(
  62. "/api/v1/inventory/assignments",
  63. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  64. )
  65. assert response.status_code == 200
  66. call_kwargs = mock_client.ams_set_filament_setting.call_args
  67. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_spool_preset_takes_priority_over_slot(
  71. self, async_client: AsyncClient, printer_factory, spool_factory
  72. ):
  73. """Spool's own slicer_filament takes priority over slot's existing preset."""
  74. printer = await printer_factory(name="H2D")
  75. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  76. mock_client = MagicMock()
  77. mock_client.ams_set_filament_setting.return_value = True
  78. mock_client.extrusion_cali_sel.return_value = True
  79. # Slot already configured by slicer with cloud-synced preset
  80. status = _make_mock_status(
  81. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  82. )
  83. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  84. mock_pm.get_client.return_value = mock_client
  85. mock_pm.get_status.return_value = status
  86. response = await async_client.post(
  87. "/api/v1/inventory/assignments",
  88. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  89. )
  90. assert response.status_code == 200
  91. call_kwargs = mock_client.ams_set_filament_setting.call_args
  92. # Spool's own preset wins over slot's existing one
  93. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_spool_preset_used_even_if_different_material_on_slot(
  97. self, async_client: AsyncClient, printer_factory, spool_factory
  98. ):
  99. """Spool's own slicer_filament is used regardless of what's on the slot."""
  100. printer = await printer_factory(name="H2D")
  101. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
  102. mock_client = MagicMock()
  103. mock_client.ams_set_filament_setting.return_value = True
  104. mock_client.extrusion_cali_sel.return_value = True
  105. # Slot currently has PLA but spool is PETG
  106. status = _make_mock_status(
  107. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  108. )
  109. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  110. mock_pm.get_client.return_value = mock_client
  111. mock_pm.get_status.return_value = status
  112. response = await async_client.post(
  113. "/api/v1/inventory/assignments",
  114. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  115. )
  116. assert response.status_code == 200
  117. call_kwargs = mock_client.ams_set_filament_setting.call_args
  118. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
  122. """Standard GF* IDs from spool.slicer_filament are used directly."""
  123. printer = await printer_factory(name="X1C")
  124. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  125. mock_client = MagicMock()
  126. mock_client.ams_set_filament_setting.return_value = True
  127. mock_client.extrusion_cali_sel.return_value = True
  128. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  129. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  130. mock_pm.get_client.return_value = mock_client
  131. mock_pm.get_status.return_value = status
  132. response = await async_client.post(
  133. "/api/v1/inventory/assignments",
  134. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  135. )
  136. assert response.status_code == 200
  137. call_kwargs = mock_client.ams_set_filament_setting.call_args
  138. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
  142. """Spool with no slicer_filament gets a generic ID from material type."""
  143. printer = await printer_factory(name="X1C")
  144. spool = await spool_factory(slicer_filament=None, material="ABS")
  145. mock_client = MagicMock()
  146. mock_client.ams_set_filament_setting.return_value = True
  147. mock_client.extrusion_cali_sel.return_value = True
  148. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "ABS"}]}])
  149. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  150. mock_pm.get_client.return_value = mock_client
  151. mock_pm.get_status.return_value = status
  152. response = await async_client.post(
  153. "/api/v1/inventory/assignments",
  154. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  155. )
  156. assert response.status_code == 200
  157. call_kwargs = mock_client.ams_set_filament_setting.call_args
  158. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):
  162. """Spool's own PFUS preset is used even when slot has a different PFUS."""
  163. printer = await printer_factory(name="H2D")
  164. spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
  165. mock_client = MagicMock()
  166. mock_client.ams_set_filament_setting.return_value = True
  167. mock_client.extrusion_cali_sel.return_value = True
  168. # Slot has a PFUS* ID from some previous config
  169. status = _make_mock_status(
  170. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
  171. )
  172. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  173. mock_pm.get_client.return_value = mock_client
  174. mock_pm.get_status.return_value = status
  175. response = await async_client.post(
  176. "/api/v1/inventory/assignments",
  177. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  178. )
  179. assert response.status_code == 200
  180. call_kwargs = mock_client.ams_set_filament_setting.call_args
  181. # Spool's own preset wins
  182. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS1111111111"
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_generic_on_slot_not_reused_over_spool_preset(
  186. self, async_client: AsyncClient, printer_factory, spool_factory
  187. ):
  188. """Generic ID on slot (e.g. GFB99) must not override spool's own preset."""
  189. printer = await printer_factory(name="P2S")
  190. spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
  191. mock_client = MagicMock()
  192. mock_client.ams_set_filament_setting.return_value = True
  193. mock_client.extrusion_cali_sel.return_value = True
  194. # Slot stuck on generic ABS from a previous assignment
  195. status = _make_mock_status(
  196. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  197. )
  198. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  199. mock_pm.get_client.return_value = mock_client
  200. mock_pm.get_status.return_value = status
  201. response = await async_client.post(
  202. "/api/v1/inventory/assignments",
  203. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  204. )
  205. assert response.status_code == 200
  206. call_kwargs = mock_client.ams_set_filament_setting.call_args
  207. # Spool's preset wins — generic on slot must not be sticky
  208. assert call_kwargs.kwargs["tray_info_idx"] == "PFUScda4c46fc9031"
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_no_preset_with_generic_on_slot_still_uses_generic(
  212. self, async_client: AsyncClient, printer_factory, spool_factory
  213. ):
  214. """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
  215. printer = await printer_factory(name="P2S")
  216. spool = await spool_factory(slicer_filament=None, material="ABS")
  217. mock_client = MagicMock()
  218. mock_client.ams_set_filament_setting.return_value = True
  219. mock_client.extrusion_cali_sel.return_value = True
  220. # Slot has generic ABS
  221. status = _make_mock_status(
  222. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  223. )
  224. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  225. mock_pm.get_client.return_value = mock_client
  226. mock_pm.get_status.return_value = status
  227. response = await async_client.post(
  228. "/api/v1/inventory/assignments",
  229. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  230. )
  231. assert response.status_code == 200
  232. call_kwargs = mock_client.ams_set_filament_setting.call_args
  233. # Still gets generic, but via fallback — not via sticky reuse
  234. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_no_preset_reuses_specific_slot_preset(
  238. self, async_client: AsyncClient, printer_factory, spool_factory
  239. ):
  240. """Spool without preset + specific preset on slot → reuse slot's preset."""
  241. printer = await printer_factory(name="X1C")
  242. spool = await spool_factory(slicer_filament=None, material="PLA")
  243. mock_client = MagicMock()
  244. mock_client.ams_set_filament_setting.return_value = True
  245. mock_client.extrusion_cali_sel.return_value = True
  246. # Slot has a specific Bambu PLA preset (not generic)
  247. status = _make_mock_status(
  248. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
  249. )
  250. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  251. mock_pm.get_client.return_value = mock_client
  252. mock_pm.get_status.return_value = status
  253. response = await async_client.post(
  254. "/api/v1/inventory/assignments",
  255. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  256. )
  257. assert response.status_code == 200
  258. call_kwargs = mock_client.ams_set_filament_setting.call_args
  259. # Slot's specific preset is reused when spool has no own preset
  260. assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
  261. class TestAssignSpoolPresetMapping:
  262. """Tests that assign_spool saves the slot preset mapping for correct UI display."""
  263. @pytest.mark.asyncio
  264. @pytest.mark.integration
  265. async def test_preset_mapping_saved_with_slicer_filament_name(
  266. self, async_client: AsyncClient, printer_factory, spool_factory
  267. ):
  268. """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
  269. printer = await printer_factory(name="X1C")
  270. spool = await spool_factory(
  271. slicer_filament="GFA05",
  272. slicer_filament_name="Bambu PLA Silk",
  273. material="PLA",
  274. subtype="Silk",
  275. brand="Bambu",
  276. )
  277. mock_client = MagicMock()
  278. mock_client.ams_set_filament_setting.return_value = True
  279. mock_client.extrusion_cali_sel.return_value = True
  280. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
  281. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  282. mock_pm.get_client.return_value = mock_client
  283. mock_pm.get_status.return_value = status
  284. response = await async_client.post(
  285. "/api/v1/inventory/assignments",
  286. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  287. )
  288. assert response.status_code == 200
  289. # Verify via the slot presets API
  290. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  291. assert presets_resp.status_code == 200
  292. presets = presets_resp.json()
  293. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
  294. assert "1" in presets
  295. # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
  296. assert presets["1"]["preset_name"] == "Bambu PLA Silk"
  297. assert presets["1"]["preset_id"] == "GFSA05"
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_preset_mapping_overwrites_old_mapping(
  301. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  302. ):
  303. """Assigning a new spool overwrites the old slot preset mapping."""
  304. from backend.app.models.slot_preset import SlotPresetMapping
  305. printer = await printer_factory(name="X1C")
  306. # Pre-existing mapping (e.g. from previous manual configuration)
  307. old_mapping = SlotPresetMapping(
  308. printer_id=printer.id,
  309. ams_id=0,
  310. tray_id=2,
  311. preset_id="GFSA01",
  312. preset_name="Bambu PLA Matte",
  313. preset_source="cloud",
  314. )
  315. db_session.add(old_mapping)
  316. await db_session.commit()
  317. # Assign a "Generic PLA Silk" spool to same slot
  318. spool = await spool_factory(
  319. slicer_filament="GFL96",
  320. slicer_filament_name="Generic PLA Silk",
  321. material="PLA",
  322. subtype="Silk",
  323. )
  324. mock_client = MagicMock()
  325. mock_client.ams_set_filament_setting.return_value = True
  326. mock_client.extrusion_cali_sel.return_value = True
  327. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
  328. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  329. mock_pm.get_client.return_value = mock_client
  330. mock_pm.get_status.return_value = status
  331. response = await async_client.post(
  332. "/api/v1/inventory/assignments",
  333. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  334. )
  335. assert response.status_code == 200
  336. # Verify via the slot presets API to avoid stale session cache
  337. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  338. assert presets_resp.status_code == 200
  339. presets = presets_resp.json()
  340. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
  341. assert "2" in presets
  342. # Old "Bambu PLA Matte" must be overwritten
  343. assert presets["2"]["preset_name"] == "Generic PLA Silk"
  344. assert presets["2"]["preset_id"] == "GFSL96"
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_preset_mapping_fallback_to_tray_sub_brands(
  348. self, async_client: AsyncClient, printer_factory, spool_factory
  349. ):
  350. """When slicer_filament_name is null, falls back to tray_sub_brands."""
  351. from backend.app.models.slot_preset import SlotPresetMapping
  352. printer = await printer_factory(name="A1M")
  353. spool = await spool_factory(
  354. slicer_filament="GFL05",
  355. slicer_filament_name=None,
  356. material="PLA",
  357. subtype="Matte",
  358. brand="Overture",
  359. )
  360. mock_client = MagicMock()
  361. mock_client.ams_set_filament_setting.return_value = True
  362. mock_client.extrusion_cali_sel.return_value = True
  363. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  364. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  365. mock_pm.get_client.return_value = mock_client
  366. mock_pm.get_status.return_value = status
  367. response = await async_client.post(
  368. "/api/v1/inventory/assignments",
  369. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  370. )
  371. assert response.status_code == 200
  372. # Verify via the slot presets API
  373. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  374. assert presets_resp.status_code == 200
  375. presets = presets_resp.json()
  376. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
  377. assert "0" in presets
  378. # Falls back to tray_sub_brands ("Overture PLA Matte")
  379. assert presets["0"]["preset_name"] == "Overture PLA Matte"
  380. class TestAssignSpoolEmptySlotPreConfig:
  381. """SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
  382. Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for
  383. unloaded slots — there's no filament context for the cali_idx to attach to.
  384. The endpoint persists the SpoolAssignment row with an empty fingerprint_type
  385. (the "pending config" marker) and skips the MQTT publish; on_ams_change
  386. re-fires the full configuration when filament is later inserted.
  387. """
  388. @pytest.mark.asyncio
  389. @pytest.mark.integration
  390. async def test_empty_slot_skips_mqtt_but_persists_assignment(
  391. self, async_client: AsyncClient, printer_factory, spool_factory
  392. ):
  393. """Assigning to an empty slot skips MQTT and returns pending_config=True."""
  394. printer = await printer_factory(name="H2D")
  395. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  396. mock_client = MagicMock()
  397. mock_client.ams_set_filament_setting.return_value = True
  398. mock_client.extrusion_cali_sel.return_value = True
  399. # Slot found but empty (tray_type=""): the SpoolBuddy scenario
  400. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
  401. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  402. mock_pm.get_client.return_value = mock_client
  403. mock_pm.get_status.return_value = status
  404. response = await async_client.post(
  405. "/api/v1/inventory/assignments",
  406. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  407. )
  408. assert response.status_code == 200
  409. body = response.json()
  410. assert body["pending_config"] is True
  411. assert body["configured"] is False
  412. # Critical: no MQTT was published (firmware would drop it)
  413. mock_client.ams_set_filament_setting.assert_not_called()
  414. mock_client.extrusion_cali_sel.assert_not_called()
  415. @pytest.mark.asyncio
  416. @pytest.mark.integration
  417. async def test_empty_slot_no_ams_data_skips_mqtt(self, async_client: AsyncClient, printer_factory, spool_factory):
  418. """No AMS data at all (printer offline, no telemetry yet) → still pre-config."""
  419. printer = await printer_factory(name="X1C")
  420. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  421. mock_client = MagicMock()
  422. # No AMS data — fingerprint_type stays None, treated as empty
  423. status = _make_mock_status(ams_data=[])
  424. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  425. mock_pm.get_client.return_value = mock_client
  426. mock_pm.get_status.return_value = status
  427. response = await async_client.post(
  428. "/api/v1/inventory/assignments",
  429. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  430. )
  431. assert response.status_code == 200
  432. assert response.json()["pending_config"] is True
  433. mock_client.ams_set_filament_setting.assert_not_called()
  434. @pytest.mark.asyncio
  435. @pytest.mark.integration
  436. async def test_loaded_slot_publishes_mqtt_immediately(
  437. self, async_client: AsyncClient, printer_factory, spool_factory
  438. ):
  439. """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
  440. printer = await printer_factory(name="X1C")
  441. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  442. mock_client = MagicMock()
  443. mock_client.ams_set_filament_setting.return_value = True
  444. mock_client.extrusion_cali_sel.return_value = True
  445. status = _make_mock_status(
  446. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
  447. )
  448. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  449. mock_pm.get_client.return_value = mock_client
  450. mock_pm.get_status.return_value = status
  451. response = await async_client.post(
  452. "/api/v1/inventory/assignments",
  453. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  454. )
  455. assert response.status_code == 200
  456. body = response.json()
  457. assert body["pending_config"] is False
  458. assert body["configured"] is True
  459. mock_client.ams_set_filament_setting.assert_called_once()
  460. @pytest.mark.asyncio
  461. @pytest.mark.integration
  462. async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
  463. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  464. ):
  465. """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
  466. from unittest.mock import AsyncMock
  467. from backend.app.main import on_ams_change
  468. from backend.app.models.spool_assignment import SpoolAssignment
  469. printer = await printer_factory(name="H2D")
  470. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  471. # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
  472. pre_assignment = SpoolAssignment(
  473. spool_id=spool.id,
  474. printer_id=printer.id,
  475. ams_id=2,
  476. tray_id=3,
  477. fingerprint_color=None,
  478. fingerprint_type=None,
  479. )
  480. db_session.add(pre_assignment)
  481. await db_session.commit()
  482. # Filament has now been physically inserted into the slot.
  483. # state=11 ("filament fed to extruder") is the load signal we trigger on.
  484. ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  485. mock_client = MagicMock()
  486. mock_client.ams_set_filament_setting.return_value = True
  487. mock_client.extrusion_cali_sel.return_value = True
  488. status = _make_mock_status(ams_data=ams_data)
  489. printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
  490. with (
  491. patch("backend.app.main.printer_manager") as mock_pm_main,
  492. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  493. patch("backend.app.main.mqtt_relay") as mock_relay,
  494. patch("backend.app.main.ws_manager") as mock_ws,
  495. ):
  496. mock_pm_main.get_printer.return_value = printer_info
  497. mock_pm_main.get_status.return_value = status
  498. mock_pm_main.get_client.return_value = mock_client
  499. mock_pm_main.get_model.return_value = "H2D"
  500. mock_pm_inv.get_client.return_value = mock_client
  501. mock_pm_inv.get_status.return_value = status
  502. mock_relay.on_ams_change = AsyncMock()
  503. mock_ws.send_printer_status = AsyncMock()
  504. mock_ws.broadcast = AsyncMock()
  505. await on_ams_change(printer.id, ams_data)
  506. # Full filament setting was published when the slot transitioned to loaded
  507. mock_client.ams_set_filament_setting.assert_called_once()
  508. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  509. assert call_kwargs["ams_id"] == 2
  510. assert call_kwargs["tray_id"] == 3
  511. assert call_kwargs["tray_info_idx"] == "GFL05"
  512. # Fingerprint was updated so the next push doesn't re-fire
  513. await db_session.refresh(pre_assignment)
  514. assert pre_assignment.fingerprint_type == "PLA"
  515. assert pre_assignment.fingerprint_color == "FF0000FF"
  516. @pytest.mark.asyncio
  517. @pytest.mark.integration
  518. async def test_on_ams_change_does_not_refire_for_already_configured_slot(
  519. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  520. ):
  521. """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
  522. from unittest.mock import AsyncMock
  523. from backend.app.main import on_ams_change
  524. from backend.app.models.spool_assignment import SpoolAssignment
  525. printer = await printer_factory(name="X1C")
  526. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  527. # Assignment already configured (fingerprint stamped)
  528. configured_assignment = SpoolAssignment(
  529. spool_id=spool.id,
  530. printer_id=printer.id,
  531. ams_id=0,
  532. tray_id=0,
  533. fingerprint_color="FF0000FF",
  534. fingerprint_type="PLA",
  535. )
  536. db_session.add(configured_assignment)
  537. await db_session.commit()
  538. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  539. mock_client = MagicMock()
  540. mock_client.ams_set_filament_setting.return_value = True
  541. mock_client.extrusion_cali_sel.return_value = True
  542. status = _make_mock_status(ams_data=ams_data)
  543. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  544. with (
  545. patch("backend.app.main.printer_manager") as mock_pm_main,
  546. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  547. patch("backend.app.main.mqtt_relay") as mock_relay,
  548. patch("backend.app.main.ws_manager") as mock_ws,
  549. ):
  550. mock_pm_main.get_printer.return_value = printer_info
  551. mock_pm_main.get_status.return_value = status
  552. mock_pm_main.get_client.return_value = mock_client
  553. mock_pm_main.get_model.return_value = "X1C"
  554. mock_pm_inv.get_client.return_value = mock_client
  555. mock_pm_inv.get_status.return_value = status
  556. mock_relay.on_ams_change = AsyncMock()
  557. mock_ws.send_printer_status = AsyncMock()
  558. mock_ws.broadcast = AsyncMock()
  559. await on_ams_change(printer.id, ams_data)
  560. # Fingerprint was already set — re-fire path skipped
  561. mock_client.ams_set_filament_setting.assert_not_called()