test_spoolman_slot_assignment_mqtt.py 43 KB

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