test_spoolman_slot_assignment_mqtt.py 35 KB

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