test_spoolman_slot_assignment_mqtt.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100
  1. """Integration tests for MQTT auto-configuration when assigning a Spoolman spool to an AMS slot.
  2. Covers:
  3. - ams_set_filament_setting is called with correct parameters on assign
  4. - extrusion_cali_sel is called when a matching K-profile exists
  5. - MQTT failure does NOT roll back the slot assignment
  6. """
  7. import json
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. SAMPLE_SPOOL = {
  12. "id": 10,
  13. "filament": {
  14. "id": 1,
  15. "name": "PLA Basic",
  16. "material": "PLA",
  17. "color_hex": "FF0000",
  18. "weight": 1000,
  19. "vendor": {"id": 1, "name": "BrandX"},
  20. },
  21. "remaining_weight": 800.0,
  22. "used_weight": 200.0,
  23. "location": None,
  24. "comment": None,
  25. "first_used": None,
  26. "last_used": None,
  27. "registered": "2024-01-01T00:00:00+00:00",
  28. "archived": False,
  29. "price": None,
  30. "extra": {},
  31. }
  32. @pytest.fixture
  33. async def slot_settings(db_session):
  34. from backend.app.models.settings import Settings
  35. db_session.add(Settings(key="spoolman_enabled", value="true"))
  36. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  37. await db_session.commit()
  38. @pytest.fixture
  39. async def test_printer(db_session):
  40. from backend.app.models.printer import Printer
  41. printer = Printer(
  42. name="MQTT Printer",
  43. serial_number="MQTTTEST001",
  44. ip_address="192.168.1.200",
  45. access_code="12345678",
  46. )
  47. db_session.add(printer)
  48. await db_session.commit()
  49. await db_session.refresh(printer)
  50. return printer
  51. @pytest.fixture
  52. def mock_spoolman_client():
  53. client = MagicMock()
  54. client.base_url = "http://localhost:7912"
  55. client.health_check = AsyncMock(return_value=True)
  56. client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
  57. # #1457: assign route enumerates spools to clear stale fallback-tag links.
  58. client.get_spools = AsyncMock(return_value=[])
  59. client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
  60. client.get_distinct_locations = AsyncMock(return_value=[])
  61. with patch(
  62. "backend.app.api.routes.spoolman_inventory._get_client",
  63. AsyncMock(return_value=client),
  64. ):
  65. yield client
  66. class TestAssignSlotMqtt:
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_mqtt_ams_set_filament_called_on_assign(
  70. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  71. ):
  72. """Assigning a Spoolman spool fires ams_set_filament_setting via MQTT."""
  73. mqtt_mock = MagicMock()
  74. mqtt_mock.ams_set_filament_setting = MagicMock()
  75. mqtt_mock.extrusion_cali_sel = MagicMock()
  76. mqtt_mock.printer_state = None
  77. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  78. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  79. response = await async_client.post(
  80. "/api/v1/spoolman/inventory/slot-assignments",
  81. json={
  82. "spoolman_spool_id": 10,
  83. "printer_id": test_printer.id,
  84. "ams_id": 0,
  85. "tray_id": 1,
  86. },
  87. )
  88. assert response.status_code == 200
  89. mqtt_mock.ams_set_filament_setting.assert_called_once()
  90. call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  91. assert call_kwargs["ams_id"] == 0
  92. assert call_kwargs["tray_id"] == 1
  93. assert call_kwargs["tray_type"] == "PLA"
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_a_silk_spool_gets_the_silk_generic(
  97. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  98. ):
  99. """Same generic as internal mode: the finish picks Generic PLA Silk."""
  100. mock_spoolman_client.get_spool = AsyncMock(
  101. return_value={**SAMPLE_SPOOL, "filament": {**SAMPLE_SPOOL["filament"], "name": "PLA Silk+"}}
  102. )
  103. mqtt_mock = MagicMock()
  104. mqtt_mock.printer_state = None
  105. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  106. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  107. response = await async_client.post(
  108. "/api/v1/spoolman/inventory/slot-assignments",
  109. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 1},
  110. )
  111. assert response.status_code == 200
  112. assert mqtt_mock.ams_set_filament_setting.call_args[1]["tray_info_idx"] == "GFL96"
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_mqtt_failure_does_not_rollback_assignment(
  116. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  117. ):
  118. """A crash inside the MQTT block must not un-persist the slot assignment."""
  119. mqtt_mock = MagicMock()
  120. mqtt_mock.ams_set_filament_setting = MagicMock(side_effect=RuntimeError("MQTT down"))
  121. mqtt_mock.printer_state = None
  122. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  123. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  124. response = await async_client.post(
  125. "/api/v1/spoolman/inventory/slot-assignments",
  126. json={
  127. "spoolman_spool_id": 10,
  128. "printer_id": test_printer.id,
  129. "ams_id": 1,
  130. "tray_id": 0,
  131. },
  132. )
  133. assert response.status_code == 200
  134. # Verify the assignment IS in the DB despite the MQTT crash
  135. all_resp = await async_client.get(
  136. "/api/v1/spoolman/inventory/slot-assignments/all",
  137. params={"printer_id": test_printer.id},
  138. )
  139. assert all_resp.status_code == 200
  140. rows = all_resp.json()
  141. assert any(r["spoolman_spool_id"] == 10 for r in rows)
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_extrusion_cali_sel_called_when_k_profile_exists(
  145. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  146. ):
  147. """extrusion_cali_sel is fired when a matching SpoolmanKProfile row exists."""
  148. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  149. kp = SpoolmanKProfile(
  150. spoolman_spool_id=10,
  151. printer_id=test_printer.id,
  152. extruder=0,
  153. nozzle_diameter="0.4",
  154. k_value=0.02,
  155. cali_idx=5,
  156. setting_id="CaliID",
  157. )
  158. db_session.add(kp)
  159. await db_session.commit()
  160. printer_state = MagicMock()
  161. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  162. printer_state.ams_extruder_map = {"0": 0}
  163. mqtt_mock = MagicMock()
  164. mqtt_mock.ams_set_filament_setting = MagicMock()
  165. mqtt_mock.extrusion_cali_sel = MagicMock()
  166. # Legacy attribute — production never had it set; keep for any code
  167. # path that still reads `mqtt_client.printer_state` directly. State
  168. # for the K-profile cascade now comes from printer_manager.get_status.
  169. mqtt_mock.printer_state = printer_state
  170. # Empty list = no printer-side kprofiles, so the realignment skips
  171. # printer_kp lookup. Tests that exercise realignment explicitly
  172. # populate this list themselves.
  173. if (
  174. not hasattr(printer_state, "kprofiles")
  175. or printer_state.kprofiles is None
  176. or isinstance(printer_state.kprofiles, MagicMock)
  177. ):
  178. printer_state.kprofiles = []
  179. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  180. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  181. pm_mock.get_status = MagicMock(return_value=printer_state)
  182. response = await async_client.post(
  183. "/api/v1/spoolman/inventory/slot-assignments",
  184. json={
  185. "spoolman_spool_id": 10,
  186. "printer_id": test_printer.id,
  187. "ams_id": 0,
  188. "tray_id": 2,
  189. },
  190. )
  191. assert response.status_code == 200
  192. mqtt_mock.extrusion_cali_sel.assert_called_once()
  193. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  194. assert call_kwargs["cali_idx"] == 5
  195. assert call_kwargs["ams_id"] == 0
  196. assert call_kwargs["tray_id"] == 2
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_extrusion_cali_sel_resets_default_on_nozzle_mismatch(
  200. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  201. ):
  202. """When nozzle diameter doesn't match K-profile (no usable kp), slot resets to Default K."""
  203. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  204. kp = SpoolmanKProfile(
  205. spoolman_spool_id=10,
  206. printer_id=test_printer.id,
  207. extruder=0,
  208. nozzle_diameter="0.6",
  209. k_value=0.03,
  210. cali_idx=7,
  211. setting_id="CaliID",
  212. )
  213. db_session.add(kp)
  214. await db_session.commit()
  215. printer_state = MagicMock()
  216. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  217. printer_state.ams_extruder_map = {"0": 0}
  218. mqtt_mock = MagicMock()
  219. mqtt_mock.ams_set_filament_setting = MagicMock()
  220. mqtt_mock.extrusion_cali_sel = MagicMock()
  221. # Legacy attribute — production never had it set; keep for any code
  222. # path that still reads `mqtt_client.printer_state` directly. State
  223. # for the K-profile cascade now comes from printer_manager.get_status.
  224. mqtt_mock.printer_state = printer_state
  225. # Empty list = no printer-side kprofiles, so the realignment skips
  226. # printer_kp lookup. Tests that exercise realignment explicitly
  227. # populate this list themselves.
  228. if (
  229. not hasattr(printer_state, "kprofiles")
  230. or printer_state.kprofiles is None
  231. or isinstance(printer_state.kprofiles, MagicMock)
  232. ):
  233. printer_state.kprofiles = []
  234. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  235. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  236. pm_mock.get_status = MagicMock(return_value=printer_state)
  237. response = await async_client.post(
  238. "/api/v1/spoolman/inventory/slot-assignments",
  239. json={
  240. "spoolman_spool_id": 10,
  241. "printer_id": test_printer.id,
  242. "ams_id": 0,
  243. "tray_id": 3,
  244. },
  245. )
  246. assert response.status_code == 200
  247. mqtt_mock.extrusion_cali_sel.assert_called_once()
  248. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  249. @pytest.mark.asyncio
  250. @pytest.mark.integration
  251. async def test_extrusion_cali_sel_resets_default_when_cali_idx_none(
  252. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  253. ):
  254. """When stored K-profile has cali_idx=None (unusable), slot resets to Default K."""
  255. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  256. kp = SpoolmanKProfile(
  257. spoolman_spool_id=10,
  258. printer_id=test_printer.id,
  259. extruder=0,
  260. nozzle_diameter="0.4",
  261. k_value=0.02,
  262. cali_idx=None,
  263. setting_id=None,
  264. )
  265. db_session.add(kp)
  266. await db_session.commit()
  267. printer_state = MagicMock()
  268. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  269. printer_state.ams_extruder_map = {"0": 0}
  270. mqtt_mock = MagicMock()
  271. mqtt_mock.ams_set_filament_setting = MagicMock()
  272. mqtt_mock.extrusion_cali_sel = MagicMock()
  273. # Legacy attribute — production never had it set; keep for any code
  274. # path that still reads `mqtt_client.printer_state` directly. State
  275. # for the K-profile cascade now comes from printer_manager.get_status.
  276. mqtt_mock.printer_state = printer_state
  277. # Empty list = no printer-side kprofiles, so the realignment skips
  278. # printer_kp lookup. Tests that exercise realignment explicitly
  279. # populate this list themselves.
  280. if (
  281. not hasattr(printer_state, "kprofiles")
  282. or printer_state.kprofiles is None
  283. or isinstance(printer_state.kprofiles, MagicMock)
  284. ):
  285. printer_state.kprofiles = []
  286. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  287. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  288. pm_mock.get_status = MagicMock(return_value=printer_state)
  289. response = await async_client.post(
  290. "/api/v1/spoolman/inventory/slot-assignments",
  291. json={
  292. "spoolman_spool_id": 10,
  293. "printer_id": test_printer.id,
  294. "ams_id": 0,
  295. "tray_id": 3,
  296. },
  297. )
  298. assert response.status_code == 200
  299. mqtt_mock.extrusion_cali_sel.assert_called_once()
  300. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  301. # ---------------------------------------------------------------------------
  302. # F7: ams_id=255 External-Slot Extruder-Inversion
  303. # ---------------------------------------------------------------------------
  304. class TestExternalSlotExtruderInversion:
  305. """F7: ams_id=255 maps tray_id→extruder via inversion (0→1, 1→0)."""
  306. @pytest.mark.asyncio
  307. @pytest.mark.integration
  308. async def test_external_slot_tray0_maps_to_extruder1(
  309. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  310. ):
  311. """tray_id=0 on ams_id=255 → extruder=1 (ext-L)."""
  312. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  313. # Create K-profiles for both extruders so we can verify which one matches
  314. kp_extruder_1 = SpoolmanKProfile(
  315. spoolman_spool_id=10,
  316. printer_id=test_printer.id,
  317. extruder=1,
  318. nozzle_diameter="0.4",
  319. k_value=0.03,
  320. cali_idx=1,
  321. setting_id=None,
  322. )
  323. db_session.add(kp_extruder_1)
  324. await db_session.commit()
  325. printer_state = MagicMock()
  326. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
  327. printer_state.ams_extruder_map = {"0": 0} # present so external inversion logic triggers
  328. mqtt_mock = MagicMock()
  329. mqtt_mock.ams_set_filament_setting = MagicMock()
  330. mqtt_mock.extrusion_cali_sel = MagicMock()
  331. # Legacy attribute — production never had it set; keep for any code
  332. # path that still reads `mqtt_client.printer_state` directly. State
  333. # for the K-profile cascade now comes from printer_manager.get_status.
  334. mqtt_mock.printer_state = printer_state
  335. # Empty list = no printer-side kprofiles, so the realignment skips
  336. # printer_kp lookup. Tests that exercise realignment explicitly
  337. # populate this list themselves.
  338. if (
  339. not hasattr(printer_state, "kprofiles")
  340. or printer_state.kprofiles is None
  341. or isinstance(printer_state.kprofiles, MagicMock)
  342. ):
  343. printer_state.kprofiles = []
  344. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  345. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  346. pm_mock.get_status = MagicMock(return_value=printer_state)
  347. resp = await async_client.post(
  348. "/api/v1/spoolman/inventory/slot-assignments",
  349. json={
  350. "spoolman_spool_id": 10,
  351. "printer_id": test_printer.id,
  352. "ams_id": 255,
  353. "tray_id": 0,
  354. },
  355. )
  356. assert resp.status_code == 200
  357. # extrusion_cali_sel should be called with the K-profile for extruder=1 (cali_idx=1)
  358. # The extruder itself is not passed as an argument — it's used internally to filter profiles
  359. mqtt_mock.extrusion_cali_sel.assert_called_once()
  360. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  361. assert call_kwargs["cali_idx"] == 1
  362. @pytest.mark.asyncio
  363. @pytest.mark.integration
  364. async def test_external_slot_tray1_maps_to_extruder0(
  365. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  366. ):
  367. """tray_id=1 on ams_id=255 → extruder=0 (ext-R)."""
  368. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  369. kp_extruder_0 = SpoolmanKProfile(
  370. spoolman_spool_id=10,
  371. printer_id=test_printer.id,
  372. extruder=0,
  373. nozzle_diameter="0.4",
  374. k_value=0.02,
  375. cali_idx=2,
  376. setting_id=None,
  377. )
  378. db_session.add(kp_extruder_0)
  379. await db_session.commit()
  380. printer_state = MagicMock()
  381. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
  382. printer_state.ams_extruder_map = {"0": 0}
  383. mqtt_mock = MagicMock()
  384. mqtt_mock.ams_set_filament_setting = MagicMock()
  385. mqtt_mock.extrusion_cali_sel = MagicMock()
  386. # Legacy attribute — production never had it set; keep for any code
  387. # path that still reads `mqtt_client.printer_state` directly. State
  388. # for the K-profile cascade now comes from printer_manager.get_status.
  389. mqtt_mock.printer_state = printer_state
  390. # Empty list = no printer-side kprofiles, so the realignment skips
  391. # printer_kp lookup. Tests that exercise realignment explicitly
  392. # populate this list themselves.
  393. if (
  394. not hasattr(printer_state, "kprofiles")
  395. or printer_state.kprofiles is None
  396. or isinstance(printer_state.kprofiles, MagicMock)
  397. ):
  398. printer_state.kprofiles = []
  399. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  400. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  401. pm_mock.get_status = MagicMock(return_value=printer_state)
  402. resp = await async_client.post(
  403. "/api/v1/spoolman/inventory/slot-assignments",
  404. json={
  405. "spoolman_spool_id": 10,
  406. "printer_id": test_printer.id,
  407. "ams_id": 255,
  408. "tray_id": 1,
  409. },
  410. )
  411. assert resp.status_code == 200
  412. # extrusion_cali_sel should be called with the K-profile for extruder=0 (cali_idx=2)
  413. mqtt_mock.extrusion_cali_sel.assert_called_once()
  414. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  415. assert call_kwargs["cali_idx"] == 2
  416. # ---------------------------------------------------------------------------
  417. # P9-TEST-BE: Live cali_idx fallback when no K-profile is stored (Bug #10)
  418. # ---------------------------------------------------------------------------
  419. class TestAssignSpoolmanSlotLiveCaliIdx:
  420. """When no SpoolmanKProfile exists, live tray cali_idx is used as fallback."""
  421. def _make_printer_state(self, ams_id: int, tray_id: int, cali_idx: int | None):
  422. """Build a minimal printer_state mock with one AMS tray."""
  423. tray_mock = {
  424. "id": tray_id,
  425. "cali_idx": cali_idx,
  426. }
  427. ams_mock = {"id": ams_id, "tray": [tray_mock]}
  428. state = MagicMock()
  429. state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  430. state.ams_extruder_map = {str(ams_id): 0}
  431. state.raw_data = {"ams": [ams_mock]}
  432. return state
  433. @pytest.mark.asyncio
  434. @pytest.mark.integration
  435. async def test_no_kprofile_resets_to_default_k(
  436. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  437. ):
  438. """When no K-profile exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
  439. printer_state = self._make_printer_state(ams_id=0, tray_id=1, cali_idx=42)
  440. mqtt_mock = MagicMock()
  441. mqtt_mock.ams_set_filament_setting = MagicMock()
  442. mqtt_mock.extrusion_cali_sel = MagicMock()
  443. # Legacy attribute — production never had it set; keep for any code
  444. # path that still reads `mqtt_client.printer_state` directly. State
  445. # for the K-profile cascade now comes from printer_manager.get_status.
  446. mqtt_mock.printer_state = printer_state
  447. # Empty list = no printer-side kprofiles, so the realignment skips
  448. # printer_kp lookup. Tests that exercise realignment explicitly
  449. # populate this list themselves.
  450. if (
  451. not hasattr(printer_state, "kprofiles")
  452. or printer_state.kprofiles is None
  453. or isinstance(printer_state.kprofiles, MagicMock)
  454. ):
  455. printer_state.kprofiles = []
  456. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  457. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  458. pm_mock.get_status = MagicMock(return_value=printer_state)
  459. resp = await async_client.post(
  460. "/api/v1/spoolman/inventory/slot-assignments",
  461. json={
  462. "spoolman_spool_id": 10,
  463. "printer_id": test_printer.id,
  464. "ams_id": 0,
  465. "tray_id": 1,
  466. },
  467. )
  468. assert resp.status_code == 200
  469. mqtt_mock.extrusion_cali_sel.assert_called_once()
  470. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  471. assert call_kwargs["cali_idx"] == -1
  472. assert call_kwargs["ams_id"] == 0
  473. assert call_kwargs["tray_id"] == 1
  474. @pytest.mark.asyncio
  475. @pytest.mark.integration
  476. async def test_no_kprofile_no_live_cali_idx_sends_default(
  477. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  478. ):
  479. """When no K-profile and tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
  480. printer_state = self._make_printer_state(ams_id=0, tray_id=2, cali_idx=None)
  481. mqtt_mock = MagicMock()
  482. mqtt_mock.ams_set_filament_setting = MagicMock()
  483. mqtt_mock.extrusion_cali_sel = MagicMock()
  484. # Legacy attribute — production never had it set; keep for any code
  485. # path that still reads `mqtt_client.printer_state` directly. State
  486. # for the K-profile cascade now comes from printer_manager.get_status.
  487. mqtt_mock.printer_state = printer_state
  488. # Empty list = no printer-side kprofiles, so the realignment skips
  489. # printer_kp lookup. Tests that exercise realignment explicitly
  490. # populate this list themselves.
  491. if (
  492. not hasattr(printer_state, "kprofiles")
  493. or printer_state.kprofiles is None
  494. or isinstance(printer_state.kprofiles, MagicMock)
  495. ):
  496. printer_state.kprofiles = []
  497. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  498. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  499. pm_mock.get_status = MagicMock(return_value=printer_state)
  500. resp = await async_client.post(
  501. "/api/v1/spoolman/inventory/slot-assignments",
  502. json={
  503. "spoolman_spool_id": 10,
  504. "printer_id": test_printer.id,
  505. "ams_id": 0,
  506. "tray_id": 2,
  507. },
  508. )
  509. assert resp.status_code == 200
  510. mqtt_mock.extrusion_cali_sel.assert_called_once()
  511. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  512. @pytest.mark.asyncio
  513. @pytest.mark.integration
  514. async def test_kprofile_takes_priority_over_live_cali_idx(
  515. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  516. ):
  517. """Stored K-profile cali_idx wins over live tray cali_idx."""
  518. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  519. kp = SpoolmanKProfile(
  520. spoolman_spool_id=10,
  521. printer_id=test_printer.id,
  522. extruder=0,
  523. nozzle_diameter="0.4",
  524. k_value=0.02,
  525. cali_idx=10,
  526. setting_id="CaliID",
  527. )
  528. db_session.add(kp)
  529. await db_session.commit()
  530. # Live tray has a different cali_idx — stored profile must win
  531. printer_state = self._make_printer_state(ams_id=0, tray_id=3, cali_idx=99)
  532. mqtt_mock = MagicMock()
  533. mqtt_mock.ams_set_filament_setting = MagicMock()
  534. mqtt_mock.extrusion_cali_sel = MagicMock()
  535. # Legacy attribute — production never had it set; keep for any code
  536. # path that still reads `mqtt_client.printer_state` directly. State
  537. # for the K-profile cascade now comes from printer_manager.get_status.
  538. mqtt_mock.printer_state = printer_state
  539. # Empty list = no printer-side kprofiles, so the realignment skips
  540. # printer_kp lookup. Tests that exercise realignment explicitly
  541. # populate this list themselves.
  542. if (
  543. not hasattr(printer_state, "kprofiles")
  544. or printer_state.kprofiles is None
  545. or isinstance(printer_state.kprofiles, MagicMock)
  546. ):
  547. printer_state.kprofiles = []
  548. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  549. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  550. pm_mock.get_status = MagicMock(return_value=printer_state)
  551. resp = await async_client.post(
  552. "/api/v1/spoolman/inventory/slot-assignments",
  553. json={
  554. "spoolman_spool_id": 10,
  555. "printer_id": test_printer.id,
  556. "ams_id": 0,
  557. "tray_id": 3,
  558. },
  559. )
  560. assert resp.status_code == 200
  561. mqtt_mock.extrusion_cali_sel.assert_called_once()
  562. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  563. # Must use stored K-profile (10), NOT live cali_idx (99)
  564. assert call_kwargs["cali_idx"] == 10
  565. @pytest.mark.asyncio
  566. @pytest.mark.integration
  567. async def test_live_cali_idx_negative_falls_back_to_default(
  568. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  569. ):
  570. """A negative live cali_idx falls through and is sent as Default (cali_idx=-1)."""
  571. printer_state = self._make_printer_state(ams_id=0, tray_id=0, cali_idx=-1)
  572. mqtt_mock = MagicMock()
  573. mqtt_mock.ams_set_filament_setting = MagicMock()
  574. mqtt_mock.extrusion_cali_sel = MagicMock()
  575. # Legacy attribute — production never had it set; keep for any code
  576. # path that still reads `mqtt_client.printer_state` directly. State
  577. # for the K-profile cascade now comes from printer_manager.get_status.
  578. mqtt_mock.printer_state = printer_state
  579. # Empty list = no printer-side kprofiles, so the realignment skips
  580. # printer_kp lookup. Tests that exercise realignment explicitly
  581. # populate this list themselves.
  582. if (
  583. not hasattr(printer_state, "kprofiles")
  584. or printer_state.kprofiles is None
  585. or isinstance(printer_state.kprofiles, MagicMock)
  586. ):
  587. printer_state.kprofiles = []
  588. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  589. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  590. pm_mock.get_status = MagicMock(return_value=printer_state)
  591. resp = await async_client.post(
  592. "/api/v1/spoolman/inventory/slot-assignments",
  593. json={
  594. "spoolman_spool_id": 10,
  595. "printer_id": test_printer.id,
  596. "ams_id": 0,
  597. "tray_id": 0,
  598. },
  599. )
  600. assert resp.status_code == 200
  601. mqtt_mock.extrusion_cali_sel.assert_called_once()
  602. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  603. # ---------------------------------------------------------------------------
  604. # Realignment of slot filament context to K-profile preset
  605. # ---------------------------------------------------------------------------
  606. # When the user assigns a Spoolman spool whose stored kp was calibrated under
  607. # a specific filament preset (e.g. P-prefix local, or a named cloud preset),
  608. # the slot must be configured under THAT preset for the printer to find the
  609. # cali_idx in its calibration table. Without realignment the slot ends up on
  610. # generic PLA / default K — the symptom maztiggy reported on x1c-2 (#1114).
  611. class TestAssignSpoolmanSlotKProfileRealignment:
  612. """assign_spoolman_slot realigns tray_info_idx + setting_id to kp context."""
  613. @pytest.mark.asyncio
  614. @pytest.mark.integration
  615. async def test_realigns_to_printer_reported_filament_id(
  616. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  617. ):
  618. """When state.kprofiles has the cali_idx, use printer_kp.filament_id verbatim.
  619. The printer keys its calibration table by filament_id, not setting_id.
  620. For a P-prefix local preset (printer-registered), filament_id and
  621. tray_info_idx must match for the cali_idx to apply.
  622. """
  623. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  624. # Stored kp with setting_id but no filament_id (the schema gap)
  625. kp = SpoolmanKProfile(
  626. spoolman_spool_id=10,
  627. printer_id=test_printer.id,
  628. extruder=0,
  629. nozzle_diameter="0.4",
  630. k_value=0.025,
  631. cali_idx=8948,
  632. setting_id="PFUSedbf16b803ff3e",
  633. )
  634. db_session.add(kp)
  635. await db_session.commit()
  636. printer_state = MagicMock()
  637. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  638. printer_state.ams_extruder_map = {"0": 0}
  639. printer_state.raw_data = None
  640. # Live calibration entry from the printer — this is what cali_idx 8948
  641. # is actually registered under. P-prefix is a printer-local preset
  642. # (different from PFUS-prefix cloud user presets).
  643. printer_kp = MagicMock()
  644. printer_kp.slot_id = 8948
  645. printer_kp.nozzle_diameter = "0.4"
  646. printer_kp.filament_id = "P4d64437"
  647. printer_kp.setting_id = "PFUSedbf16b803ff3e"
  648. printer_state.kprofiles = [printer_kp]
  649. mqtt_mock = MagicMock()
  650. mqtt_mock.ams_set_filament_setting = MagicMock()
  651. mqtt_mock.extrusion_cali_sel = MagicMock()
  652. mqtt_mock.printer_state = printer_state
  653. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  654. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  655. pm_mock.get_status = MagicMock(return_value=printer_state)
  656. response = await async_client.post(
  657. "/api/v1/spoolman/inventory/slot-assignments",
  658. json={
  659. "spoolman_spool_id": 10,
  660. "printer_id": test_printer.id,
  661. "ams_id": 0,
  662. "tray_id": 1,
  663. },
  664. )
  665. assert response.status_code == 200
  666. # Both MQTT commands must reference the printer-reported filament_id
  667. # so the slot context and the cali_sel context match.
  668. amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  669. assert amf_kwargs["tray_info_idx"] == "P4d64437"
  670. assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
  671. cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  672. assert cs_kwargs["cali_idx"] == 8948
  673. assert cs_kwargs["filament_id"] == "P4d64437"
  674. @pytest.mark.asyncio
  675. @pytest.mark.integration
  676. async def test_skips_realignment_for_pfus_prefix(
  677. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  678. ):
  679. """PFUS-prefix cloud-user presets are rejected by the slicer in tray_info_idx.
  680. For those, tray_info_idx must stay as the GF* generic so the slicer
  681. can render the slot. setting_id can still be realigned to the cloud
  682. preset (slicer uses that for display), but tray_info_idx stays GF*.
  683. """
  684. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  685. kp = SpoolmanKProfile(
  686. spoolman_spool_id=10,
  687. printer_id=test_printer.id,
  688. extruder=0,
  689. nozzle_diameter="0.4",
  690. k_value=0.025,
  691. cali_idx=42,
  692. setting_id="PFUSedbf16b803ff3e",
  693. )
  694. db_session.add(kp)
  695. await db_session.commit()
  696. printer_state = MagicMock()
  697. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  698. printer_state.ams_extruder_map = {"0": 0}
  699. printer_state.raw_data = None
  700. # Printer-side kp filament_id is PFUS-prefix → realignment must skip
  701. printer_kp = MagicMock()
  702. printer_kp.slot_id = 42
  703. printer_kp.nozzle_diameter = "0.4"
  704. printer_kp.filament_id = "PFUSedbf16b803ff3e"
  705. printer_kp.setting_id = "PFUSedbf16b803ff3e"
  706. printer_state.kprofiles = [printer_kp]
  707. mqtt_mock = MagicMock()
  708. mqtt_mock.ams_set_filament_setting = MagicMock()
  709. mqtt_mock.extrusion_cali_sel = MagicMock()
  710. mqtt_mock.printer_state = printer_state
  711. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  712. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  713. pm_mock.get_status = MagicMock(return_value=printer_state)
  714. response = await async_client.post(
  715. "/api/v1/spoolman/inventory/slot-assignments",
  716. json={
  717. "spoolman_spool_id": 10,
  718. "printer_id": test_printer.id,
  719. "ams_id": 0,
  720. "tray_id": 2,
  721. },
  722. )
  723. assert response.status_code == 200
  724. amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  725. # tray_info_idx stays as the resolved generic (slicer accepts GF*)
  726. assert amf_kwargs["tray_info_idx"] == "GFL99"
  727. # setting_id may be realigned to the cloud preset for slicer display
  728. assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
  729. @pytest.mark.asyncio
  730. @pytest.mark.integration
  731. async def test_extruder_relax_falls_back_to_any_extruder_kp(
  732. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  733. ):
  734. """Hard-skip on extruder mismatch silently dropped valid stored profiles
  735. when the AMS-extruder map shifted. The cascade now prefers exact
  736. extruder match but falls back to any kp on the same printer + nozzle.
  737. """
  738. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  739. # kp is for extruder=1, but slot will be on extruder=0 (mismatch)
  740. kp = SpoolmanKProfile(
  741. spoolman_spool_id=10,
  742. printer_id=test_printer.id,
  743. extruder=1,
  744. nozzle_diameter="0.4",
  745. k_value=0.025,
  746. cali_idx=42,
  747. setting_id="GFSL05",
  748. )
  749. db_session.add(kp)
  750. await db_session.commit()
  751. printer_state = MagicMock()
  752. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  753. printer_state.ams_extruder_map = {"0": 0}
  754. printer_state.raw_data = None
  755. printer_state.kprofiles = []
  756. mqtt_mock = MagicMock()
  757. mqtt_mock.ams_set_filament_setting = MagicMock()
  758. mqtt_mock.extrusion_cali_sel = MagicMock()
  759. mqtt_mock.printer_state = printer_state
  760. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  761. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  762. pm_mock.get_status = MagicMock(return_value=printer_state)
  763. response = await async_client.post(
  764. "/api/v1/spoolman/inventory/slot-assignments",
  765. json={
  766. "spoolman_spool_id": 10,
  767. "printer_id": test_printer.id,
  768. "ams_id": 0,
  769. "tray_id": 3,
  770. },
  771. )
  772. assert response.status_code == 200
  773. # extruder mismatch was hard-skipped pre-fix; now used as fallback
  774. cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  775. assert cs_kwargs["cali_idx"] == 42
  776. # ---- #1713: slicer_filament resolved into tray_info_idx + setting_id --------
  777. #
  778. # Before this fix the Spoolman-mode assign route ignored the spool's stored
  779. # slicer_filament (the user's configured Bambu Studio / Orca filament profile)
  780. # and only filled tray_info_idx from the generic-material fallback. The user
  781. # saw ams_filament_setting publish with tray_info_idx=GFL99 / setting_id=""
  782. # even though they had assigned a real profile to the spool, and had to
  783. # manually re-configure each slot through the printer card. The internal-mode
  784. # route did the resolution correctly via _apply_spool_to_slot_inner; the
  785. # Spoolman route was never ported.
  786. #
  787. # These tests pin the parity: an assign of a Spoolman spool whose
  788. # bambu_slicer_filament extra-field points at a real preset must publish that
  789. # preset's tray_info_idx + setting_id, not the generic-material bucket.
  790. class TestSlicerFilamentResolutionParity:
  791. """#1713: Spoolman-mode assign honours the spool's configured slicer
  792. filament profile, matching internal-mode behaviour."""
  793. @pytest.mark.asyncio
  794. @pytest.mark.integration
  795. async def test_gf_prefix_slicer_filament_resolves_to_tray_info_idx(
  796. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  797. ):
  798. """GF-prefix Bambu official preset (e.g. ``GFA01``) routes straight
  799. through ``normalize_slicer_filament`` — the simplest path and the
  800. most common shape for users who picked their preset in the slicer."""
  801. mock_spoolman_client.get_spool = AsyncMock(
  802. return_value={**SAMPLE_SPOOL, "extra": {"bambu_slicer_filament": '"GFA01"'}}
  803. )
  804. mqtt_mock = MagicMock()
  805. mqtt_mock.ams_set_filament_setting = MagicMock()
  806. mqtt_mock.extrusion_cali_sel = MagicMock()
  807. mqtt_mock.printer_state = None
  808. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  809. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  810. pm_mock.get_status = MagicMock(return_value=None)
  811. response = await async_client.post(
  812. "/api/v1/spoolman/inventory/slot-assignments",
  813. json={
  814. "spoolman_spool_id": 10,
  815. "printer_id": test_printer.id,
  816. "ams_id": 0,
  817. "tray_id": 0,
  818. },
  819. )
  820. assert response.status_code == 200
  821. call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  822. assert call_kwargs["tray_info_idx"] == "GFA01", (
  823. "Pre-fix: dropped slicer_filament and published GFL99 generic-PLA bucket. "
  824. "Post-fix: must publish the actual preset id."
  825. )
  826. assert call_kwargs["setting_id"].startswith("GFSA01"), (
  827. "setting_id must be derived from the resolved filament_id, not left empty as the pre-fix path did."
  828. )
  829. @pytest.mark.asyncio
  830. @pytest.mark.integration
  831. async def test_local_preset_int_id_resolves_to_filament_id_from_json(
  832. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  833. ):
  834. """#1713 regression: shaddowlink's exact case. Spool's slicer_filament
  835. is the integer id of a LocalPreset whose setting JSON carries the
  836. printer-side ``filament_id`` (e.g. ``P20bd830``). The publish must
  837. carry that filament_id + its derived setting_id — not the generic
  838. material bucket.
  839. From his support bundle:
  840. 11:33:01 — assign_spoolman_slot published tray_info_idx=GFL99 (BUG)
  841. 11:33:13 — user manually fired /printers/.../configure with
  842. tray_info_idx=P20bd830, setting_id=PFUS3822acb73c88cc
  843. """
  844. from backend.app.models.local_preset import LocalPreset
  845. lp = LocalPreset(
  846. name="AMOLEN PLA Silk @0.4 nozzle",
  847. preset_type="filament",
  848. filament_type="PLA",
  849. setting=json.dumps({"filament_id": "P20bd830"}),
  850. )
  851. db_session.add(lp)
  852. await db_session.commit()
  853. await db_session.refresh(lp)
  854. # Spoolman spool whose bambu_slicer_filament points at this LocalPreset
  855. # by integer id (the shape the inventory UI persists when the user
  856. # picks a local preset in the filament dropdown).
  857. mock_spoolman_client.get_spool = AsyncMock(
  858. return_value={
  859. **SAMPLE_SPOOL,
  860. "extra": {"bambu_slicer_filament": json.dumps(str(lp.id))},
  861. }
  862. )
  863. mqtt_mock = MagicMock()
  864. mqtt_mock.ams_set_filament_setting = MagicMock()
  865. mqtt_mock.extrusion_cali_sel = MagicMock()
  866. mqtt_mock.printer_state = None
  867. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  868. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  869. pm_mock.get_status = MagicMock(return_value=None)
  870. response = await async_client.post(
  871. "/api/v1/spoolman/inventory/slot-assignments",
  872. json={
  873. "spoolman_spool_id": 10,
  874. "printer_id": test_printer.id,
  875. "ams_id": 255,
  876. "tray_id": 0,
  877. },
  878. )
  879. assert response.status_code == 200
  880. call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  881. # Pre-fix the publish here was tray_info_idx="GFL99", setting_id="".
  882. assert call_kwargs["tray_info_idx"] == "P20bd830"
  883. assert call_kwargs["setting_id"], "setting_id must not be empty post-fix"
  884. @pytest.mark.asyncio
  885. @pytest.mark.integration
  886. async def test_empty_slicer_filament_still_falls_back_to_generic_material(
  887. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  888. ):
  889. """Spools without a configured slicer_filament must still get the
  890. generic-material fallback so the slot is at least minimally
  891. configured. Guards against the resolver path swallowing the empty
  892. case and leaving tray_info_idx empty."""
  893. # extra dict has no bambu_slicer_filament key
  894. mock_spoolman_client.get_spool = AsyncMock(return_value={**SAMPLE_SPOOL, "extra": {}})
  895. mqtt_mock = MagicMock()
  896. mqtt_mock.ams_set_filament_setting = MagicMock()
  897. mqtt_mock.extrusion_cali_sel = MagicMock()
  898. mqtt_mock.printer_state = None
  899. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  900. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  901. pm_mock.get_status = MagicMock(return_value=None)
  902. response = await async_client.post(
  903. "/api/v1/spoolman/inventory/slot-assignments",
  904. json={
  905. "spoolman_spool_id": 10,
  906. "printer_id": test_printer.id,
  907. "ams_id": 0,
  908. "tray_id": 0,
  909. },
  910. )
  911. assert response.status_code == 200
  912. call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  913. # PLA → GFL99 (the generic-PLA bucket from GENERIC_FILAMENT_IDS).
  914. assert call_kwargs["tray_info_idx"] == "GFL99"
  915. # The generic-fallback path must STILL produce a non-empty setting_id
  916. # (matches the internal-mode tail). Pre-fix this was "".
  917. assert call_kwargs["setting_id"], (
  918. "Even on the generic-material fallback, setting_id must be "
  919. "filament_id_to_setting_id-derived so the slot detail modal "
  920. "doesn't render with empty fields."
  921. )
  922. @pytest.mark.asyncio
  923. @pytest.mark.integration
  924. @pytest.mark.parametrize(
  925. "material,expected",
  926. [("PP", "GFP97"), ("PE", "GFP99"), ("PCTG", "GFG97"), ("PLA Silk", "GFL96")],
  927. )
  928. async def test_generic_fallback_covers_every_material_with_a_bambu_generic(
  929. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, material, expected
  930. ):
  931. """A PP spool without a preset went out with tray_info_idx="" (#3273):
  932. the shared table lacked the generics the Configure dialog already had."""
  933. spool = {**SAMPLE_SPOOL, "extra": {}, "filament": {**SAMPLE_SPOOL["filament"], "material": material}}
  934. mock_spoolman_client.get_spool = AsyncMock(return_value=spool)
  935. mqtt_mock = MagicMock()
  936. mqtt_mock.ams_set_filament_setting = MagicMock()
  937. mqtt_mock.extrusion_cali_sel = MagicMock()
  938. mqtt_mock.printer_state = None
  939. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  940. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  941. pm_mock.get_status = MagicMock(return_value=None)
  942. response = await async_client.post(
  943. "/api/v1/spoolman/inventory/slot-assignments",
  944. json={
  945. "spoolman_spool_id": 10,
  946. "printer_id": test_printer.id,
  947. "ams_id": 0,
  948. "tray_id": 0,
  949. },
  950. )
  951. assert response.status_code == 200
  952. call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  953. assert call_kwargs["tray_info_idx"] == expected
  954. assert call_kwargs["setting_id"] == "GFS" + expected[2:]