test_spoolman_slot_assignment_mqtt.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  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. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. SAMPLE_SPOOL = {
  11. "id": 10,
  12. "filament": {
  13. "id": 1,
  14. "name": "PLA Basic",
  15. "material": "PLA",
  16. "color_hex": "FF0000",
  17. "weight": 1000,
  18. "vendor": {"id": 1, "name": "BrandX"},
  19. },
  20. "remaining_weight": 800.0,
  21. "used_weight": 200.0,
  22. "location": None,
  23. "comment": None,
  24. "first_used": None,
  25. "last_used": None,
  26. "registered": "2024-01-01T00:00:00+00:00",
  27. "archived": False,
  28. "price": None,
  29. "extra": {},
  30. }
  31. @pytest.fixture
  32. async def slot_settings(db_session):
  33. from backend.app.models.settings import Settings
  34. db_session.add(Settings(key="spoolman_enabled", value="true"))
  35. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  36. await db_session.commit()
  37. @pytest.fixture
  38. async def test_printer(db_session):
  39. from backend.app.models.printer import Printer
  40. printer = Printer(
  41. name="MQTT Printer",
  42. serial_number="MQTTTEST001",
  43. ip_address="192.168.1.200",
  44. access_code="12345678",
  45. )
  46. db_session.add(printer)
  47. await db_session.commit()
  48. await db_session.refresh(printer)
  49. return printer
  50. @pytest.fixture
  51. def mock_spoolman_client():
  52. client = MagicMock()
  53. client.base_url = "http://localhost:7912"
  54. client.health_check = AsyncMock(return_value=True)
  55. client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
  56. with patch(
  57. "backend.app.api.routes.spoolman_inventory._get_client",
  58. AsyncMock(return_value=client),
  59. ):
  60. yield client
  61. class TestAssignSlotMqtt:
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_mqtt_ams_set_filament_called_on_assign(
  65. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  66. ):
  67. """Assigning a Spoolman spool fires ams_set_filament_setting via MQTT."""
  68. mqtt_mock = MagicMock()
  69. mqtt_mock.ams_set_filament_setting = MagicMock()
  70. mqtt_mock.extrusion_cali_sel = MagicMock()
  71. mqtt_mock.printer_state = None
  72. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  73. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  74. response = await async_client.post(
  75. "/api/v1/spoolman/inventory/slot-assignments",
  76. json={
  77. "spoolman_spool_id": 10,
  78. "printer_id": test_printer.id,
  79. "ams_id": 0,
  80. "tray_id": 1,
  81. },
  82. )
  83. assert response.status_code == 200
  84. mqtt_mock.ams_set_filament_setting.assert_called_once()
  85. call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  86. assert call_kwargs["ams_id"] == 0
  87. assert call_kwargs["tray_id"] == 1
  88. assert call_kwargs["tray_type"] == "PLA"
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_mqtt_failure_does_not_rollback_assignment(
  92. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  93. ):
  94. """A crash inside the MQTT block must not un-persist the slot assignment."""
  95. mqtt_mock = MagicMock()
  96. mqtt_mock.ams_set_filament_setting = MagicMock(side_effect=RuntimeError("MQTT down"))
  97. mqtt_mock.printer_state = None
  98. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  99. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  100. response = await async_client.post(
  101. "/api/v1/spoolman/inventory/slot-assignments",
  102. json={
  103. "spoolman_spool_id": 10,
  104. "printer_id": test_printer.id,
  105. "ams_id": 1,
  106. "tray_id": 0,
  107. },
  108. )
  109. assert response.status_code == 200
  110. # Verify the assignment IS in the DB despite the MQTT crash
  111. all_resp = await async_client.get(
  112. "/api/v1/spoolman/inventory/slot-assignments/all",
  113. params={"printer_id": test_printer.id},
  114. )
  115. assert all_resp.status_code == 200
  116. rows = all_resp.json()
  117. assert any(r["spoolman_spool_id"] == 10 for r in rows)
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_extrusion_cali_sel_called_when_k_profile_exists(
  121. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  122. ):
  123. """extrusion_cali_sel is fired when a matching SpoolmanKProfile row exists."""
  124. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  125. kp = SpoolmanKProfile(
  126. spoolman_spool_id=10,
  127. printer_id=test_printer.id,
  128. extruder=0,
  129. nozzle_diameter="0.4",
  130. k_value=0.02,
  131. cali_idx=5,
  132. setting_id="CaliID",
  133. )
  134. db_session.add(kp)
  135. await db_session.commit()
  136. printer_state = MagicMock()
  137. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  138. printer_state.ams_extruder_map = {"0": 0}
  139. mqtt_mock = MagicMock()
  140. mqtt_mock.ams_set_filament_setting = MagicMock()
  141. mqtt_mock.extrusion_cali_sel = MagicMock()
  142. # Legacy attribute — production never had it set; keep for any code
  143. # path that still reads `mqtt_client.printer_state` directly. State
  144. # for the K-profile cascade now comes from printer_manager.get_status.
  145. mqtt_mock.printer_state = printer_state
  146. # Empty list = no printer-side kprofiles, so the realignment skips
  147. # printer_kp lookup. Tests that exercise realignment explicitly
  148. # populate this list themselves.
  149. if (
  150. not hasattr(printer_state, "kprofiles")
  151. or printer_state.kprofiles is None
  152. or isinstance(printer_state.kprofiles, MagicMock)
  153. ):
  154. printer_state.kprofiles = []
  155. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  156. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  157. pm_mock.get_status = MagicMock(return_value=printer_state)
  158. response = await async_client.post(
  159. "/api/v1/spoolman/inventory/slot-assignments",
  160. json={
  161. "spoolman_spool_id": 10,
  162. "printer_id": test_printer.id,
  163. "ams_id": 0,
  164. "tray_id": 2,
  165. },
  166. )
  167. assert response.status_code == 200
  168. mqtt_mock.extrusion_cali_sel.assert_called_once()
  169. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  170. assert call_kwargs["cali_idx"] == 5
  171. assert call_kwargs["ams_id"] == 0
  172. assert call_kwargs["tray_id"] == 2
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_extrusion_cali_sel_not_called_on_nozzle_mismatch(
  176. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  177. ):
  178. """extrusion_cali_sel is NOT called when nozzle diameter does not match K-profile."""
  179. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  180. kp = SpoolmanKProfile(
  181. spoolman_spool_id=10,
  182. printer_id=test_printer.id,
  183. extruder=0,
  184. nozzle_diameter="0.6",
  185. k_value=0.03,
  186. cali_idx=7,
  187. setting_id="CaliID",
  188. )
  189. db_session.add(kp)
  190. await db_session.commit()
  191. printer_state = MagicMock()
  192. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  193. printer_state.ams_extruder_map = {"0": 0}
  194. mqtt_mock = MagicMock()
  195. mqtt_mock.ams_set_filament_setting = MagicMock()
  196. mqtt_mock.extrusion_cali_sel = MagicMock()
  197. # Legacy attribute — production never had it set; keep for any code
  198. # path that still reads `mqtt_client.printer_state` directly. State
  199. # for the K-profile cascade now comes from printer_manager.get_status.
  200. mqtt_mock.printer_state = printer_state
  201. # Empty list = no printer-side kprofiles, so the realignment skips
  202. # printer_kp lookup. Tests that exercise realignment explicitly
  203. # populate this list themselves.
  204. if (
  205. not hasattr(printer_state, "kprofiles")
  206. or printer_state.kprofiles is None
  207. or isinstance(printer_state.kprofiles, MagicMock)
  208. ):
  209. printer_state.kprofiles = []
  210. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  211. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  212. pm_mock.get_status = MagicMock(return_value=printer_state)
  213. response = await async_client.post(
  214. "/api/v1/spoolman/inventory/slot-assignments",
  215. json={
  216. "spoolman_spool_id": 10,
  217. "printer_id": test_printer.id,
  218. "ams_id": 0,
  219. "tray_id": 3,
  220. },
  221. )
  222. assert response.status_code == 200
  223. mqtt_mock.extrusion_cali_sel.assert_not_called()
  224. @pytest.mark.asyncio
  225. @pytest.mark.integration
  226. async def test_extrusion_cali_sel_not_called_when_cali_idx_none(
  227. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  228. ):
  229. """extrusion_cali_sel is NOT called when K-profile has cali_idx=None."""
  230. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  231. kp = SpoolmanKProfile(
  232. spoolman_spool_id=10,
  233. printer_id=test_printer.id,
  234. extruder=0,
  235. nozzle_diameter="0.4",
  236. k_value=0.02,
  237. cali_idx=None,
  238. setting_id=None,
  239. )
  240. db_session.add(kp)
  241. await db_session.commit()
  242. printer_state = MagicMock()
  243. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  244. printer_state.ams_extruder_map = {"0": 0}
  245. mqtt_mock = MagicMock()
  246. mqtt_mock.ams_set_filament_setting = MagicMock()
  247. mqtt_mock.extrusion_cali_sel = MagicMock()
  248. # Legacy attribute — production never had it set; keep for any code
  249. # path that still reads `mqtt_client.printer_state` directly. State
  250. # for the K-profile cascade now comes from printer_manager.get_status.
  251. mqtt_mock.printer_state = printer_state
  252. # Empty list = no printer-side kprofiles, so the realignment skips
  253. # printer_kp lookup. Tests that exercise realignment explicitly
  254. # populate this list themselves.
  255. if (
  256. not hasattr(printer_state, "kprofiles")
  257. or printer_state.kprofiles is None
  258. or isinstance(printer_state.kprofiles, MagicMock)
  259. ):
  260. printer_state.kprofiles = []
  261. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  262. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  263. pm_mock.get_status = MagicMock(return_value=printer_state)
  264. response = await async_client.post(
  265. "/api/v1/spoolman/inventory/slot-assignments",
  266. json={
  267. "spoolman_spool_id": 10,
  268. "printer_id": test_printer.id,
  269. "ams_id": 0,
  270. "tray_id": 3,
  271. },
  272. )
  273. assert response.status_code == 200
  274. mqtt_mock.extrusion_cali_sel.assert_not_called()
  275. # ---------------------------------------------------------------------------
  276. # F7: ams_id=255 External-Slot Extruder-Inversion
  277. # ---------------------------------------------------------------------------
  278. class TestExternalSlotExtruderInversion:
  279. """F7: ams_id=255 maps tray_id→extruder via inversion (0→1, 1→0)."""
  280. @pytest.mark.asyncio
  281. @pytest.mark.integration
  282. async def test_external_slot_tray0_maps_to_extruder1(
  283. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  284. ):
  285. """tray_id=0 on ams_id=255 → extruder=1 (ext-L)."""
  286. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  287. # Create K-profiles for both extruders so we can verify which one matches
  288. kp_extruder_1 = SpoolmanKProfile(
  289. spoolman_spool_id=10,
  290. printer_id=test_printer.id,
  291. extruder=1,
  292. nozzle_diameter="0.4",
  293. k_value=0.03,
  294. cali_idx=1,
  295. setting_id=None,
  296. )
  297. db_session.add(kp_extruder_1)
  298. await db_session.commit()
  299. printer_state = MagicMock()
  300. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
  301. printer_state.ams_extruder_map = {"0": 0} # present so external inversion logic triggers
  302. mqtt_mock = MagicMock()
  303. mqtt_mock.ams_set_filament_setting = MagicMock()
  304. mqtt_mock.extrusion_cali_sel = MagicMock()
  305. # Legacy attribute — production never had it set; keep for any code
  306. # path that still reads `mqtt_client.printer_state` directly. State
  307. # for the K-profile cascade now comes from printer_manager.get_status.
  308. mqtt_mock.printer_state = printer_state
  309. # Empty list = no printer-side kprofiles, so the realignment skips
  310. # printer_kp lookup. Tests that exercise realignment explicitly
  311. # populate this list themselves.
  312. if (
  313. not hasattr(printer_state, "kprofiles")
  314. or printer_state.kprofiles is None
  315. or isinstance(printer_state.kprofiles, MagicMock)
  316. ):
  317. printer_state.kprofiles = []
  318. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  319. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  320. pm_mock.get_status = MagicMock(return_value=printer_state)
  321. resp = await async_client.post(
  322. "/api/v1/spoolman/inventory/slot-assignments",
  323. json={
  324. "spoolman_spool_id": 10,
  325. "printer_id": test_printer.id,
  326. "ams_id": 255,
  327. "tray_id": 0,
  328. },
  329. )
  330. assert resp.status_code == 200
  331. # extrusion_cali_sel should be called with the K-profile for extruder=1 (cali_idx=1)
  332. # The extruder itself is not passed as an argument — it's used internally to filter profiles
  333. mqtt_mock.extrusion_cali_sel.assert_called_once()
  334. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  335. assert call_kwargs["cali_idx"] == 1
  336. @pytest.mark.asyncio
  337. @pytest.mark.integration
  338. async def test_external_slot_tray1_maps_to_extruder0(
  339. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  340. ):
  341. """tray_id=1 on ams_id=255 → extruder=0 (ext-R)."""
  342. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  343. kp_extruder_0 = SpoolmanKProfile(
  344. spoolman_spool_id=10,
  345. printer_id=test_printer.id,
  346. extruder=0,
  347. nozzle_diameter="0.4",
  348. k_value=0.02,
  349. cali_idx=2,
  350. setting_id=None,
  351. )
  352. db_session.add(kp_extruder_0)
  353. await db_session.commit()
  354. printer_state = MagicMock()
  355. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
  356. printer_state.ams_extruder_map = {"0": 0}
  357. mqtt_mock = MagicMock()
  358. mqtt_mock.ams_set_filament_setting = MagicMock()
  359. mqtt_mock.extrusion_cali_sel = MagicMock()
  360. # Legacy attribute — production never had it set; keep for any code
  361. # path that still reads `mqtt_client.printer_state` directly. State
  362. # for the K-profile cascade now comes from printer_manager.get_status.
  363. mqtt_mock.printer_state = printer_state
  364. # Empty list = no printer-side kprofiles, so the realignment skips
  365. # printer_kp lookup. Tests that exercise realignment explicitly
  366. # populate this list themselves.
  367. if (
  368. not hasattr(printer_state, "kprofiles")
  369. or printer_state.kprofiles is None
  370. or isinstance(printer_state.kprofiles, MagicMock)
  371. ):
  372. printer_state.kprofiles = []
  373. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  374. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  375. pm_mock.get_status = MagicMock(return_value=printer_state)
  376. resp = await async_client.post(
  377. "/api/v1/spoolman/inventory/slot-assignments",
  378. json={
  379. "spoolman_spool_id": 10,
  380. "printer_id": test_printer.id,
  381. "ams_id": 255,
  382. "tray_id": 1,
  383. },
  384. )
  385. assert resp.status_code == 200
  386. # extrusion_cali_sel should be called with the K-profile for extruder=0 (cali_idx=2)
  387. mqtt_mock.extrusion_cali_sel.assert_called_once()
  388. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  389. assert call_kwargs["cali_idx"] == 2
  390. # ---------------------------------------------------------------------------
  391. # P9-TEST-BE: Live cali_idx fallback when no K-profile is stored (Bug #10)
  392. # ---------------------------------------------------------------------------
  393. class TestAssignSpoolmanSlotLiveCaliIdx:
  394. """When no SpoolmanKProfile exists, live tray cali_idx is used as fallback."""
  395. def _make_printer_state(self, ams_id: int, tray_id: int, cali_idx: int | None):
  396. """Build a minimal printer_state mock with one AMS tray."""
  397. tray_mock = {
  398. "id": tray_id,
  399. "cali_idx": cali_idx,
  400. }
  401. ams_mock = {"id": ams_id, "tray": [tray_mock]}
  402. state = MagicMock()
  403. state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  404. state.ams_extruder_map = {str(ams_id): 0}
  405. state.raw_data = {"ams": [ams_mock]}
  406. return state
  407. @pytest.mark.asyncio
  408. @pytest.mark.integration
  409. async def test_no_kprofile_uses_live_cali_idx(
  410. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  411. ):
  412. """When no K-profile exists, live tray cali_idx is sent via extrusion_cali_sel."""
  413. printer_state = self._make_printer_state(ams_id=0, tray_id=1, cali_idx=42)
  414. mqtt_mock = MagicMock()
  415. mqtt_mock.ams_set_filament_setting = MagicMock()
  416. mqtt_mock.extrusion_cali_sel = MagicMock()
  417. # Legacy attribute — production never had it set; keep for any code
  418. # path that still reads `mqtt_client.printer_state` directly. State
  419. # for the K-profile cascade now comes from printer_manager.get_status.
  420. mqtt_mock.printer_state = printer_state
  421. # Empty list = no printer-side kprofiles, so the realignment skips
  422. # printer_kp lookup. Tests that exercise realignment explicitly
  423. # populate this list themselves.
  424. if (
  425. not hasattr(printer_state, "kprofiles")
  426. or printer_state.kprofiles is None
  427. or isinstance(printer_state.kprofiles, MagicMock)
  428. ):
  429. printer_state.kprofiles = []
  430. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  431. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  432. pm_mock.get_status = MagicMock(return_value=printer_state)
  433. resp = await async_client.post(
  434. "/api/v1/spoolman/inventory/slot-assignments",
  435. json={
  436. "spoolman_spool_id": 10,
  437. "printer_id": test_printer.id,
  438. "ams_id": 0,
  439. "tray_id": 1,
  440. },
  441. )
  442. assert resp.status_code == 200
  443. mqtt_mock.extrusion_cali_sel.assert_called_once()
  444. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  445. assert call_kwargs["cali_idx"] == 42
  446. assert call_kwargs["ams_id"] == 0
  447. assert call_kwargs["tray_id"] == 1
  448. @pytest.mark.asyncio
  449. @pytest.mark.integration
  450. async def test_no_kprofile_no_live_cali_idx_nothing_sent(
  451. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  452. ):
  453. """When no K-profile and tray has no cali_idx, extrusion_cali_sel is not called."""
  454. printer_state = self._make_printer_state(ams_id=0, tray_id=2, cali_idx=None)
  455. mqtt_mock = MagicMock()
  456. mqtt_mock.ams_set_filament_setting = MagicMock()
  457. mqtt_mock.extrusion_cali_sel = MagicMock()
  458. # Legacy attribute — production never had it set; keep for any code
  459. # path that still reads `mqtt_client.printer_state` directly. State
  460. # for the K-profile cascade now comes from printer_manager.get_status.
  461. mqtt_mock.printer_state = printer_state
  462. # Empty list = no printer-side kprofiles, so the realignment skips
  463. # printer_kp lookup. Tests that exercise realignment explicitly
  464. # populate this list themselves.
  465. if (
  466. not hasattr(printer_state, "kprofiles")
  467. or printer_state.kprofiles is None
  468. or isinstance(printer_state.kprofiles, MagicMock)
  469. ):
  470. printer_state.kprofiles = []
  471. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  472. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  473. pm_mock.get_status = MagicMock(return_value=printer_state)
  474. resp = await async_client.post(
  475. "/api/v1/spoolman/inventory/slot-assignments",
  476. json={
  477. "spoolman_spool_id": 10,
  478. "printer_id": test_printer.id,
  479. "ams_id": 0,
  480. "tray_id": 2,
  481. },
  482. )
  483. assert resp.status_code == 200
  484. mqtt_mock.extrusion_cali_sel.assert_not_called()
  485. @pytest.mark.asyncio
  486. @pytest.mark.integration
  487. async def test_kprofile_takes_priority_over_live_cali_idx(
  488. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  489. ):
  490. """Stored K-profile cali_idx wins over live tray cali_idx."""
  491. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  492. kp = SpoolmanKProfile(
  493. spoolman_spool_id=10,
  494. printer_id=test_printer.id,
  495. extruder=0,
  496. nozzle_diameter="0.4",
  497. k_value=0.02,
  498. cali_idx=10,
  499. setting_id="CaliID",
  500. )
  501. db_session.add(kp)
  502. await db_session.commit()
  503. # Live tray has a different cali_idx — stored profile must win
  504. printer_state = self._make_printer_state(ams_id=0, tray_id=3, cali_idx=99)
  505. mqtt_mock = MagicMock()
  506. mqtt_mock.ams_set_filament_setting = MagicMock()
  507. mqtt_mock.extrusion_cali_sel = MagicMock()
  508. # Legacy attribute — production never had it set; keep for any code
  509. # path that still reads `mqtt_client.printer_state` directly. State
  510. # for the K-profile cascade now comes from printer_manager.get_status.
  511. mqtt_mock.printer_state = printer_state
  512. # Empty list = no printer-side kprofiles, so the realignment skips
  513. # printer_kp lookup. Tests that exercise realignment explicitly
  514. # populate this list themselves.
  515. if (
  516. not hasattr(printer_state, "kprofiles")
  517. or printer_state.kprofiles is None
  518. or isinstance(printer_state.kprofiles, MagicMock)
  519. ):
  520. printer_state.kprofiles = []
  521. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  522. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  523. pm_mock.get_status = MagicMock(return_value=printer_state)
  524. resp = await async_client.post(
  525. "/api/v1/spoolman/inventory/slot-assignments",
  526. json={
  527. "spoolman_spool_id": 10,
  528. "printer_id": test_printer.id,
  529. "ams_id": 0,
  530. "tray_id": 3,
  531. },
  532. )
  533. assert resp.status_code == 200
  534. mqtt_mock.extrusion_cali_sel.assert_called_once()
  535. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  536. # Must use stored K-profile (10), NOT live cali_idx (99)
  537. assert call_kwargs["cali_idx"] == 10
  538. @pytest.mark.asyncio
  539. @pytest.mark.integration
  540. async def test_live_cali_idx_not_used_if_negative(
  541. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  542. ):
  543. """A negative live cali_idx is invalid and must not be sent."""
  544. printer_state = self._make_printer_state(ams_id=0, tray_id=0, cali_idx=-1)
  545. mqtt_mock = MagicMock()
  546. mqtt_mock.ams_set_filament_setting = MagicMock()
  547. mqtt_mock.extrusion_cali_sel = MagicMock()
  548. # Legacy attribute — production never had it set; keep for any code
  549. # path that still reads `mqtt_client.printer_state` directly. State
  550. # for the K-profile cascade now comes from printer_manager.get_status.
  551. mqtt_mock.printer_state = printer_state
  552. # Empty list = no printer-side kprofiles, so the realignment skips
  553. # printer_kp lookup. Tests that exercise realignment explicitly
  554. # populate this list themselves.
  555. if (
  556. not hasattr(printer_state, "kprofiles")
  557. or printer_state.kprofiles is None
  558. or isinstance(printer_state.kprofiles, MagicMock)
  559. ):
  560. printer_state.kprofiles = []
  561. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  562. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  563. pm_mock.get_status = MagicMock(return_value=printer_state)
  564. resp = await async_client.post(
  565. "/api/v1/spoolman/inventory/slot-assignments",
  566. json={
  567. "spoolman_spool_id": 10,
  568. "printer_id": test_printer.id,
  569. "ams_id": 0,
  570. "tray_id": 0,
  571. },
  572. )
  573. assert resp.status_code == 200
  574. mqtt_mock.extrusion_cali_sel.assert_not_called()
  575. # ---------------------------------------------------------------------------
  576. # Realignment of slot filament context to K-profile preset
  577. # ---------------------------------------------------------------------------
  578. # When the user assigns a Spoolman spool whose stored kp was calibrated under
  579. # a specific filament preset (e.g. P-prefix local, or a named cloud preset),
  580. # the slot must be configured under THAT preset for the printer to find the
  581. # cali_idx in its calibration table. Without realignment the slot ends up on
  582. # generic PLA / default K — the symptom maztiggy reported on x1c-2 (#1114).
  583. class TestAssignSpoolmanSlotKProfileRealignment:
  584. """assign_spoolman_slot realigns tray_info_idx + setting_id to kp context."""
  585. @pytest.mark.asyncio
  586. @pytest.mark.integration
  587. async def test_realigns_to_printer_reported_filament_id(
  588. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  589. ):
  590. """When state.kprofiles has the cali_idx, use printer_kp.filament_id verbatim.
  591. The printer keys its calibration table by filament_id, not setting_id.
  592. For a P-prefix local preset (printer-registered), filament_id and
  593. tray_info_idx must match for the cali_idx to apply.
  594. """
  595. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  596. # Stored kp with setting_id but no filament_id (the schema gap)
  597. kp = SpoolmanKProfile(
  598. spoolman_spool_id=10,
  599. printer_id=test_printer.id,
  600. extruder=0,
  601. nozzle_diameter="0.4",
  602. k_value=0.025,
  603. cali_idx=8948,
  604. setting_id="PFUSedbf16b803ff3e",
  605. )
  606. db_session.add(kp)
  607. await db_session.commit()
  608. printer_state = MagicMock()
  609. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  610. printer_state.ams_extruder_map = {"0": 0}
  611. printer_state.raw_data = None
  612. # Live calibration entry from the printer — this is what cali_idx 8948
  613. # is actually registered under. P-prefix is a printer-local preset
  614. # (different from PFUS-prefix cloud user presets).
  615. printer_kp = MagicMock()
  616. printer_kp.slot_id = 8948
  617. printer_kp.nozzle_diameter = "0.4"
  618. printer_kp.filament_id = "P4d64437"
  619. printer_kp.setting_id = "PFUSedbf16b803ff3e"
  620. printer_state.kprofiles = [printer_kp]
  621. mqtt_mock = MagicMock()
  622. mqtt_mock.ams_set_filament_setting = MagicMock()
  623. mqtt_mock.extrusion_cali_sel = MagicMock()
  624. mqtt_mock.printer_state = printer_state
  625. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  626. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  627. pm_mock.get_status = MagicMock(return_value=printer_state)
  628. response = await async_client.post(
  629. "/api/v1/spoolman/inventory/slot-assignments",
  630. json={
  631. "spoolman_spool_id": 10,
  632. "printer_id": test_printer.id,
  633. "ams_id": 0,
  634. "tray_id": 1,
  635. },
  636. )
  637. assert response.status_code == 200
  638. # Both MQTT commands must reference the printer-reported filament_id
  639. # so the slot context and the cali_sel context match.
  640. amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  641. assert amf_kwargs["tray_info_idx"] == "P4d64437"
  642. assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
  643. cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  644. assert cs_kwargs["cali_idx"] == 8948
  645. assert cs_kwargs["filament_id"] == "P4d64437"
  646. @pytest.mark.asyncio
  647. @pytest.mark.integration
  648. async def test_skips_realignment_for_pfus_prefix(
  649. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  650. ):
  651. """PFUS-prefix cloud-user presets are rejected by the slicer in tray_info_idx.
  652. For those, tray_info_idx must stay as the GF* generic so the slicer
  653. can render the slot. setting_id can still be realigned to the cloud
  654. preset (slicer uses that for display), but tray_info_idx stays GF*.
  655. """
  656. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  657. kp = SpoolmanKProfile(
  658. spoolman_spool_id=10,
  659. printer_id=test_printer.id,
  660. extruder=0,
  661. nozzle_diameter="0.4",
  662. k_value=0.025,
  663. cali_idx=42,
  664. setting_id="PFUSedbf16b803ff3e",
  665. )
  666. db_session.add(kp)
  667. await db_session.commit()
  668. printer_state = MagicMock()
  669. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  670. printer_state.ams_extruder_map = {"0": 0}
  671. printer_state.raw_data = None
  672. # Printer-side kp filament_id is PFUS-prefix → realignment must skip
  673. printer_kp = MagicMock()
  674. printer_kp.slot_id = 42
  675. printer_kp.nozzle_diameter = "0.4"
  676. printer_kp.filament_id = "PFUSedbf16b803ff3e"
  677. printer_kp.setting_id = "PFUSedbf16b803ff3e"
  678. printer_state.kprofiles = [printer_kp]
  679. mqtt_mock = MagicMock()
  680. mqtt_mock.ams_set_filament_setting = MagicMock()
  681. mqtt_mock.extrusion_cali_sel = MagicMock()
  682. mqtt_mock.printer_state = printer_state
  683. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  684. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  685. pm_mock.get_status = MagicMock(return_value=printer_state)
  686. response = await async_client.post(
  687. "/api/v1/spoolman/inventory/slot-assignments",
  688. json={
  689. "spoolman_spool_id": 10,
  690. "printer_id": test_printer.id,
  691. "ams_id": 0,
  692. "tray_id": 2,
  693. },
  694. )
  695. assert response.status_code == 200
  696. amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  697. # tray_info_idx stays as the resolved generic (slicer accepts GF*)
  698. assert amf_kwargs["tray_info_idx"] == "GFL99"
  699. # setting_id may be realigned to the cloud preset for slicer display
  700. assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
  701. @pytest.mark.asyncio
  702. @pytest.mark.integration
  703. async def test_extruder_relax_falls_back_to_any_extruder_kp(
  704. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  705. ):
  706. """Hard-skip on extruder mismatch silently dropped valid stored profiles
  707. when the AMS-extruder map shifted. The cascade now prefers exact
  708. extruder match but falls back to any kp on the same printer + nozzle.
  709. """
  710. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  711. # kp is for extruder=1, but slot will be on extruder=0 (mismatch)
  712. kp = SpoolmanKProfile(
  713. spoolman_spool_id=10,
  714. printer_id=test_printer.id,
  715. extruder=1,
  716. nozzle_diameter="0.4",
  717. k_value=0.025,
  718. cali_idx=42,
  719. setting_id="GFSL05",
  720. )
  721. db_session.add(kp)
  722. await db_session.commit()
  723. printer_state = MagicMock()
  724. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  725. printer_state.ams_extruder_map = {"0": 0}
  726. printer_state.raw_data = None
  727. printer_state.kprofiles = []
  728. mqtt_mock = MagicMock()
  729. mqtt_mock.ams_set_filament_setting = MagicMock()
  730. mqtt_mock.extrusion_cali_sel = MagicMock()
  731. mqtt_mock.printer_state = printer_state
  732. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  733. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  734. pm_mock.get_status = MagicMock(return_value=printer_state)
  735. response = await async_client.post(
  736. "/api/v1/spoolman/inventory/slot-assignments",
  737. json={
  738. "spoolman_spool_id": 10,
  739. "printer_id": test_printer.id,
  740. "ams_id": 0,
  741. "tray_id": 3,
  742. },
  743. )
  744. assert response.status_code == 200
  745. # extruder mismatch was hard-skipped pre-fix; now used as fallback
  746. cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  747. assert cs_kwargs["cali_idx"] == 42