test_spoolman_slot_assignment_mqtt.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  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_resets_default_on_nozzle_mismatch(
  176. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  177. ):
  178. """When nozzle diameter doesn't match K-profile (no usable kp), slot resets to Default K."""
  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_called_once()
  224. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  225. @pytest.mark.asyncio
  226. @pytest.mark.integration
  227. async def test_extrusion_cali_sel_resets_default_when_cali_idx_none(
  228. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  229. ):
  230. """When stored K-profile has cali_idx=None (unusable), slot resets to Default K."""
  231. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  232. kp = SpoolmanKProfile(
  233. spoolman_spool_id=10,
  234. printer_id=test_printer.id,
  235. extruder=0,
  236. nozzle_diameter="0.4",
  237. k_value=0.02,
  238. cali_idx=None,
  239. setting_id=None,
  240. )
  241. db_session.add(kp)
  242. await db_session.commit()
  243. printer_state = MagicMock()
  244. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  245. printer_state.ams_extruder_map = {"0": 0}
  246. mqtt_mock = MagicMock()
  247. mqtt_mock.ams_set_filament_setting = MagicMock()
  248. mqtt_mock.extrusion_cali_sel = MagicMock()
  249. # Legacy attribute — production never had it set; keep for any code
  250. # path that still reads `mqtt_client.printer_state` directly. State
  251. # for the K-profile cascade now comes from printer_manager.get_status.
  252. mqtt_mock.printer_state = printer_state
  253. # Empty list = no printer-side kprofiles, so the realignment skips
  254. # printer_kp lookup. Tests that exercise realignment explicitly
  255. # populate this list themselves.
  256. if (
  257. not hasattr(printer_state, "kprofiles")
  258. or printer_state.kprofiles is None
  259. or isinstance(printer_state.kprofiles, MagicMock)
  260. ):
  261. printer_state.kprofiles = []
  262. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  263. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  264. pm_mock.get_status = MagicMock(return_value=printer_state)
  265. response = await async_client.post(
  266. "/api/v1/spoolman/inventory/slot-assignments",
  267. json={
  268. "spoolman_spool_id": 10,
  269. "printer_id": test_printer.id,
  270. "ams_id": 0,
  271. "tray_id": 3,
  272. },
  273. )
  274. assert response.status_code == 200
  275. mqtt_mock.extrusion_cali_sel.assert_called_once()
  276. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  277. # ---------------------------------------------------------------------------
  278. # F7: ams_id=255 External-Slot Extruder-Inversion
  279. # ---------------------------------------------------------------------------
  280. class TestExternalSlotExtruderInversion:
  281. """F7: ams_id=255 maps tray_id→extruder via inversion (0→1, 1→0)."""
  282. @pytest.mark.asyncio
  283. @pytest.mark.integration
  284. async def test_external_slot_tray0_maps_to_extruder1(
  285. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  286. ):
  287. """tray_id=0 on ams_id=255 → extruder=1 (ext-L)."""
  288. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  289. # Create K-profiles for both extruders so we can verify which one matches
  290. kp_extruder_1 = SpoolmanKProfile(
  291. spoolman_spool_id=10,
  292. printer_id=test_printer.id,
  293. extruder=1,
  294. nozzle_diameter="0.4",
  295. k_value=0.03,
  296. cali_idx=1,
  297. setting_id=None,
  298. )
  299. db_session.add(kp_extruder_1)
  300. await db_session.commit()
  301. printer_state = MagicMock()
  302. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
  303. printer_state.ams_extruder_map = {"0": 0} # present so external inversion logic triggers
  304. mqtt_mock = MagicMock()
  305. mqtt_mock.ams_set_filament_setting = MagicMock()
  306. mqtt_mock.extrusion_cali_sel = MagicMock()
  307. # Legacy attribute — production never had it set; keep for any code
  308. # path that still reads `mqtt_client.printer_state` directly. State
  309. # for the K-profile cascade now comes from printer_manager.get_status.
  310. mqtt_mock.printer_state = printer_state
  311. # Empty list = no printer-side kprofiles, so the realignment skips
  312. # printer_kp lookup. Tests that exercise realignment explicitly
  313. # populate this list themselves.
  314. if (
  315. not hasattr(printer_state, "kprofiles")
  316. or printer_state.kprofiles is None
  317. or isinstance(printer_state.kprofiles, MagicMock)
  318. ):
  319. printer_state.kprofiles = []
  320. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  321. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  322. pm_mock.get_status = MagicMock(return_value=printer_state)
  323. resp = await async_client.post(
  324. "/api/v1/spoolman/inventory/slot-assignments",
  325. json={
  326. "spoolman_spool_id": 10,
  327. "printer_id": test_printer.id,
  328. "ams_id": 255,
  329. "tray_id": 0,
  330. },
  331. )
  332. assert resp.status_code == 200
  333. # extrusion_cali_sel should be called with the K-profile for extruder=1 (cali_idx=1)
  334. # The extruder itself is not passed as an argument — it's used internally to filter profiles
  335. mqtt_mock.extrusion_cali_sel.assert_called_once()
  336. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  337. assert call_kwargs["cali_idx"] == 1
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_external_slot_tray1_maps_to_extruder0(
  341. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  342. ):
  343. """tray_id=1 on ams_id=255 → extruder=0 (ext-R)."""
  344. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  345. kp_extruder_0 = SpoolmanKProfile(
  346. spoolman_spool_id=10,
  347. printer_id=test_printer.id,
  348. extruder=0,
  349. nozzle_diameter="0.4",
  350. k_value=0.02,
  351. cali_idx=2,
  352. setting_id=None,
  353. )
  354. db_session.add(kp_extruder_0)
  355. await db_session.commit()
  356. printer_state = MagicMock()
  357. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
  358. printer_state.ams_extruder_map = {"0": 0}
  359. mqtt_mock = MagicMock()
  360. mqtt_mock.ams_set_filament_setting = MagicMock()
  361. mqtt_mock.extrusion_cali_sel = MagicMock()
  362. # Legacy attribute — production never had it set; keep for any code
  363. # path that still reads `mqtt_client.printer_state` directly. State
  364. # for the K-profile cascade now comes from printer_manager.get_status.
  365. mqtt_mock.printer_state = printer_state
  366. # Empty list = no printer-side kprofiles, so the realignment skips
  367. # printer_kp lookup. Tests that exercise realignment explicitly
  368. # populate this list themselves.
  369. if (
  370. not hasattr(printer_state, "kprofiles")
  371. or printer_state.kprofiles is None
  372. or isinstance(printer_state.kprofiles, MagicMock)
  373. ):
  374. printer_state.kprofiles = []
  375. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  376. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  377. pm_mock.get_status = MagicMock(return_value=printer_state)
  378. resp = await async_client.post(
  379. "/api/v1/spoolman/inventory/slot-assignments",
  380. json={
  381. "spoolman_spool_id": 10,
  382. "printer_id": test_printer.id,
  383. "ams_id": 255,
  384. "tray_id": 1,
  385. },
  386. )
  387. assert resp.status_code == 200
  388. # extrusion_cali_sel should be called with the K-profile for extruder=0 (cali_idx=2)
  389. mqtt_mock.extrusion_cali_sel.assert_called_once()
  390. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  391. assert call_kwargs["cali_idx"] == 2
  392. # ---------------------------------------------------------------------------
  393. # P9-TEST-BE: Live cali_idx fallback when no K-profile is stored (Bug #10)
  394. # ---------------------------------------------------------------------------
  395. class TestAssignSpoolmanSlotLiveCaliIdx:
  396. """When no SpoolmanKProfile exists, live tray cali_idx is used as fallback."""
  397. def _make_printer_state(self, ams_id: int, tray_id: int, cali_idx: int | None):
  398. """Build a minimal printer_state mock with one AMS tray."""
  399. tray_mock = {
  400. "id": tray_id,
  401. "cali_idx": cali_idx,
  402. }
  403. ams_mock = {"id": ams_id, "tray": [tray_mock]}
  404. state = MagicMock()
  405. state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  406. state.ams_extruder_map = {str(ams_id): 0}
  407. state.raw_data = {"ams": [ams_mock]}
  408. return state
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. async def test_no_kprofile_resets_to_default_k(
  412. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  413. ):
  414. """When no K-profile exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
  415. printer_state = self._make_printer_state(ams_id=0, tray_id=1, cali_idx=42)
  416. mqtt_mock = MagicMock()
  417. mqtt_mock.ams_set_filament_setting = MagicMock()
  418. mqtt_mock.extrusion_cali_sel = MagicMock()
  419. # Legacy attribute — production never had it set; keep for any code
  420. # path that still reads `mqtt_client.printer_state` directly. State
  421. # for the K-profile cascade now comes from printer_manager.get_status.
  422. mqtt_mock.printer_state = printer_state
  423. # Empty list = no printer-side kprofiles, so the realignment skips
  424. # printer_kp lookup. Tests that exercise realignment explicitly
  425. # populate this list themselves.
  426. if (
  427. not hasattr(printer_state, "kprofiles")
  428. or printer_state.kprofiles is None
  429. or isinstance(printer_state.kprofiles, MagicMock)
  430. ):
  431. printer_state.kprofiles = []
  432. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  433. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  434. pm_mock.get_status = MagicMock(return_value=printer_state)
  435. resp = await async_client.post(
  436. "/api/v1/spoolman/inventory/slot-assignments",
  437. json={
  438. "spoolman_spool_id": 10,
  439. "printer_id": test_printer.id,
  440. "ams_id": 0,
  441. "tray_id": 1,
  442. },
  443. )
  444. assert resp.status_code == 200
  445. mqtt_mock.extrusion_cali_sel.assert_called_once()
  446. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  447. assert call_kwargs["cali_idx"] == -1
  448. assert call_kwargs["ams_id"] == 0
  449. assert call_kwargs["tray_id"] == 1
  450. @pytest.mark.asyncio
  451. @pytest.mark.integration
  452. async def test_no_kprofile_no_live_cali_idx_sends_default(
  453. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  454. ):
  455. """When no K-profile and tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
  456. printer_state = self._make_printer_state(ams_id=0, tray_id=2, cali_idx=None)
  457. mqtt_mock = MagicMock()
  458. mqtt_mock.ams_set_filament_setting = MagicMock()
  459. mqtt_mock.extrusion_cali_sel = MagicMock()
  460. # Legacy attribute — production never had it set; keep for any code
  461. # path that still reads `mqtt_client.printer_state` directly. State
  462. # for the K-profile cascade now comes from printer_manager.get_status.
  463. mqtt_mock.printer_state = printer_state
  464. # Empty list = no printer-side kprofiles, so the realignment skips
  465. # printer_kp lookup. Tests that exercise realignment explicitly
  466. # populate this list themselves.
  467. if (
  468. not hasattr(printer_state, "kprofiles")
  469. or printer_state.kprofiles is None
  470. or isinstance(printer_state.kprofiles, MagicMock)
  471. ):
  472. printer_state.kprofiles = []
  473. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  474. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  475. pm_mock.get_status = MagicMock(return_value=printer_state)
  476. resp = await async_client.post(
  477. "/api/v1/spoolman/inventory/slot-assignments",
  478. json={
  479. "spoolman_spool_id": 10,
  480. "printer_id": test_printer.id,
  481. "ams_id": 0,
  482. "tray_id": 2,
  483. },
  484. )
  485. assert resp.status_code == 200
  486. mqtt_mock.extrusion_cali_sel.assert_called_once()
  487. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  488. @pytest.mark.asyncio
  489. @pytest.mark.integration
  490. async def test_kprofile_takes_priority_over_live_cali_idx(
  491. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  492. ):
  493. """Stored K-profile cali_idx wins over live tray cali_idx."""
  494. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  495. kp = SpoolmanKProfile(
  496. spoolman_spool_id=10,
  497. printer_id=test_printer.id,
  498. extruder=0,
  499. nozzle_diameter="0.4",
  500. k_value=0.02,
  501. cali_idx=10,
  502. setting_id="CaliID",
  503. )
  504. db_session.add(kp)
  505. await db_session.commit()
  506. # Live tray has a different cali_idx — stored profile must win
  507. printer_state = self._make_printer_state(ams_id=0, tray_id=3, cali_idx=99)
  508. mqtt_mock = MagicMock()
  509. mqtt_mock.ams_set_filament_setting = MagicMock()
  510. mqtt_mock.extrusion_cali_sel = MagicMock()
  511. # Legacy attribute — production never had it set; keep for any code
  512. # path that still reads `mqtt_client.printer_state` directly. State
  513. # for the K-profile cascade now comes from printer_manager.get_status.
  514. mqtt_mock.printer_state = printer_state
  515. # Empty list = no printer-side kprofiles, so the realignment skips
  516. # printer_kp lookup. Tests that exercise realignment explicitly
  517. # populate this list themselves.
  518. if (
  519. not hasattr(printer_state, "kprofiles")
  520. or printer_state.kprofiles is None
  521. or isinstance(printer_state.kprofiles, MagicMock)
  522. ):
  523. printer_state.kprofiles = []
  524. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  525. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  526. pm_mock.get_status = MagicMock(return_value=printer_state)
  527. resp = await async_client.post(
  528. "/api/v1/spoolman/inventory/slot-assignments",
  529. json={
  530. "spoolman_spool_id": 10,
  531. "printer_id": test_printer.id,
  532. "ams_id": 0,
  533. "tray_id": 3,
  534. },
  535. )
  536. assert resp.status_code == 200
  537. mqtt_mock.extrusion_cali_sel.assert_called_once()
  538. call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  539. # Must use stored K-profile (10), NOT live cali_idx (99)
  540. assert call_kwargs["cali_idx"] == 10
  541. @pytest.mark.asyncio
  542. @pytest.mark.integration
  543. async def test_live_cali_idx_negative_falls_back_to_default(
  544. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
  545. ):
  546. """A negative live cali_idx falls through and is sent as Default (cali_idx=-1)."""
  547. printer_state = self._make_printer_state(ams_id=0, tray_id=0, cali_idx=-1)
  548. mqtt_mock = MagicMock()
  549. mqtt_mock.ams_set_filament_setting = MagicMock()
  550. mqtt_mock.extrusion_cali_sel = MagicMock()
  551. # Legacy attribute — production never had it set; keep for any code
  552. # path that still reads `mqtt_client.printer_state` directly. State
  553. # for the K-profile cascade now comes from printer_manager.get_status.
  554. mqtt_mock.printer_state = printer_state
  555. # Empty list = no printer-side kprofiles, so the realignment skips
  556. # printer_kp lookup. Tests that exercise realignment explicitly
  557. # populate this list themselves.
  558. if (
  559. not hasattr(printer_state, "kprofiles")
  560. or printer_state.kprofiles is None
  561. or isinstance(printer_state.kprofiles, MagicMock)
  562. ):
  563. printer_state.kprofiles = []
  564. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  565. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  566. pm_mock.get_status = MagicMock(return_value=printer_state)
  567. resp = await async_client.post(
  568. "/api/v1/spoolman/inventory/slot-assignments",
  569. json={
  570. "spoolman_spool_id": 10,
  571. "printer_id": test_printer.id,
  572. "ams_id": 0,
  573. "tray_id": 0,
  574. },
  575. )
  576. assert resp.status_code == 200
  577. mqtt_mock.extrusion_cali_sel.assert_called_once()
  578. assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  579. # ---------------------------------------------------------------------------
  580. # Realignment of slot filament context to K-profile preset
  581. # ---------------------------------------------------------------------------
  582. # When the user assigns a Spoolman spool whose stored kp was calibrated under
  583. # a specific filament preset (e.g. P-prefix local, or a named cloud preset),
  584. # the slot must be configured under THAT preset for the printer to find the
  585. # cali_idx in its calibration table. Without realignment the slot ends up on
  586. # generic PLA / default K — the symptom maztiggy reported on x1c-2 (#1114).
  587. class TestAssignSpoolmanSlotKProfileRealignment:
  588. """assign_spoolman_slot realigns tray_info_idx + setting_id to kp context."""
  589. @pytest.mark.asyncio
  590. @pytest.mark.integration
  591. async def test_realigns_to_printer_reported_filament_id(
  592. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  593. ):
  594. """When state.kprofiles has the cali_idx, use printer_kp.filament_id verbatim.
  595. The printer keys its calibration table by filament_id, not setting_id.
  596. For a P-prefix local preset (printer-registered), filament_id and
  597. tray_info_idx must match for the cali_idx to apply.
  598. """
  599. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  600. # Stored kp with setting_id but no filament_id (the schema gap)
  601. kp = SpoolmanKProfile(
  602. spoolman_spool_id=10,
  603. printer_id=test_printer.id,
  604. extruder=0,
  605. nozzle_diameter="0.4",
  606. k_value=0.025,
  607. cali_idx=8948,
  608. setting_id="PFUSedbf16b803ff3e",
  609. )
  610. db_session.add(kp)
  611. await db_session.commit()
  612. printer_state = MagicMock()
  613. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  614. printer_state.ams_extruder_map = {"0": 0}
  615. printer_state.raw_data = None
  616. # Live calibration entry from the printer — this is what cali_idx 8948
  617. # is actually registered under. P-prefix is a printer-local preset
  618. # (different from PFUS-prefix cloud user presets).
  619. printer_kp = MagicMock()
  620. printer_kp.slot_id = 8948
  621. printer_kp.nozzle_diameter = "0.4"
  622. printer_kp.filament_id = "P4d64437"
  623. printer_kp.setting_id = "PFUSedbf16b803ff3e"
  624. printer_state.kprofiles = [printer_kp]
  625. mqtt_mock = MagicMock()
  626. mqtt_mock.ams_set_filament_setting = MagicMock()
  627. mqtt_mock.extrusion_cali_sel = MagicMock()
  628. mqtt_mock.printer_state = printer_state
  629. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  630. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  631. pm_mock.get_status = MagicMock(return_value=printer_state)
  632. response = await async_client.post(
  633. "/api/v1/spoolman/inventory/slot-assignments",
  634. json={
  635. "spoolman_spool_id": 10,
  636. "printer_id": test_printer.id,
  637. "ams_id": 0,
  638. "tray_id": 1,
  639. },
  640. )
  641. assert response.status_code == 200
  642. # Both MQTT commands must reference the printer-reported filament_id
  643. # so the slot context and the cali_sel context match.
  644. amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  645. assert amf_kwargs["tray_info_idx"] == "P4d64437"
  646. assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
  647. cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  648. assert cs_kwargs["cali_idx"] == 8948
  649. assert cs_kwargs["filament_id"] == "P4d64437"
  650. @pytest.mark.asyncio
  651. @pytest.mark.integration
  652. async def test_skips_realignment_for_pfus_prefix(
  653. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  654. ):
  655. """PFUS-prefix cloud-user presets are rejected by the slicer in tray_info_idx.
  656. For those, tray_info_idx must stay as the GF* generic so the slicer
  657. can render the slot. setting_id can still be realigned to the cloud
  658. preset (slicer uses that for display), but tray_info_idx stays GF*.
  659. """
  660. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  661. kp = SpoolmanKProfile(
  662. spoolman_spool_id=10,
  663. printer_id=test_printer.id,
  664. extruder=0,
  665. nozzle_diameter="0.4",
  666. k_value=0.025,
  667. cali_idx=42,
  668. setting_id="PFUSedbf16b803ff3e",
  669. )
  670. db_session.add(kp)
  671. await db_session.commit()
  672. printer_state = MagicMock()
  673. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  674. printer_state.ams_extruder_map = {"0": 0}
  675. printer_state.raw_data = None
  676. # Printer-side kp filament_id is PFUS-prefix → realignment must skip
  677. printer_kp = MagicMock()
  678. printer_kp.slot_id = 42
  679. printer_kp.nozzle_diameter = "0.4"
  680. printer_kp.filament_id = "PFUSedbf16b803ff3e"
  681. printer_kp.setting_id = "PFUSedbf16b803ff3e"
  682. printer_state.kprofiles = [printer_kp]
  683. mqtt_mock = MagicMock()
  684. mqtt_mock.ams_set_filament_setting = MagicMock()
  685. mqtt_mock.extrusion_cali_sel = MagicMock()
  686. mqtt_mock.printer_state = printer_state
  687. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  688. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  689. pm_mock.get_status = MagicMock(return_value=printer_state)
  690. response = await async_client.post(
  691. "/api/v1/spoolman/inventory/slot-assignments",
  692. json={
  693. "spoolman_spool_id": 10,
  694. "printer_id": test_printer.id,
  695. "ams_id": 0,
  696. "tray_id": 2,
  697. },
  698. )
  699. assert response.status_code == 200
  700. amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
  701. # tray_info_idx stays as the resolved generic (slicer accepts GF*)
  702. assert amf_kwargs["tray_info_idx"] == "GFL99"
  703. # setting_id may be realigned to the cloud preset for slicer display
  704. assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
  705. @pytest.mark.asyncio
  706. @pytest.mark.integration
  707. async def test_extruder_relax_falls_back_to_any_extruder_kp(
  708. self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
  709. ):
  710. """Hard-skip on extruder mismatch silently dropped valid stored profiles
  711. when the AMS-extruder map shifted. The cascade now prefers exact
  712. extruder match but falls back to any kp on the same printer + nozzle.
  713. """
  714. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  715. # kp is for extruder=1, but slot will be on extruder=0 (mismatch)
  716. kp = SpoolmanKProfile(
  717. spoolman_spool_id=10,
  718. printer_id=test_printer.id,
  719. extruder=1,
  720. nozzle_diameter="0.4",
  721. k_value=0.025,
  722. cali_idx=42,
  723. setting_id="GFSL05",
  724. )
  725. db_session.add(kp)
  726. await db_session.commit()
  727. printer_state = MagicMock()
  728. printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  729. printer_state.ams_extruder_map = {"0": 0}
  730. printer_state.raw_data = None
  731. printer_state.kprofiles = []
  732. mqtt_mock = MagicMock()
  733. mqtt_mock.ams_set_filament_setting = MagicMock()
  734. mqtt_mock.extrusion_cali_sel = MagicMock()
  735. mqtt_mock.printer_state = printer_state
  736. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
  737. pm_mock.get_client = MagicMock(return_value=mqtt_mock)
  738. pm_mock.get_status = MagicMock(return_value=printer_state)
  739. response = await async_client.post(
  740. "/api/v1/spoolman/inventory/slot-assignments",
  741. json={
  742. "spoolman_spool_id": 10,
  743. "printer_id": test_printer.id,
  744. "ams_id": 0,
  745. "tray_id": 3,
  746. },
  747. )
  748. assert response.status_code == 200
  749. # extruder mismatch was hard-skipped pre-fix; now used as fallback
  750. cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
  751. assert cs_kwargs["cali_idx"] == 42