test_inventory_assign.py 114 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570
  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 AsyncMock, 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. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. @pytest.mark.parametrize("slot_generic", ["GFL96", "GFL95"])
  273. async def test_no_preset_keeps_a_silk_or_high_speed_generic_on_the_slot(
  274. self, async_client: AsyncClient, printer_factory, spool_factory, slot_generic
  275. ):
  276. """PLA Silk and PLA High Speed joined the generic table (#3273), but the
  277. reuse check kept its old set: a slot set to one of them still keeps it
  278. for a preset-less PLA spool rather than dropping to Generic PLA."""
  279. printer = await printer_factory(name="X1C")
  280. spool = await spool_factory(slicer_filament=None, material="PLA")
  281. mock_client = MagicMock()
  282. mock_client.ams_set_filament_setting.return_value = True
  283. mock_client.extrusion_cali_sel.return_value = True
  284. status = _make_mock_status(
  285. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": slot_generic, "tray_type": "PLA"}]}]
  286. )
  287. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  288. mock_pm.get_client.return_value = mock_client
  289. mock_pm.get_status.return_value = status
  290. response = await async_client.post(
  291. "/api/v1/inventory/assignments",
  292. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  293. )
  294. assert response.status_code == 200
  295. call_kwargs = mock_client.ams_set_filament_setting.call_args
  296. assert call_kwargs.kwargs["tray_info_idx"] == slot_generic
  297. class TestSlotReuseRespectsTheSubtype:
  298. """A preset-less spool reuses the slot's Bambu filament id when the bare
  299. type matches, to keep a calibrated preset. A Silk spool must not inherit a
  300. slot that the printer describes as Matte, though."""
  301. async def _sent_id(self, async_client, printer_factory, spool_factory, subtype, slot_sub_brands):
  302. printer = await printer_factory(name="X1C")
  303. spool = await spool_factory(slicer_filament=None, material="PLA", subtype=subtype)
  304. mock_client = MagicMock()
  305. mock_client.ams_set_filament_setting.return_value = True
  306. mock_client.extrusion_cali_sel.return_value = True
  307. tray = {"id": 0, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": slot_sub_brands}
  308. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray]}])
  309. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  310. mock_pm.get_client.return_value = mock_client
  311. mock_pm.get_status.return_value = status
  312. response = await async_client.post(
  313. "/api/v1/inventory/assignments",
  314. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  315. )
  316. assert response.status_code == 200
  317. return mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"]
  318. @pytest.mark.asyncio
  319. @pytest.mark.integration
  320. async def test_a_silk_spool_does_not_take_a_matte_slot(self, async_client, printer_factory, spool_factory):
  321. sent = await self._sent_id(async_client, printer_factory, spool_factory, "Silk", "PLA Matte")
  322. assert sent == "GFL96" # Generic PLA Silk, the spool's own subtype
  323. @pytest.mark.asyncio
  324. @pytest.mark.integration
  325. async def test_a_silk_plus_spool_gets_generic_pla_silk(self, async_client, printer_factory, spool_factory):
  326. assert await self._sent_id(async_client, printer_factory, spool_factory, "Silk+", "PLA Matte") == "GFL96"
  327. @pytest.mark.asyncio
  328. @pytest.mark.integration
  329. async def test_a_finish_in_the_material_column_counts(self, async_client, printer_factory, spool_factory):
  330. printer = await printer_factory(name="X1C")
  331. spool = await spool_factory(slicer_filament=None, material="PLA Silk", subtype=None)
  332. mock_client = MagicMock()
  333. mock_client.ams_set_filament_setting.return_value = True
  334. mock_client.extrusion_cali_sel.return_value = True
  335. tray = {"id": 0, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": "PLA Matte"}
  336. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray]}])
  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. await async_client.post(
  341. "/api/v1/inventory/assignments",
  342. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  343. )
  344. assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_the_same_subtype_still_reuses_the_slot(self, async_client, printer_factory, spool_factory):
  348. assert await self._sent_id(async_client, printer_factory, spool_factory, "Matte", "PLA Matte") == "GFA01"
  349. @pytest.mark.asyncio
  350. @pytest.mark.integration
  351. @pytest.mark.parametrize(("subtype", "slot_sub_brands"), [(None, "PLA Matte"), ("Silk", ""), ("Silk", "Sunlu PLA")])
  352. async def test_without_two_known_subtypes_it_still_reuses(
  353. self, async_client, printer_factory, spool_factory, subtype, slot_sub_brands
  354. ):
  355. sent = await self._sent_id(async_client, printer_factory, spool_factory, subtype, slot_sub_brands)
  356. assert sent == "GFA01"
  357. class TestAssignSpoolPresetMapping:
  358. """Tests that assign_spool saves the slot preset mapping for correct UI display."""
  359. @pytest.mark.asyncio
  360. @pytest.mark.integration
  361. async def test_preset_mapping_saved_with_slicer_filament_name(
  362. self, async_client: AsyncClient, printer_factory, spool_factory
  363. ):
  364. """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
  365. printer = await printer_factory(name="X1C")
  366. spool = await spool_factory(
  367. slicer_filament="GFA05",
  368. slicer_filament_name="Bambu PLA Silk",
  369. material="PLA",
  370. subtype="Silk",
  371. brand="Bambu",
  372. )
  373. mock_client = MagicMock()
  374. mock_client.ams_set_filament_setting.return_value = True
  375. mock_client.extrusion_cali_sel.return_value = True
  376. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
  377. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  378. mock_pm.get_client.return_value = mock_client
  379. mock_pm.get_status.return_value = status
  380. response = await async_client.post(
  381. "/api/v1/inventory/assignments",
  382. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  383. )
  384. assert response.status_code == 200
  385. # Verify via the slot presets API
  386. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  387. assert presets_resp.status_code == 200
  388. presets = presets_resp.json()
  389. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
  390. assert "1" in presets
  391. # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
  392. assert presets["1"]["preset_name"] == "Bambu PLA Silk"
  393. assert presets["1"]["preset_id"] == "GFSA05"
  394. # The filament id the slot was given goes with it, so the slot card can
  395. # tell a later re-configuration from elsewhere (#3216).
  396. assert presets["1"]["tray_info_idx"] == "GFA05"
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_preset_mapping_overwrites_old_mapping(
  400. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  401. ):
  402. """Assigning a new spool overwrites the old slot preset mapping."""
  403. from backend.app.models.slot_preset import SlotPresetMapping
  404. printer = await printer_factory(name="X1C")
  405. # Pre-existing mapping (e.g. from previous manual configuration)
  406. old_mapping = SlotPresetMapping(
  407. printer_id=printer.id,
  408. ams_id=0,
  409. tray_id=2,
  410. preset_id="GFSA01",
  411. preset_name="Bambu PLA Matte",
  412. preset_source="cloud",
  413. tray_info_idx="GFA01",
  414. )
  415. db_session.add(old_mapping)
  416. await db_session.commit()
  417. # Assign a "Generic PLA Silk" spool to same slot
  418. spool = await spool_factory(
  419. slicer_filament="GFL96",
  420. slicer_filament_name="Generic PLA Silk",
  421. material="PLA",
  422. subtype="Silk",
  423. )
  424. mock_client = MagicMock()
  425. mock_client.ams_set_filament_setting.return_value = True
  426. mock_client.extrusion_cali_sel.return_value = True
  427. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
  428. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  429. mock_pm.get_client.return_value = mock_client
  430. mock_pm.get_status.return_value = status
  431. response = await async_client.post(
  432. "/api/v1/inventory/assignments",
  433. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  434. )
  435. assert response.status_code == 200
  436. # Verify via the slot presets API to avoid stale session cache
  437. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  438. assert presets_resp.status_code == 200
  439. presets = presets_resp.json()
  440. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
  441. assert "2" in presets
  442. # Old "Bambu PLA Matte" must be overwritten
  443. assert presets["2"]["preset_name"] == "Generic PLA Silk"
  444. assert presets["2"]["preset_id"] == "GFSL96"
  445. # ...and so must the filament id recorded with it (#3216).
  446. assert presets["2"]["tray_info_idx"] == "GFL96"
  447. @pytest.mark.asyncio
  448. @pytest.mark.integration
  449. async def test_preset_mapping_fallback_to_tray_sub_brands(
  450. self, async_client: AsyncClient, printer_factory, spool_factory
  451. ):
  452. """When slicer_filament_name is null, falls back to tray_sub_brands."""
  453. from backend.app.models.slot_preset import SlotPresetMapping
  454. printer = await printer_factory(name="A1M")
  455. spool = await spool_factory(
  456. slicer_filament="GFL05",
  457. slicer_filament_name=None,
  458. material="PLA",
  459. subtype="Matte",
  460. brand="Overture",
  461. )
  462. mock_client = MagicMock()
  463. mock_client.ams_set_filament_setting.return_value = True
  464. mock_client.extrusion_cali_sel.return_value = True
  465. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  466. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  467. mock_pm.get_client.return_value = mock_client
  468. mock_pm.get_status.return_value = status
  469. response = await async_client.post(
  470. "/api/v1/inventory/assignments",
  471. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  472. )
  473. assert response.status_code == 200
  474. # Verify via the slot presets API
  475. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  476. assert presets_resp.status_code == 200
  477. presets = presets_resp.json()
  478. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
  479. assert "0" in presets
  480. # Falls back to tray_sub_brands ("Overture PLA Matte")
  481. assert presets["0"]["preset_name"] == "Overture PLA Matte"
  482. class TestAssignSpoolLiveCaliIdx:
  483. """assign_spool always resets the slot to Default K when the spool has no stored K-profile."""
  484. @pytest.mark.asyncio
  485. @pytest.mark.integration
  486. async def test_no_kprofile_resets_to_default_k(self, async_client: AsyncClient, printer_factory, spool_factory):
  487. """When no KProfile row exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
  488. printer = await printer_factory()
  489. spool = await spool_factory()
  490. mock_client = MagicMock()
  491. mock_client.ams_set_filament_setting.return_value = True
  492. mock_client.extrusion_cali_sel.return_value = True
  493. # Live cali_idx=42 belongs to whatever filament was previously calibrated
  494. # in this slot. Applying it to a different spool would use the wrong K
  495. # value, so the assign flow must override it with Default K (-1).
  496. tray_data = {
  497. "id": 1,
  498. "cali_idx": 42,
  499. "tray_color": "FF0000FF",
  500. "tray_type": "PLA",
  501. "tray_sub_brands": "PLA Basic",
  502. "tray_id_name": "GFL99",
  503. }
  504. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  505. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  506. mock_pm.get_client.return_value = mock_client
  507. mock_pm.get_status.return_value = status
  508. response = await async_client.post(
  509. "/api/v1/inventory/assignments",
  510. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  511. )
  512. assert response.status_code == 200
  513. mock_client.extrusion_cali_sel.assert_called_once()
  514. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  515. @pytest.mark.asyncio
  516. @pytest.mark.integration
  517. async def test_no_kprofile_no_live_cali_idx_sends_default(
  518. self, async_client: AsyncClient, printer_factory, spool_factory
  519. ):
  520. """When tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
  521. printer = await printer_factory()
  522. spool = await spool_factory()
  523. mock_client = MagicMock()
  524. mock_client.ams_set_filament_setting.return_value = True
  525. mock_client.extrusion_cali_sel.return_value = True
  526. tray_data = {
  527. "id": 0,
  528. "cali_idx": None,
  529. "tray_color": "FF0000FF",
  530. "tray_type": "PLA",
  531. "tray_sub_brands": "PLA Basic",
  532. "tray_id_name": "GFL99",
  533. }
  534. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  535. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  536. mock_pm.get_client.return_value = mock_client
  537. mock_pm.get_status.return_value = status
  538. response = await async_client.post(
  539. "/api/v1/inventory/assignments",
  540. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  541. )
  542. assert response.status_code == 200
  543. mock_client.extrusion_cali_sel.assert_called_once()
  544. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  545. @pytest.mark.asyncio
  546. @pytest.mark.integration
  547. async def test_negative_live_cali_idx_sends_default(
  548. self, async_client: AsyncClient, printer_factory, spool_factory
  549. ):
  550. """A negative live cali_idx (-1) falls through and is sent as Default (cali_idx=-1)."""
  551. printer = await printer_factory()
  552. spool = await spool_factory()
  553. mock_client = MagicMock()
  554. mock_client.ams_set_filament_setting.return_value = True
  555. mock_client.extrusion_cali_sel.return_value = True
  556. tray_data = {
  557. "id": 0,
  558. "cali_idx": -1,
  559. "tray_color": "FF0000FF",
  560. "tray_type": "PLA",
  561. "tray_sub_brands": "PLA Basic",
  562. "tray_id_name": "GFL99",
  563. }
  564. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  565. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  566. mock_pm.get_client.return_value = mock_client
  567. mock_pm.get_status.return_value = status
  568. response = await async_client.post(
  569. "/api/v1/inventory/assignments",
  570. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  571. )
  572. assert response.status_code == 200
  573. mock_client.extrusion_cali_sel.assert_called_once()
  574. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  575. class TestAssignSpoolEmptySlotPreConfig:
  576. """Assign path under ambiguous / explicit-empty AMS state.
  577. Updated for the #1322 follow-up: only the firmware's *explicit* empty
  578. signal (state ∈ {9, 10}) skips MQTT. Anything else — including the
  579. SpoolBuddy weigh-then-assign-before-insert case where state/tray_type
  580. can't tell us whether a spool is loaded — attempts MQTT. The deferred-
  581. config workflow still works because on_ams_change at main.py:1031-1054
  582. re-fires when an AMS push eventually reports the loaded slot.
  583. """
  584. @pytest.mark.asyncio
  585. @pytest.mark.integration
  586. async def test_empty_tray_type_without_state_still_fires_mqtt(
  587. self, async_client: AsyncClient, printer_factory, spool_factory
  588. ):
  589. """tray_type='' with no state field: AMS can't tell us whether a
  590. spool is loaded. Trust the user's Assign click and fire MQTT —
  591. firmware accepts it when a spool is physically there, drops it
  592. silently otherwise (no harm)."""
  593. printer = await printer_factory(name="H2D")
  594. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  595. mock_client = MagicMock()
  596. mock_client.ams_set_filament_setting.return_value = True
  597. mock_client.extrusion_cali_sel.return_value = True
  598. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
  599. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  600. mock_pm.get_client.return_value = mock_client
  601. mock_pm.get_status.return_value = status
  602. response = await async_client.post(
  603. "/api/v1/inventory/assignments",
  604. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  605. )
  606. assert response.status_code == 200
  607. mock_client.ams_set_filament_setting.assert_called_once()
  608. body = response.json()
  609. assert body["pending_config"] is False
  610. assert body["configured"] is True
  611. @pytest.mark.asyncio
  612. @pytest.mark.integration
  613. async def test_no_ams_data_with_no_client_marks_pending(
  614. self, async_client: AsyncClient, printer_factory, spool_factory
  615. ):
  616. """No AMS data + no MQTT client (printer offline, no telemetry):
  617. publish can't happen, so configured=False and pending_config=True so
  618. on_ams_change replay picks it up when the printer comes online."""
  619. printer = await printer_factory(name="X1C")
  620. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  621. # No AMS data — fingerprint_type stays None.
  622. status = _make_mock_status(ams_data=[])
  623. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  624. mock_pm.get_client.return_value = None # Printer offline, no MQTT client.
  625. mock_pm.get_status.return_value = status
  626. response = await async_client.post(
  627. "/api/v1/inventory/assignments",
  628. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  629. )
  630. assert response.status_code == 200
  631. body = response.json()
  632. assert body["pending_config"] is True
  633. assert body["configured"] is False
  634. @pytest.mark.asyncio
  635. @pytest.mark.integration
  636. async def test_loaded_slot_publishes_mqtt_immediately(
  637. self, async_client: AsyncClient, printer_factory, spool_factory
  638. ):
  639. """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
  640. printer = await printer_factory(name="X1C")
  641. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  642. mock_client = MagicMock()
  643. mock_client.ams_set_filament_setting.return_value = True
  644. mock_client.extrusion_cali_sel.return_value = True
  645. status = _make_mock_status(
  646. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
  647. )
  648. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  649. mock_pm.get_client.return_value = mock_client
  650. mock_pm.get_status.return_value = status
  651. response = await async_client.post(
  652. "/api/v1/inventory/assignments",
  653. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  654. )
  655. assert response.status_code == 200
  656. body = response.json()
  657. assert body["pending_config"] is False
  658. assert body["configured"] is True
  659. mock_client.ams_set_filament_setting.assert_called_once()
  660. @pytest.mark.asyncio
  661. @pytest.mark.integration
  662. async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
  663. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  664. ):
  665. """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
  666. from unittest.mock import AsyncMock
  667. from backend.app.main import on_ams_change
  668. from backend.app.models.spool_assignment import SpoolAssignment
  669. printer = await printer_factory(name="H2D")
  670. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  671. # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
  672. pre_assignment = SpoolAssignment(
  673. spool_id=spool.id,
  674. printer_id=printer.id,
  675. ams_id=2,
  676. tray_id=3,
  677. fingerprint_color=None,
  678. fingerprint_type=None,
  679. )
  680. db_session.add(pre_assignment)
  681. await db_session.commit()
  682. # Filament has now been physically inserted into the slot.
  683. # state=11 ("filament fed to extruder") is the load signal we trigger on.
  684. ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  685. mock_client = MagicMock()
  686. mock_client.ams_set_filament_setting.return_value = True
  687. mock_client.extrusion_cali_sel.return_value = True
  688. status = _make_mock_status(ams_data=ams_data)
  689. printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
  690. with (
  691. patch("backend.app.main.printer_manager") as mock_pm_main,
  692. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  693. patch("backend.app.main.mqtt_relay") as mock_relay,
  694. patch("backend.app.main.ws_manager") as mock_ws,
  695. ):
  696. mock_pm_main.get_printer.return_value = printer_info
  697. mock_pm_main.get_status.return_value = status
  698. mock_pm_main.get_client.return_value = mock_client
  699. mock_pm_main.get_model.return_value = "H2D"
  700. mock_pm_inv.get_client.return_value = mock_client
  701. mock_pm_inv.get_status.return_value = status
  702. mock_relay.on_ams_change = AsyncMock()
  703. mock_ws.send_printer_status = AsyncMock()
  704. mock_ws.broadcast = AsyncMock()
  705. await on_ams_change(printer.id, ams_data)
  706. # Full filament setting was published when the slot transitioned to loaded
  707. mock_client.ams_set_filament_setting.assert_called_once()
  708. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  709. assert call_kwargs["ams_id"] == 2
  710. assert call_kwargs["tray_id"] == 3
  711. assert call_kwargs["tray_info_idx"] == "GFL05"
  712. # Fingerprint was updated so the next push doesn't re-fire
  713. await db_session.refresh(pre_assignment)
  714. assert pre_assignment.fingerprint_type == "PLA"
  715. assert pre_assignment.fingerprint_color == "FF0000FF"
  716. @pytest.mark.asyncio
  717. @pytest.mark.integration
  718. async def test_the_replay_passes_the_slots_finish(
  719. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  720. ):
  721. """A preset-less Silk spool loaded into a slot reporting Bambu Matte."""
  722. from unittest.mock import AsyncMock
  723. from backend.app.main import on_ams_change
  724. from backend.app.models.spool_assignment import SpoolAssignment
  725. printer = await printer_factory(name="H2D")
  726. spool = await spool_factory(slicer_filament=None, material="PLA", subtype="Silk")
  727. db_session.add(
  728. SpoolAssignment(
  729. spool_id=spool.id,
  730. printer_id=printer.id,
  731. ams_id=2,
  732. tray_id=3,
  733. fingerprint_color=None,
  734. fingerprint_type=None,
  735. )
  736. )
  737. await db_session.commit()
  738. tray = {
  739. "id": 3,
  740. "tray_type": "PLA",
  741. "tray_color": "FF0000FF",
  742. "state": 11,
  743. "tray_info_idx": "GFA01",
  744. "tray_sub_brands": "PLA Matte",
  745. }
  746. ams_data = [{"id": 2, "tray": [tray]}]
  747. mock_client = MagicMock()
  748. status = _make_mock_status(ams_data=ams_data)
  749. with (
  750. patch("backend.app.main.printer_manager") as mock_pm_main,
  751. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  752. patch("backend.app.main.mqtt_relay") as mock_relay,
  753. patch("backend.app.main.ws_manager") as mock_ws,
  754. ):
  755. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  756. mock_pm_main.get_status.return_value = status
  757. mock_pm_main.get_client.return_value = mock_client
  758. mock_pm_main.get_model.return_value = "H2D"
  759. mock_pm_inv.get_client.return_value = mock_client
  760. mock_pm_inv.get_status.return_value = status
  761. mock_relay.on_ams_change = AsyncMock()
  762. mock_ws.send_printer_status = AsyncMock()
  763. mock_ws.broadcast = AsyncMock()
  764. await on_ams_change(printer.id, ams_data)
  765. assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
  766. @pytest.mark.asyncio
  767. @pytest.mark.integration
  768. async def test_on_ams_change_does_not_refire_for_already_configured_slot(
  769. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  770. ):
  771. """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
  772. from unittest.mock import AsyncMock
  773. from backend.app.main import on_ams_change
  774. from backend.app.models.spool_assignment import SpoolAssignment
  775. printer = await printer_factory(name="X1C")
  776. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  777. # Assignment already configured (fingerprint stamped)
  778. configured_assignment = SpoolAssignment(
  779. spool_id=spool.id,
  780. printer_id=printer.id,
  781. ams_id=0,
  782. tray_id=0,
  783. fingerprint_color="FF0000FF",
  784. fingerprint_type="PLA",
  785. )
  786. db_session.add(configured_assignment)
  787. await db_session.commit()
  788. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  789. mock_client = MagicMock()
  790. mock_client.ams_set_filament_setting.return_value = True
  791. mock_client.extrusion_cali_sel.return_value = True
  792. status = _make_mock_status(ams_data=ams_data)
  793. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  794. with (
  795. patch("backend.app.main.printer_manager") as mock_pm_main,
  796. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  797. patch("backend.app.main.mqtt_relay") as mock_relay,
  798. patch("backend.app.main.ws_manager") as mock_ws,
  799. ):
  800. mock_pm_main.get_printer.return_value = printer_info
  801. mock_pm_main.get_status.return_value = status
  802. mock_pm_main.get_client.return_value = mock_client
  803. mock_pm_main.get_model.return_value = "X1C"
  804. mock_pm_inv.get_client.return_value = mock_client
  805. mock_pm_inv.get_status.return_value = status
  806. mock_relay.on_ams_change = AsyncMock()
  807. mock_ws.send_printer_status = AsyncMock()
  808. mock_ws.broadcast = AsyncMock()
  809. await on_ams_change(printer.id, ams_data)
  810. # Fingerprint was already set — re-fire path skipped
  811. mock_client.ams_set_filament_setting.assert_not_called()
  812. @pytest.mark.asyncio
  813. @pytest.mark.integration
  814. async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
  815. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  816. ):
  817. """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
  818. (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
  819. configured the slot manually in Bambu Studio so tray_type went from ''
  820. to 'PLA' — but state stays at 3 because these firmwares never set it
  821. to 11. With state-only detection the replay never fired."""
  822. from unittest.mock import AsyncMock
  823. from backend.app.main import on_ams_change
  824. from backend.app.models.spool_assignment import SpoolAssignment
  825. printer = await printer_factory(name="A1 mini")
  826. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  827. pre_assignment = SpoolAssignment(
  828. spool_id=spool.id,
  829. printer_id=printer.id,
  830. ams_id=0,
  831. tray_id=3,
  832. fingerprint_color=None,
  833. fingerprint_type=None,
  834. )
  835. db_session.add(pre_assignment)
  836. await db_session.commit()
  837. # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
  838. # is now configured — the replay must fire on this transition too.
  839. ams_data = [
  840. {
  841. "id": 0,
  842. "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
  843. }
  844. ]
  845. mock_client = MagicMock()
  846. mock_client.ams_set_filament_setting.return_value = True
  847. mock_client.extrusion_cali_sel.return_value = True
  848. status = _make_mock_status(ams_data=ams_data)
  849. printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
  850. with (
  851. patch("backend.app.main.printer_manager") as mock_pm_main,
  852. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  853. patch("backend.app.main.mqtt_relay") as mock_relay,
  854. patch("backend.app.main.ws_manager") as mock_ws,
  855. ):
  856. mock_pm_main.get_printer.return_value = printer_info
  857. mock_pm_main.get_status.return_value = status
  858. mock_pm_main.get_client.return_value = mock_client
  859. mock_pm_main.get_model.return_value = "A1 mini"
  860. mock_pm_inv.get_client.return_value = mock_client
  861. mock_pm_inv.get_status.return_value = status
  862. mock_relay.on_ams_change = AsyncMock()
  863. mock_ws.send_printer_status = AsyncMock()
  864. mock_ws.broadcast = AsyncMock()
  865. await on_ams_change(printer.id, ams_data)
  866. # Replay fired despite state never being 11 — the disjunction picked
  867. # up tray_type going non-empty.
  868. mock_client.ams_set_filament_setting.assert_called_once()
  869. await db_session.refresh(pre_assignment)
  870. assert pre_assignment.fingerprint_type == "PLA"
  871. @pytest.mark.asyncio
  872. @pytest.mark.integration
  873. async def test_auto_unlink_broadcasts_assignment_change(
  874. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  875. ):
  876. """#2575 follow-up: when on_ams_change auto-unlinks a stale external-spool
  877. assignment, it must broadcast spool_assignment_changed. Only the manual
  878. REST endpoints did, so open browsers kept rendering the unlinked spool —
  879. the reporter read that as "the fix didn't work" when the DB was correct."""
  880. from unittest.mock import AsyncMock
  881. from sqlalchemy import select
  882. from backend.app.main import on_ams_change
  883. from backend.app.models.spool_assignment import SpoolAssignment
  884. printer = await printer_factory(name="X1C")
  885. spool = await spool_factory(slicer_filament="GFU01", material="TPU")
  886. # TPU inventory spool assigned to the external slot (ams_id=255, tray 0)
  887. assignment = SpoolAssignment(
  888. spool_id=spool.id,
  889. printer_id=printer.id,
  890. ams_id=255,
  891. tray_id=0,
  892. fingerprint_color="000000FF",
  893. fingerprint_type="TPU",
  894. )
  895. db_session.add(assignment)
  896. await db_session.commit()
  897. # The printer's external spool now reports ABS — the assignment is stale.
  898. vt_tray = [
  899. {
  900. "id": "254",
  901. "tray_type": "ABS",
  902. "tray_color": "000000FF",
  903. "tag_uid": "0000000000000000",
  904. "tray_uuid": "00000000000000000000000000000000",
  905. }
  906. ]
  907. status = _make_mock_status(ams_data=[], vt_tray=vt_tray)
  908. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  909. with (
  910. patch("backend.app.main.printer_manager") as mock_pm_main,
  911. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  912. patch("backend.app.main.mqtt_relay") as mock_relay,
  913. patch("backend.app.main.ws_manager") as mock_ws,
  914. ):
  915. mock_pm_main.get_printer.return_value = printer_info
  916. mock_pm_main.get_status.return_value = status
  917. mock_pm_main.get_client.return_value = MagicMock()
  918. mock_pm_main.get_model.return_value = "X1C"
  919. mock_pm_inv.get_client.return_value = MagicMock()
  920. mock_pm_inv.get_status.return_value = status
  921. mock_relay.on_ams_change = AsyncMock()
  922. mock_ws.send_printer_status = AsyncMock()
  923. mock_ws.broadcast = AsyncMock()
  924. await on_ams_change(printer.id, [])
  925. # The stale TPU assignment on the now-ABS external slot was unlinked...
  926. gone = await db_session.execute(
  927. select(SpoolAssignment).where(
  928. SpoolAssignment.printer_id == printer.id,
  929. SpoolAssignment.ams_id == 255,
  930. SpoolAssignment.tray_id == 0,
  931. )
  932. )
  933. assert gone.scalar_one_or_none() is None
  934. # ...and the frontend was told about it.
  935. change_events = [
  936. c.args[0]
  937. for c in mock_ws.broadcast.await_args_list
  938. if c.args and isinstance(c.args[0], dict) and c.args[0].get("type") == "spool_assignment_changed"
  939. ]
  940. assert change_events, "auto-unlink must broadcast spool_assignment_changed"
  941. assert change_events[0]["printer_id"] == printer.id
  942. assert change_events[0]["ams_id"] == 255
  943. assert change_events[0]["tray_id"] == 0
  944. class TestAssignSpoolEmptyDetection:
  945. """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
  946. but filament not in feeder. The assign route must prefer that signal over
  947. tray_type for the empty-vs-loaded check, because a manual "Reset slot"
  948. clears tray_type to "" while leaving filament physically loaded — the
  949. legacy heuristic would route to the pending-config path and skip MQTT
  950. forever, since on_ams_change replay only fires on an empty→loaded
  951. transition that never comes when the slot is already loaded.
  952. """
  953. @pytest.mark.asyncio
  954. @pytest.mark.integration
  955. async def test_state_loaded_with_empty_tray_type_fires_mqtt(
  956. self, async_client: AsyncClient, printer_factory, spool_factory
  957. ):
  958. """Post-reset case: state=11 (loaded) but tray_type='' — MQTT must fire."""
  959. printer = await printer_factory()
  960. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  961. mock_client = MagicMock()
  962. mock_client.ams_set_filament_setting.return_value = True
  963. mock_client.extrusion_cali_sel.return_value = True
  964. # Simulates the "reset slot" aftermath: filament physically loaded
  965. # (state=11) but tray_type/tray_color/tray_info_idx have been cleared.
  966. tray_data = {"id": 3, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  967. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  968. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  969. mock_pm.get_client.return_value = mock_client
  970. mock_pm.get_status.return_value = status
  971. response = await async_client.post(
  972. "/api/v1/inventory/assignments",
  973. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  974. )
  975. assert response.status_code == 200
  976. # MQTT must have fired — the bug was that legacy detection saw the
  977. # empty tray_type and skipped this entirely.
  978. mock_client.ams_set_filament_setting.assert_called_once()
  979. # Response must report configured=True, pending_config=False — the
  980. # slot is loaded, just had stale metadata cleared.
  981. body = response.json()
  982. assert body["pending_config"] is False
  983. assert body["configured"] is True
  984. @pytest.mark.asyncio
  985. @pytest.mark.integration
  986. async def test_state_empty_skips_mqtt_and_marks_pending(
  987. self, async_client: AsyncClient, printer_factory, spool_factory
  988. ):
  989. """Genuinely empty slot: state=9 — MQTT skipped, pending_config=True."""
  990. printer = await printer_factory()
  991. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  992. mock_client = MagicMock()
  993. mock_client.ams_set_filament_setting.return_value = True
  994. tray_data = {"id": 3, "state": 9, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  995. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  996. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  997. mock_pm.get_client.return_value = mock_client
  998. mock_pm.get_status.return_value = status
  999. response = await async_client.post(
  1000. "/api/v1/inventory/assignments",
  1001. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1002. )
  1003. assert response.status_code == 200
  1004. # SpoolBuddy weigh-then-assign workflow: firmware drops MQTT for
  1005. # unloaded slots, so we don't bother sending it.
  1006. mock_client.ams_set_filament_setting.assert_not_called()
  1007. body = response.json()
  1008. assert body["pending_config"] is True
  1009. assert body["configured"] is False
  1010. @pytest.mark.asyncio
  1011. @pytest.mark.integration
  1012. async def test_state_missing_falls_back_to_tray_type_loaded(
  1013. self, async_client: AsyncClient, printer_factory, spool_factory
  1014. ):
  1015. """Older firmware without state field: tray_type='PLA' → treated as loaded."""
  1016. printer = await printer_factory()
  1017. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  1018. mock_client = MagicMock()
  1019. mock_client.ams_set_filament_setting.return_value = True
  1020. # No 'state' key at all — older firmware behaviour.
  1021. tray_data = {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF"}
  1022. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1023. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1024. mock_pm.get_client.return_value = mock_client
  1025. mock_pm.get_status.return_value = status
  1026. response = await async_client.post(
  1027. "/api/v1/inventory/assignments",
  1028. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1029. )
  1030. assert response.status_code == 200
  1031. # Legacy fallback: tray_type non-empty → treated as loaded → MQTT fires.
  1032. mock_client.ams_set_filament_setting.assert_called_once()
  1033. body = response.json()
  1034. assert body["pending_config"] is False
  1035. @pytest.mark.asyncio
  1036. @pytest.mark.integration
  1037. async def test_state_missing_with_empty_tray_type_still_fires_mqtt(
  1038. self, async_client: AsyncClient, printer_factory, spool_factory
  1039. ):
  1040. """Older firmware without state field + empty tray_type still fires MQTT.
  1041. The AMS doesn't tell us whether a spool is physically loaded in this
  1042. case (no state, no tray_type), so the assign click is the user's
  1043. assertion that a spool is there. Firmware silently drops the push on
  1044. a truly empty slot — no harm done, and on_ams_change replay handles
  1045. the deferred-config case (#1322 follow-up).
  1046. """
  1047. printer = await printer_factory()
  1048. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  1049. mock_client = MagicMock()
  1050. mock_client.ams_set_filament_setting.return_value = True
  1051. mock_client.extrusion_cali_sel.return_value = True
  1052. tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
  1053. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1054. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1055. mock_pm.get_client.return_value = mock_client
  1056. mock_pm.get_status.return_value = status
  1057. response = await async_client.post(
  1058. "/api/v1/inventory/assignments",
  1059. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1060. )
  1061. assert response.status_code == 200
  1062. mock_client.ams_set_filament_setting.assert_called_once()
  1063. body = response.json()
  1064. assert body["pending_config"] is False
  1065. assert body["configured"] is True
  1066. @pytest.mark.asyncio
  1067. @pytest.mark.integration
  1068. async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
  1069. self, async_client: AsyncClient, printer_factory, spool_factory
  1070. ):
  1071. """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
  1072. report tray.state=3, never 11 — even for fully-loaded configured slots.
  1073. A state-only check classified those as empty and skipped MQTT (#1322).
  1074. With the disjunctive check, tray_type='PLA' alone is enough to fire."""
  1075. printer = await printer_factory()
  1076. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  1077. mock_client = MagicMock()
  1078. mock_client.ams_set_filament_setting.return_value = True
  1079. mock_client.extrusion_cali_sel.return_value = True
  1080. # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
  1081. tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
  1082. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1083. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1084. mock_pm.get_client.return_value = mock_client
  1085. mock_pm.get_status.return_value = status
  1086. response = await async_client.post(
  1087. "/api/v1/inventory/assignments",
  1088. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1089. )
  1090. assert response.status_code == 200
  1091. mock_client.ams_set_filament_setting.assert_called_once()
  1092. body = response.json()
  1093. assert body["pending_config"] is False
  1094. assert body["configured"] is True
  1095. @pytest.mark.asyncio
  1096. @pytest.mark.integration
  1097. async def test_post_reset_slot_with_state_3_still_fires_mqtt(
  1098. self, async_client: AsyncClient, printer_factory, spool_factory
  1099. ):
  1100. """A1 Mini BMCU / P1S Standard AMS post-"Reset Slot" with spool still
  1101. inserted: state=3, tray_type="". The AMS gives us no signal to tell
  1102. this apart from a truly-empty slot. We trust the user's Assign click
  1103. and fire MQTT — firmware accepts the push because a spool is
  1104. physically there (#1322 follow-up by @RosdasHH).
  1105. Replaces the previous "marks_pending" assertion which was the bug:
  1106. that gate created a deadlock because the AMS would never report a
  1107. state change (nothing physically changed), so on_ams_change replay
  1108. never re-fired the deferred config either.
  1109. """
  1110. printer = await printer_factory()
  1111. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  1112. mock_client = MagicMock()
  1113. mock_client.ams_set_filament_setting.return_value = True
  1114. mock_client.extrusion_cali_sel.return_value = True
  1115. tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
  1116. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1117. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1118. mock_pm.get_client.return_value = mock_client
  1119. mock_pm.get_status.return_value = status
  1120. response = await async_client.post(
  1121. "/api/v1/inventory/assignments",
  1122. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1123. )
  1124. assert response.status_code == 200
  1125. mock_client.ams_set_filament_setting.assert_called_once()
  1126. body = response.json()
  1127. assert body["pending_config"] is False
  1128. assert body["configured"] is True
  1129. @pytest.mark.asyncio
  1130. @pytest.mark.integration
  1131. async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(
  1132. self, async_client: AsyncClient, printer_factory, spool_factory
  1133. ):
  1134. """External (vt_tray) slot post-reset: same fix applies for ams_id=255."""
  1135. printer = await printer_factory(name="X1C")
  1136. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  1137. mock_client = MagicMock()
  1138. mock_client.ams_set_filament_setting.return_value = True
  1139. # External slot tray_id=0 → vt_tray id=254. state=11 (loaded), tray_type
  1140. # cleared by reset.
  1141. vt_data = [{"id": 254, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}]
  1142. status = _make_mock_status(ams_data=[], vt_tray=vt_data)
  1143. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1144. mock_pm.get_client.return_value = mock_client
  1145. mock_pm.get_status.return_value = status
  1146. response = await async_client.post(
  1147. "/api/v1/inventory/assignments",
  1148. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 255, "tray_id": 0},
  1149. )
  1150. assert response.status_code == 200
  1151. mock_client.ams_set_filament_setting.assert_called_once()
  1152. body = response.json()
  1153. assert body["pending_config"] is False
  1154. assert body["configured"] is True
  1155. class TestAssignSpoolPresenceBit:
  1156. """#3084: the slot the firmware says is full, and the cache says is empty.
  1157. ``apply_tray_exist_bits`` stamps ``state = 9`` on every slot whose
  1158. ``tray_exist_bits`` bit is 0 and annotates ``exists`` on every slot it
  1159. looks at — but when the bit comes back it only refreshes ``exists`` and
  1160. leaves the 9 where it was. Swap a Bambu spool for a non-RFID one and the
  1161. slot sits at ``exists=True, state=9`` until something configures it.
  1162. Reported on an H2D/H2C AMS-HT: remove the Bambu spool (bits ``f``), insert
  1163. a third-party one 9 seconds later (bits ``1000f``), then Assign Spool 28
  1164. seconds after that — and no ``ams_filament_setting`` was published at all.
  1165. Configure worked, because it publishes unconditionally.
  1166. """
  1167. @pytest.mark.asyncio
  1168. @pytest.mark.integration
  1169. async def test_the_bit_overrules_a_stale_empty_state(
  1170. self, async_client: AsyncClient, printer_factory, spool_factory
  1171. ):
  1172. """exists=True with a leftover state=9 — MQTT must fire."""
  1173. printer = await printer_factory(name="H2D")
  1174. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1175. mock_client = MagicMock()
  1176. mock_client.ams_set_filament_setting.return_value = True
  1177. mock_client.extrusion_cali_sel.return_value = True
  1178. tray_data = {"id": 0, "state": 9, "exists": True, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  1179. status = _make_mock_status(ams_data=[{"id": 128, "tray": [tray_data]}])
  1180. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1181. mock_pm.get_client.return_value = mock_client
  1182. mock_pm.get_status.return_value = status
  1183. response = await async_client.post(
  1184. "/api/v1/inventory/assignments",
  1185. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 128, "tray_id": 0},
  1186. )
  1187. assert response.status_code == 200
  1188. mock_client.ams_set_filament_setting.assert_called_once()
  1189. body = response.json()
  1190. assert body["configured"] is True
  1191. assert body["pending_config"] is False
  1192. @pytest.mark.asyncio
  1193. @pytest.mark.integration
  1194. async def test_an_empty_bit_does_not_start_suppressing_pushes(
  1195. self, async_client: AsyncClient, printer_factory, spool_factory
  1196. ):
  1197. """The bit overrules the 9 and nothing else.
  1198. Reading it the other way too would be tidier — skip the push firmware
  1199. is going to drop — but it also means a slot that silently stops
  1200. configuring on whichever AMS variant we compute the bit position
  1201. wrong for. The saving is one MQTT message; the failure is the bug
  1202. this commit is fixing, inverted. So a state that does not say "empty"
  1203. still publishes, exactly as it did before.
  1204. """
  1205. printer = await printer_factory(name="H2D")
  1206. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1207. mock_client = MagicMock()
  1208. mock_client.ams_set_filament_setting.return_value = True
  1209. mock_client.extrusion_cali_sel.return_value = True
  1210. tray_data = {"id": 3, "state": 11, "exists": False, "tray_type": "", "tray_color": ""}
  1211. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1212. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1213. mock_pm.get_client.return_value = mock_client
  1214. mock_pm.get_status.return_value = status
  1215. response = await async_client.post(
  1216. "/api/v1/inventory/assignments",
  1217. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1218. )
  1219. assert response.status_code == 200
  1220. mock_client.ams_set_filament_setting.assert_called_once()
  1221. assert response.json()["pending_config"] is False
  1222. @pytest.mark.asyncio
  1223. @pytest.mark.integration
  1224. async def test_the_pre_assign_workflow_still_skips_a_genuinely_empty_slot(
  1225. self, async_client: AsyncClient, printer_factory, spool_factory
  1226. ):
  1227. """SpoolBuddy weighs a spool and assigns it before it goes in. Bit
  1228. clear and state 9 agree that the slot is empty, so the push is still
  1229. deferred to the replay — unchanged."""
  1230. printer = await printer_factory(name="H2D")
  1231. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1232. mock_client = MagicMock()
  1233. mock_client.ams_set_filament_setting.return_value = True
  1234. tray_data = {"id": 3, "state": 9, "exists": False, "tray_type": "", "tray_color": ""}
  1235. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1236. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1237. mock_pm.get_client.return_value = mock_client
  1238. mock_pm.get_status.return_value = status
  1239. response = await async_client.post(
  1240. "/api/v1/inventory/assignments",
  1241. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1242. )
  1243. assert response.status_code == 200
  1244. mock_client.ams_set_filament_setting.assert_not_called()
  1245. body = response.json()
  1246. assert body["configured"] is False
  1247. assert body["pending_config"] is True
  1248. @pytest.mark.asyncio
  1249. @pytest.mark.integration
  1250. async def test_an_unannotated_tray_still_reads_the_state(
  1251. self, async_client: AsyncClient, printer_factory, spool_factory
  1252. ):
  1253. """No presence bit in the payload → the 9/10 heuristic still decides.
  1254. The external spool's ``vt_tray`` has no bit in the mask, and neither do
  1255. the hand-built payloads every other test in this file uses.
  1256. """
  1257. printer = await printer_factory(name="H2D")
  1258. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1259. mock_client = MagicMock()
  1260. mock_client.ams_set_filament_setting.return_value = True
  1261. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "state": 9, "tray_type": ""}]}])
  1262. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1263. mock_pm.get_client.return_value = mock_client
  1264. mock_pm.get_status.return_value = status
  1265. response = await async_client.post(
  1266. "/api/v1/inventory/assignments",
  1267. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1268. )
  1269. assert response.status_code == 200
  1270. mock_client.ams_set_filament_setting.assert_not_called()
  1271. assert response.json()["pending_config"] is True
  1272. @pytest.mark.asyncio
  1273. @pytest.mark.integration
  1274. async def test_deferred_config_fires_for_a_spool_the_ams_cannot_name(
  1275. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1276. ):
  1277. """The pre-assign workflow's half of the same bug.
  1278. A non-RFID spool inserted into a pre-assigned slot brings no
  1279. ``tray_type`` with it, and the stale 9 kept the replay's "loaded" test
  1280. false, so the deferred configuration never fired for it either. The
  1281. presence bit is the only thing in the payload that changed.
  1282. """
  1283. from unittest.mock import AsyncMock
  1284. from backend.app.main import on_ams_change
  1285. from backend.app.models.spool_assignment import SpoolAssignment
  1286. printer = await printer_factory(name="H2D")
  1287. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1288. pre_assignment = SpoolAssignment(
  1289. spool_id=spool.id,
  1290. printer_id=printer.id,
  1291. ams_id=2,
  1292. tray_id=3,
  1293. fingerprint_color=None,
  1294. fingerprint_type=None,
  1295. )
  1296. db_session.add(pre_assignment)
  1297. await db_session.commit()
  1298. ams_data = [{"id": 2, "tray": [{"id": 3, "state": 9, "exists": True, "tray_type": "", "tray_color": ""}]}]
  1299. mock_client = MagicMock()
  1300. mock_client.ams_set_filament_setting.return_value = True
  1301. mock_client.extrusion_cali_sel.return_value = True
  1302. status = _make_mock_status(ams_data=ams_data)
  1303. with (
  1304. patch("backend.app.main.printer_manager") as mock_pm_main,
  1305. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  1306. patch("backend.app.main.mqtt_relay") as mock_relay,
  1307. patch("backend.app.main.ws_manager") as mock_ws,
  1308. ):
  1309. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1310. mock_pm_main.get_status.return_value = status
  1311. mock_pm_main.get_client.return_value = mock_client
  1312. mock_pm_main.get_model.return_value = "H2D"
  1313. mock_pm_inv.get_client.return_value = mock_client
  1314. mock_pm_inv.get_status.return_value = status
  1315. mock_relay.on_ams_change = AsyncMock()
  1316. mock_ws.send_printer_status = AsyncMock()
  1317. mock_ws.broadcast = AsyncMock()
  1318. await on_ams_change(printer.id, ams_data)
  1319. mock_client.ams_set_filament_setting.assert_called_once()
  1320. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  1321. assert call_kwargs["ams_id"] == 2
  1322. assert call_kwargs["tray_id"] == 3
  1323. assert call_kwargs["tray_info_idx"] == "GFL05"
  1324. # The assignment is still there — the pass that fires the config is the
  1325. # same pass that deletes stale ones (#3100).
  1326. db_session.expunge_all()
  1327. assert await db_session.get(SpoolAssignment, pre_assignment.id) is not None
  1328. class TestAssignSpoolPfcnCloudPreset:
  1329. """Assign path for PFCN-prefix cloud presets (#1648).
  1330. PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
  1331. and GFS (Bambu official) — used for cloud-shared / partner-uploaded
  1332. presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
  1333. the assign path skipped the cloud-detail lookup and left the raw PFCN
  1334. string in tray_info_idx, which the printer's calibration table can't
  1335. resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
  1336. itself, making "Configure" feel like a mandatory follow-up step.
  1337. """
  1338. @pytest.mark.asyncio
  1339. @pytest.mark.integration
  1340. async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
  1341. self, async_client: AsyncClient, printer_factory, spool_factory
  1342. ):
  1343. """When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
  1344. the raw PFCN must be discarded as slicer-invalid and the slot configures
  1345. with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
  1346. was to leak the raw PFCN, which the slicer can't resolve."""
  1347. printer = await printer_factory(name="H2D")
  1348. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1349. mock_client = MagicMock()
  1350. mock_client.ams_set_filament_setting.return_value = True
  1351. mock_client.extrusion_cali_sel.return_value = True
  1352. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  1353. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1354. mock_pm.get_client.return_value = mock_client
  1355. mock_pm.get_status.return_value = status
  1356. response = await async_client.post(
  1357. "/api/v1/inventory/assignments",
  1358. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1359. )
  1360. assert response.status_code == 200
  1361. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1362. # PFCN never leaks into tray_info_idx — must resolve to the
  1363. # generic-material fallback when cloud lookup couldn't.
  1364. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  1365. assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
  1366. @pytest.mark.asyncio
  1367. @pytest.mark.integration
  1368. async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
  1369. """Symmetry with the PFUS case: when the spool's PFCN is discarded as
  1370. slicer-invalid, the slot's existing valid P-prefix preset is reused
  1371. if material matches — preserves calibration context instead of
  1372. resetting to generic."""
  1373. printer = await printer_factory(name="H2D")
  1374. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1375. mock_client = MagicMock()
  1376. mock_client.ams_set_filament_setting.return_value = True
  1377. mock_client.extrusion_cali_sel.return_value = True
  1378. status = _make_mock_status(
  1379. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  1380. )
  1381. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1382. mock_pm.get_client.return_value = mock_client
  1383. mock_pm.get_status.return_value = status
  1384. response = await async_client.post(
  1385. "/api/v1/inventory/assignments",
  1386. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1387. )
  1388. assert response.status_code == 200
  1389. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1390. assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
  1391. @pytest.mark.asyncio
  1392. @pytest.mark.integration
  1393. async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
  1394. self, async_client: AsyncClient, printer_factory, spool_factory
  1395. ):
  1396. """When the user is authenticated against Bambu Cloud, the PFCN setting_id
  1397. triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
  1398. filament_id from `detail["filament_id"]` and ships that as
  1399. tray_info_idx. This is the happy path the Configure modal already had
  1400. but the assign path didn't, #1648."""
  1401. printer = await printer_factory(name="H2D")
  1402. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1403. mock_client = MagicMock()
  1404. mock_client.ams_set_filament_setting.return_value = True
  1405. mock_client.extrusion_cali_sel.return_value = True
  1406. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  1407. # Cloud responds with a real filament_id for the PFCN preset — exactly
  1408. # what the Configure modal already exploits.
  1409. mock_cloud = MagicMock()
  1410. mock_cloud.is_authenticated = True
  1411. async def fake_get_detail(setting_id):
  1412. assert setting_id == "PFCN80e80c1f79db85"
  1413. return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
  1414. async def fake_close():
  1415. return None
  1416. mock_cloud.get_setting_detail = fake_get_detail
  1417. mock_cloud.close = fake_close
  1418. async def fake_build_cloud(_db, _user):
  1419. return mock_cloud
  1420. with (
  1421. patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
  1422. patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
  1423. ):
  1424. mock_pm.get_client.return_value = mock_client
  1425. mock_pm.get_status.return_value = status
  1426. response = await async_client.post(
  1427. "/api/v1/inventory/assignments",
  1428. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1429. )
  1430. assert response.status_code == 200
  1431. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1432. # tray_info_idx is the resolved cloud filament_id; setting_id is the
  1433. # original PFCN (which the slicer needs separately).
  1434. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  1435. assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"
  1436. def _make_printing_status(ams_data, state="RUNNING"):
  1437. """Printer status carrying an explicit gcode state for the runout guard."""
  1438. status = _make_mock_status(ams_data=ams_data)
  1439. status.state = state
  1440. return status
  1441. class TestAutoUnlinkDuringRunout:
  1442. """A slot that reports empty mid-print is a filament runout, not a spool
  1443. swap — the spool is still in the AMS, just consumed.
  1444. Unlinking there erased the only record of which spool fed the print, so the
  1445. completion path had nothing to charge the runout segment to. With AMS
  1446. filament backup that is the normal course of events, not an edge case.
  1447. """
  1448. @pytest.mark.asyncio
  1449. @pytest.mark.integration
  1450. async def test_cleared_tray_data_keeps_the_assignment_while_printing(
  1451. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1452. ):
  1453. from unittest.mock import AsyncMock
  1454. from backend.app.main import on_ams_change
  1455. from backend.app.models.spool_assignment import SpoolAssignment
  1456. printer = await printer_factory(name="H2D")
  1457. spool = await spool_factory(material="ABS", rgba="616777FF")
  1458. assignment = SpoolAssignment(
  1459. spool_id=spool.id,
  1460. printer_id=printer.id,
  1461. ams_id=0,
  1462. tray_id=2,
  1463. fingerprint_color="616777FF",
  1464. fingerprint_type="ABS",
  1465. )
  1466. db_session.add(assignment)
  1467. await db_session.commit()
  1468. # The firmware clears colour and type when it unloads a spool it just
  1469. # emptied (state 26 = "not loaded").
  1470. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
  1471. with (
  1472. patch("backend.app.main.printer_manager") as mock_pm_main,
  1473. patch("backend.app.main.mqtt_relay") as mock_relay,
  1474. patch("backend.app.main.ws_manager") as mock_ws,
  1475. ):
  1476. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1477. mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
  1478. mock_pm_main.get_model.return_value = "H2D"
  1479. mock_relay.on_ams_change = AsyncMock()
  1480. mock_ws.send_printer_status = AsyncMock()
  1481. mock_ws.broadcast = AsyncMock()
  1482. await on_ams_change(printer.id, ams_data)
  1483. # on_ams_change committed through its own session — drop this one's
  1484. # identity map so the assertion reads the database, not a cached row.
  1485. db_session.expunge_all()
  1486. remaining = await db_session.get(SpoolAssignment, assignment.id)
  1487. assert remaining is not None, "runout must not unlink the spool that fed the print"
  1488. @pytest.mark.asyncio
  1489. @pytest.mark.integration
  1490. async def test_cleared_tray_data_still_unlinks_when_idle(
  1491. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1492. ):
  1493. """Off the print, an emptied slot really does mean the spool is gone --
  1494. once it has stayed empty for the grace period (#3186)."""
  1495. from unittest.mock import AsyncMock
  1496. from backend.app.main import on_ams_change
  1497. from backend.app.models.spool_assignment import SpoolAssignment
  1498. from backend.app.services import slot_unlink_grace
  1499. printer = await printer_factory(name="H2D")
  1500. spool = await spool_factory(material="ABS", rgba="616777FF")
  1501. assignment = SpoolAssignment(
  1502. spool_id=spool.id,
  1503. printer_id=printer.id,
  1504. ams_id=0,
  1505. tray_id=2,
  1506. fingerprint_color="616777FF",
  1507. fingerprint_type="ABS",
  1508. )
  1509. db_session.add(assignment)
  1510. await db_session.commit()
  1511. assignment_id = assignment.id
  1512. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
  1513. with (
  1514. patch("backend.app.main.printer_manager") as mock_pm_main,
  1515. patch("backend.app.main.mqtt_relay") as mock_relay,
  1516. patch("backend.app.main.ws_manager") as mock_ws,
  1517. ):
  1518. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1519. mock_pm_main.get_status.return_value = _make_printing_status(ams_data, state="IDLE")
  1520. mock_pm_main.get_model.return_value = "H2D"
  1521. mock_relay.on_ams_change = AsyncMock()
  1522. mock_ws.send_printer_status = AsyncMock()
  1523. mock_ws.broadcast = AsyncMock()
  1524. clock = [1000.0]
  1525. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1526. await on_ams_change(printer.id, ams_data)
  1527. db_session.expunge_all()
  1528. assert await db_session.get(SpoolAssignment, assignment_id) is not None
  1529. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1530. await on_ams_change(printer.id, ams_data)
  1531. db_session.expunge_all()
  1532. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1533. @pytest.mark.asyncio
  1534. @pytest.mark.integration
  1535. async def test_a_genuinely_different_filament_still_unlinks_while_printing(
  1536. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1537. ):
  1538. """The guard is for blank tray data only — a real swap must still
  1539. reconcile, or the wrong spool gets charged."""
  1540. from unittest.mock import AsyncMock
  1541. from backend.app.main import on_ams_change
  1542. from backend.app.models.spool_assignment import SpoolAssignment
  1543. printer = await printer_factory(name="H2D")
  1544. spool = await spool_factory(material="ABS", rgba="616777FF")
  1545. assignment = SpoolAssignment(
  1546. spool_id=spool.id,
  1547. printer_id=printer.id,
  1548. ams_id=0,
  1549. tray_id=2,
  1550. fingerprint_color="616777FF",
  1551. fingerprint_type="ABS",
  1552. )
  1553. db_session.add(assignment)
  1554. await db_session.commit()
  1555. assignment_id = assignment.id
  1556. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "state": 11}]}]
  1557. with (
  1558. patch("backend.app.main.printer_manager") as mock_pm_main,
  1559. patch("backend.app.main.mqtt_relay") as mock_relay,
  1560. patch("backend.app.main.ws_manager") as mock_ws,
  1561. ):
  1562. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1563. mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
  1564. mock_pm_main.get_model.return_value = "H2D"
  1565. mock_relay.on_ams_change = AsyncMock()
  1566. mock_ws.send_printer_status = AsyncMock()
  1567. mock_ws.broadcast = AsyncMock()
  1568. await on_ams_change(printer.id, ams_data)
  1569. db_session.expunge_all()
  1570. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1571. @pytest.mark.asyncio
  1572. @pytest.mark.integration
  1573. async def test_slot_missing_from_ams_data_keeps_the_assignment_while_printing(
  1574. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1575. ):
  1576. from unittest.mock import AsyncMock
  1577. from backend.app.main import on_ams_change
  1578. from backend.app.models.spool_assignment import SpoolAssignment
  1579. printer = await printer_factory(name="H2D")
  1580. spool = await spool_factory(material="ABS", rgba="616777FF")
  1581. assignment = SpoolAssignment(
  1582. spool_id=spool.id,
  1583. printer_id=printer.id,
  1584. ams_id=0,
  1585. tray_id=2,
  1586. fingerprint_color="616777FF",
  1587. fingerprint_type="ABS",
  1588. )
  1589. db_session.add(assignment)
  1590. await db_session.commit()
  1591. assignment_id = assignment.id
  1592. # Tray 2 dropped out of the payload entirely.
  1593. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "ABS", "tray_color": "FFFFFFFF", "state": 11}]}]
  1594. with (
  1595. patch("backend.app.main.printer_manager") as mock_pm_main,
  1596. patch("backend.app.main.mqtt_relay") as mock_relay,
  1597. patch("backend.app.main.ws_manager") as mock_ws,
  1598. ):
  1599. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1600. mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
  1601. mock_pm_main.get_model.return_value = "H2D"
  1602. mock_relay.on_ams_change = AsyncMock()
  1603. mock_ws.send_printer_status = AsyncMock()
  1604. mock_ws.broadcast = AsyncMock()
  1605. await on_ams_change(printer.id, ams_data)
  1606. db_session.expunge_all()
  1607. assert await db_session.get(SpoolAssignment, assignment_id) is not None
  1608. class TestAutoUnlinkOccupiedSlot:
  1609. """#3100: six saved assignments deleted across three X1 Carbons.
  1610. Each one had an explicit earlier assignment and then an ``Auto-unlink ...
  1611. fingerprint mismatch``, and the reporter recovered the mappings from logs
  1612. because they were gone from the inventory API, not merely hidden. Two
  1613. shapes, one cause: a slot the presence bit calls occupied while the tray
  1614. reports nothing about what is in it.
  1615. ``cur=/ fp=BCBCBCFF/PLA spool=8A8F92FF/PLA`` — an established assignment,
  1616. a blank idle report, and the row deleted. The spool never went anywhere;
  1617. the AMS simply had nothing to say about a filament it cannot read.
  1618. """
  1619. @staticmethod
  1620. async def _run(printer_id, ams_data, status):
  1621. from unittest.mock import AsyncMock
  1622. from backend.app.main import on_ams_change
  1623. with (
  1624. patch("backend.app.main.printer_manager") as mock_pm_main,
  1625. patch("backend.app.main.mqtt_relay") as mock_relay,
  1626. patch("backend.app.main.ws_manager") as mock_ws,
  1627. ):
  1628. mock_pm_main.get_printer.return_value = MagicMock(name="X1C", serial_number="0948BB540200427")
  1629. mock_pm_main.get_status.return_value = status
  1630. mock_pm_main.get_model.return_value = "X1C"
  1631. mock_pm_main.get_client.return_value = None
  1632. mock_relay.on_ams_change = AsyncMock()
  1633. mock_ws.send_printer_status = AsyncMock()
  1634. mock_ws.broadcast = AsyncMock()
  1635. await on_ams_change(printer_id, ams_data)
  1636. @staticmethod
  1637. async def _assignment(db_session, printer, spool):
  1638. from backend.app.models.spool_assignment import SpoolAssignment
  1639. assignment = SpoolAssignment(
  1640. spool_id=spool.id,
  1641. printer_id=printer.id,
  1642. ams_id=1,
  1643. tray_id=1,
  1644. fingerprint_color="BCBCBCFF",
  1645. fingerprint_type="PLA",
  1646. )
  1647. db_session.add(assignment)
  1648. await db_session.commit()
  1649. return assignment.id
  1650. @pytest.mark.asyncio
  1651. @pytest.mark.integration
  1652. async def test_a_blank_report_from_an_occupied_slot_keeps_the_assignment(
  1653. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1654. ):
  1655. from backend.app.models.spool_assignment import SpoolAssignment
  1656. printer = await printer_factory(name="X1C")
  1657. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1658. assignment_id = await self._assignment(db_session, printer, spool)
  1659. ams_data = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1660. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1661. db_session.expunge_all()
  1662. assert await db_session.get(SpoolAssignment, assignment_id) is not None, (
  1663. "a spool the AMS cannot identify is not a spool that was removed"
  1664. )
  1665. @pytest.mark.asyncio
  1666. @pytest.mark.integration
  1667. async def test_a_blank_report_from_an_empty_slot_still_unlinks(
  1668. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1669. ):
  1670. """The guard reads the bit, not the blankness — take the spool out and
  1671. the assignment still goes, which is what makes the test above a
  1672. distinction rather than a blanket reprieve."""
  1673. from backend.app.models.spool_assignment import SpoolAssignment
  1674. from backend.app.services import slot_unlink_grace
  1675. printer = await printer_factory(name="X1C")
  1676. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1677. assignment_id = await self._assignment(db_session, printer, spool)
  1678. ams_data = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1679. clock = [1000.0]
  1680. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1681. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1682. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1683. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1684. db_session.expunge_all()
  1685. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1686. @pytest.mark.asyncio
  1687. @pytest.mark.integration
  1688. async def test_a_genuinely_different_filament_in_an_occupied_slot_still_unlinks(
  1689. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1690. ):
  1691. """The guard is for a blank report only. A slot that names a filament
  1692. which is not the assigned spool is a swap, bit set or not."""
  1693. from backend.app.models.spool_assignment import SpoolAssignment
  1694. printer = await printer_factory(name="X1C")
  1695. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1696. assignment_id = await self._assignment(db_session, printer, spool)
  1697. ams_data = [
  1698. {"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}
  1699. ]
  1700. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1701. db_session.expunge_all()
  1702. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1703. class TestAutoUnlinkHeldForGrace:
  1704. """#3186: one push is not proof a spool was taken out.
  1705. An idle X1 Carbon on X1Plus sent a push that cleared a whole AMS unit --
  1706. presence bits off, colour and type blank -- and four saved assignments were
  1707. deleted in the same instant (``Auto-unlinked 4 stale spool assignments``).
  1708. The spools never moved. A removal is now held for the grace period and
  1709. unlinked only if the slot is still empty then.
  1710. """
  1711. @staticmethod
  1712. async def _assign_unit(db_session, printer, spool):
  1713. from backend.app.models.spool_assignment import SpoolAssignment
  1714. ids = []
  1715. for tray_id in range(4):
  1716. a = SpoolAssignment(
  1717. spool_id=spool.id,
  1718. printer_id=printer.id,
  1719. ams_id=1,
  1720. tray_id=tray_id,
  1721. fingerprint_color="858585FF",
  1722. fingerprint_type="PLA",
  1723. )
  1724. db_session.add(a)
  1725. await db_session.commit()
  1726. ids.append(a.id)
  1727. return ids
  1728. @staticmethod
  1729. def _unit(blank: bool):
  1730. return [
  1731. {
  1732. "id": 1,
  1733. "tray": [
  1734. {"id": t, "exists": False, "tray_type": "", "tray_color": "", "state": 9}
  1735. if blank
  1736. else {"id": t, "exists": True, "tray_type": "PLA", "tray_color": "858585FF", "state": 11}
  1737. for t in range(4)
  1738. ],
  1739. }
  1740. ]
  1741. @staticmethod
  1742. async def _surviving(db_session, ids):
  1743. from backend.app.models.spool_assignment import SpoolAssignment
  1744. db_session.expunge_all()
  1745. return [i for i in ids if await db_session.get(SpoolAssignment, i) is not None]
  1746. @pytest.mark.asyncio
  1747. @pytest.mark.integration
  1748. async def test_a_whole_unit_blanking_for_one_push_keeps_every_assignment(
  1749. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1750. ):
  1751. from backend.app.services import slot_unlink_grace
  1752. printer = await printer_factory(name="X1C")
  1753. spool = await spool_factory(material="PLA", rgba="000000FF")
  1754. ids = await self._assign_unit(db_session, printer, spool)
  1755. clock = [1000.0]
  1756. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1757. blank, back = self._unit(blank=True), self._unit(blank=False)
  1758. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1759. assert await self._surviving(db_session, ids) == ids
  1760. clock[0] += 30
  1761. await TestAutoUnlinkOccupiedSlot._run(printer.id, back, _make_printing_status(back, state="IDLE"))
  1762. # The recovery dropped the holds, so a blank long after the first
  1763. # one starts a fresh clock instead of confirming the old one.
  1764. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1765. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1766. assert await self._surviving(db_session, ids) == ids
  1767. @pytest.mark.asyncio
  1768. @pytest.mark.integration
  1769. async def test_a_unit_missing_from_one_push_keeps_its_assignments(
  1770. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1771. ):
  1772. printer = await printer_factory(name="X1C")
  1773. spool = await spool_factory(material="PLA", rgba="000000FF")
  1774. ids = await self._assign_unit(db_session, printer, spool)
  1775. # AMS 1 dropped out of the payload entirely.
  1776. other_unit = [{"id": 0, "tray": [{"id": 0, "exists": True, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]
  1777. await TestAutoUnlinkOccupiedSlot._run(printer.id, other_unit, _make_printing_status(other_unit, state="IDLE"))
  1778. assert await self._surviving(db_session, ids) == ids
  1779. @pytest.mark.asyncio
  1780. @pytest.mark.integration
  1781. async def test_a_slot_that_stays_empty_is_unlinked_by_the_recheck(
  1782. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1783. ):
  1784. """No second push has to arrive: a spool taken out and left out never
  1785. changes the AMS hash again, so the held removal re-checks itself."""
  1786. from backend.app.main import _recheck_held_unlinks
  1787. from backend.app.services import slot_unlink_grace
  1788. printer = await printer_factory(name="X1C")
  1789. spool = await spool_factory(material="PLA", rgba="000000FF")
  1790. ids = await self._assign_unit(db_session, printer, spool)
  1791. blank = self._unit(blank=True)
  1792. clock = [1000.0]
  1793. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1794. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1795. assert printer.id in slot_unlink_grace._recheck_tasks, "a held removal must schedule its re-check"
  1796. assert await self._surviving(db_session, ids) == ids
  1797. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1798. with (
  1799. patch("backend.app.main.printer_manager") as mock_pm_main,
  1800. patch("backend.app.main.ws_manager") as mock_ws,
  1801. ):
  1802. mock_pm_main.get_status.return_value = _make_printing_status(blank, state="IDLE")
  1803. mock_ws.broadcast = AsyncMock()
  1804. await _recheck_held_unlinks(printer.id)
  1805. assert await self._surviving(db_session, ids) == []
  1806. @pytest.mark.asyncio
  1807. @pytest.mark.integration
  1808. async def test_the_recheck_leaves_a_slot_that_came_back(
  1809. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1810. ):
  1811. from backend.app.main import _recheck_held_unlinks
  1812. from backend.app.services import slot_unlink_grace
  1813. printer = await printer_factory(name="X1C")
  1814. spool = await spool_factory(material="PLA", rgba="000000FF")
  1815. ids = await self._assign_unit(db_session, printer, spool)
  1816. blank, back = self._unit(blank=True), self._unit(blank=False)
  1817. clock = [1000.0]
  1818. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1819. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1820. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1821. with (
  1822. patch("backend.app.main.printer_manager") as mock_pm_main,
  1823. patch("backend.app.main.ws_manager") as mock_ws,
  1824. ):
  1825. mock_pm_main.get_status.return_value = _make_printing_status(back, state="IDLE")
  1826. mock_ws.broadcast = AsyncMock()
  1827. await _recheck_held_unlinks(printer.id)
  1828. assert await self._surviving(db_session, ids) == ids
  1829. class TestAutoUnlinkSwapDuringGrace:
  1830. """A non-RFID spool swapped out and another put in within the grace period.
  1831. The new spool reports occupied-but-blank, which on its own keeps an
  1832. assignment (#3100). After an *empty* report it means the spool changed,
  1833. so the held removal has to keep running rather than be cancelled -- or the
  1834. old spool would stay assigned to the new one indefinitely.
  1835. """
  1836. @pytest.mark.asyncio
  1837. @pytest.mark.integration
  1838. async def test_a_swap_still_unlinks_once_the_grace_runs_out(
  1839. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1840. ):
  1841. from backend.app.models.spool_assignment import SpoolAssignment
  1842. from backend.app.services import slot_unlink_grace
  1843. printer = await printer_factory(name="X1C")
  1844. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1845. assignment_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
  1846. taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1847. new_spool = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1848. clock = [1000.0]
  1849. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1850. await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
  1851. clock[0] += 20
  1852. await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
  1853. db_session.expunge_all()
  1854. assert await db_session.get(SpoolAssignment, assignment_id) is not None
  1855. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1856. await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
  1857. db_session.expunge_all()
  1858. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1859. @pytest.mark.asyncio
  1860. @pytest.mark.integration
  1861. async def test_a_new_assignment_to_the_slot_starts_clean(
  1862. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1863. ):
  1864. """Assigning a spool to the slot after the swap -- even the same spool
  1865. again -- makes a new row, and the old clock does not carry over to it."""
  1866. from backend.app.models.spool_assignment import SpoolAssignment
  1867. from backend.app.services import slot_unlink_grace
  1868. printer = await printer_factory(name="X1C")
  1869. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1870. old_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
  1871. taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1872. back_in = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1873. clock = [1000.0]
  1874. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1875. await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
  1876. # The user puts the spool back and assigns it again through the
  1877. # endpoint, which replaces the row -- on SQLite quite possibly under
  1878. # the same id, so only dropping the hold keeps the old clock off it.
  1879. hold_key = ("inventory", 1, 1, spool.id)
  1880. assert slot_unlink_grace.is_held(printer.id, hold_key)
  1881. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1882. mock_pm.get_client.return_value = None
  1883. mock_pm.get_status.return_value = _make_mock_status(ams_data=back_in)
  1884. response = await async_client.post(
  1885. "/api/v1/inventory/assignments",
  1886. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 1, "tray_id": 1},
  1887. )
  1888. assert response.status_code == 200
  1889. assert not slot_unlink_grace.is_held(printer.id, hold_key), "an assignment must drop the old hold"
  1890. new_id = response.json()["id"]
  1891. assert await db_session.get(SpoolAssignment, old_id) is None or new_id == old_id
  1892. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1893. await TestAutoUnlinkOccupiedSlot._run(printer.id, back_in, _make_printing_status(back_in, state="IDLE"))
  1894. db_session.expunge_all()
  1895. assert await db_session.get(SpoolAssignment, new_id) is not None
  1896. class TestSpoolmanSlotUnlinkHeldForGrace:
  1897. """Spoolman mode's half of #3186: the slot row waits out the same grace."""
  1898. @pytest.mark.asyncio
  1899. @pytest.mark.integration
  1900. async def test_the_recheck_deletes_a_row_only_once_the_slot_stayed_empty(
  1901. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1902. ):
  1903. from backend.app.main import _expire_spoolman_empty_slots
  1904. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1905. from backend.app.services import slot_unlink_grace
  1906. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1907. printer = await printer_factory(name="H2D")
  1908. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1909. db_session.add(row)
  1910. await db_session.commit()
  1911. row_id = row.id
  1912. blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1913. clock = [1000.0]
  1914. with (
  1915. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1916. patch("backend.app.main.ws_manager") as mock_ws,
  1917. ):
  1918. mock_ws.broadcast = AsyncMock()
  1919. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  1920. db_session.expunge_all()
  1921. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1922. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1923. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  1924. db_session.expunge_all()
  1925. assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
  1926. mock_ws.broadcast.assert_awaited()
  1927. @pytest.mark.asyncio
  1928. @pytest.mark.integration
  1929. async def test_a_swap_to_an_unreadable_spool_still_expires_the_row(
  1930. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1931. ):
  1932. from backend.app.main import _expire_spoolman_empty_slots
  1933. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1934. from backend.app.services import slot_unlink_grace
  1935. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1936. printer = await printer_factory(name="H2D")
  1937. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1938. db_session.add(row)
  1939. await db_session.commit()
  1940. row_id = row.id
  1941. taken_out = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1942. new_spool = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1943. clock = [1000.0]
  1944. with (
  1945. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1946. patch("backend.app.main.ws_manager") as mock_ws,
  1947. ):
  1948. mock_ws.broadcast = AsyncMock()
  1949. await _expire_spoolman_empty_slots(printer.id, taken_out, printing_now=False)
  1950. clock[0] += 20
  1951. await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
  1952. db_session.expunge_all()
  1953. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1954. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1955. await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
  1956. db_session.expunge_all()
  1957. assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
  1958. @pytest.mark.asyncio
  1959. @pytest.mark.integration
  1960. async def test_an_occupied_unreadable_slot_with_no_hold_keeps_its_row(
  1961. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1962. ):
  1963. """#3100 still holds for the re-check: never reported empty, never unlinked."""
  1964. from backend.app.main import _expire_spoolman_empty_slots
  1965. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1966. from backend.app.services import slot_unlink_grace
  1967. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1968. printer = await printer_factory(name="H2D")
  1969. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1970. db_session.add(row)
  1971. await db_session.commit()
  1972. row_id = row.id
  1973. unreadable = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1974. clock = [1000.0]
  1975. with (
  1976. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1977. patch("backend.app.main.ws_manager") as mock_ws,
  1978. ):
  1979. mock_ws.broadcast = AsyncMock()
  1980. for _ in range(3):
  1981. await _expire_spoolman_empty_slots(printer.id, unreadable, printing_now=False)
  1982. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1983. db_session.expunge_all()
  1984. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1985. @pytest.mark.asyncio
  1986. @pytest.mark.integration
  1987. async def test_the_recheck_keeps_a_row_whose_slot_reads_again(
  1988. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1989. ):
  1990. from backend.app.main import _expire_spoolman_empty_slots
  1991. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1992. from backend.app.services import slot_unlink_grace
  1993. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1994. printer = await printer_factory(name="H2D")
  1995. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1996. db_session.add(row)
  1997. await db_session.commit()
  1998. row_id = row.id
  1999. blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  2000. back = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "PLA", "tray_color": "858585FF"}]}]
  2001. clock = [1000.0]
  2002. with (
  2003. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  2004. patch("backend.app.main.ws_manager") as mock_ws,
  2005. ):
  2006. mock_ws.broadcast = AsyncMock()
  2007. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  2008. clock[0] += 30
  2009. await _expire_spoolman_empty_slots(printer.id, back, printing_now=False)
  2010. clock[0] += slot_unlink_grace.GRACE_SECONDS
  2011. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  2012. db_session.expunge_all()
  2013. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  2014. class TestSpoolmanSlotAssignmentDuringRunout:
  2015. """`spoolman_slot_assignments` is how a tag-less spool assigned through the
  2016. Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
  2017. empties mid-print loses the runout segment's usage — the same failure the
  2018. internal inventory's auto-unlink had, so it needs the same guard."""
  2019. async def _enable_spoolman(self, db_session):
  2020. from backend.app.models.settings import Settings
  2021. for key, value in (
  2022. ("spoolman_enabled", "true"),
  2023. ("spoolman_sync_mode", "auto"),
  2024. ("spoolman_url", "http://spoolman.test"),
  2025. ):
  2026. db_session.add(Settings(key=key, value=value))
  2027. await db_session.commit()
  2028. async def _run(self, printer_id, state, tray=None):
  2029. from unittest.mock import AsyncMock
  2030. from backend.app.main import on_ams_change
  2031. # A tray the firmware has cleared: parse_ams_tray returns None, which
  2032. # is what marks the slot empty for the cleanup pass.
  2033. ams_data = [{"id": 0, "tray": [tray or {"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
  2034. spoolman_client = MagicMock()
  2035. spoolman_client.health_check = AsyncMock(return_value=True)
  2036. spoolman_client.get_spools = AsyncMock(return_value=[])
  2037. spoolman_client.sync_ams_tray = AsyncMock(return_value=None)
  2038. # None is what marks the slot empty for the cleanup pass.
  2039. spoolman_client.parse_ams_tray.return_value = None
  2040. with (
  2041. patch("backend.app.main.printer_manager") as mock_pm_main,
  2042. patch("backend.app.main.mqtt_relay") as mock_relay,
  2043. patch("backend.app.main.ws_manager") as mock_ws,
  2044. patch("backend.app.main.get_spoolman_client", new=AsyncMock(return_value=spoolman_client)),
  2045. ):
  2046. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  2047. mock_pm_main.get_status.return_value = state
  2048. mock_pm_main.get_model.return_value = "H2D"
  2049. mock_relay.on_ams_change = AsyncMock()
  2050. mock_ws.send_printer_status = AsyncMock()
  2051. mock_ws.broadcast = AsyncMock()
  2052. await on_ams_change(printer_id, ams_data)
  2053. @pytest.mark.asyncio
  2054. @pytest.mark.integration
  2055. async def test_the_slot_row_survives_a_runout(
  2056. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  2057. ):
  2058. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2059. await self._enable_spoolman(db_session)
  2060. printer = await printer_factory(name="H2D")
  2061. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  2062. db_session.add(row)
  2063. await db_session.commit()
  2064. row_id = row.id
  2065. await self._run(printer.id, _make_printing_status(None))
  2066. db_session.expunge_all()
  2067. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  2068. @pytest.mark.asyncio
  2069. @pytest.mark.integration
  2070. async def test_an_occupied_slot_the_ams_cannot_read_keeps_its_row(
  2071. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  2072. ):
  2073. """Spoolman mode's half of #3100.
  2074. parse_ams_tray calls a tray with no type empty, and a tag-less spool
  2075. has none until something configures it — so the row assigned through
  2076. the UI was deleted by the first idle push after the spool went in.
  2077. Firmware's presence bit is the same answer the built-in inventory
  2078. uses, so the two modes stay in step.
  2079. """
  2080. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2081. await self._enable_spoolman(db_session)
  2082. printer = await printer_factory(name="H2D")
  2083. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  2084. db_session.add(row)
  2085. await db_session.commit()
  2086. row_id = row.id
  2087. await self._run(
  2088. printer.id,
  2089. _make_printing_status(None, state="IDLE"),
  2090. tray={"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9},
  2091. )
  2092. db_session.expunge_all()
  2093. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  2094. @pytest.mark.asyncio
  2095. @pytest.mark.integration
  2096. async def test_the_slot_row_is_still_cleaned_up_when_idle(
  2097. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  2098. ):
  2099. """Proves the guard is what saved the row above, not an unreachable
  2100. code path -- once the slot has stayed empty for the grace period."""
  2101. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2102. from backend.app.services import slot_unlink_grace
  2103. await self._enable_spoolman(db_session)
  2104. printer = await printer_factory(name="H2D")
  2105. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  2106. db_session.add(row)
  2107. await db_session.commit()
  2108. row_id = row.id
  2109. clock = [1000.0]
  2110. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  2111. await self._run(printer.id, _make_printing_status(None, state="IDLE"))
  2112. db_session.expunge_all()
  2113. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  2114. clock[0] += slot_unlink_grace.GRACE_SECONDS
  2115. await self._run(printer.id, _make_printing_status(None, state="IDLE"))
  2116. db_session.expunge_all()
  2117. assert await db_session.get(SpoolmanSlotAssignment, row_id) is None