test_spoolman_slot_assignment_mqtt.py 43 KB

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