test_kprofile_drift_3219.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. """A slot that loses its K-profile selection gets the stored one back (#3219).
  2. An X1 Carbon power-cycled mid-print came back with every AMS slot on
  3. ``cali_idx: -1`` while tags, spools and remain% were unchanged. ``on_ams_change``
  4. never fired, so nothing restored the selections, and two queued jobs printed
  5. on the default K.
  6. """
  7. from contextlib import asynccontextmanager
  8. from types import SimpleNamespace
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.services import kprofile_drift
  12. from backend.app.services.slot_kprofile import SlotKProfile
  13. PRINTER = 7
  14. def _tray(tray_id, cali_idx=-1, tray_type="PLA"):
  15. return {"id": str(tray_id), "tray_type": tray_type, "cali_idx": cali_idx, "tray_info_idx": "GFA00"}
  16. def _state(trays, *, state="IDLE", connected=True, vt_tray=None, model_nozzles=1):
  17. raw = {"ams": {"ams": [{"id": "0", "tray": trays}]}}
  18. if vt_tray is not None:
  19. raw["vt_tray"] = vt_tray
  20. nozzles = [SimpleNamespace(nozzle_diameter="0.4", nozzle_type="") for _ in range(model_nozzles)]
  21. return SimpleNamespace(
  22. raw_data=raw,
  23. connected=connected,
  24. state=state,
  25. nozzles=nozzles,
  26. ams_extruder_map=None,
  27. ams_switch_inlet=None,
  28. )
  29. def _profile(cali_idx, name="PLA Basic 0.025"):
  30. return SlotKProfile(cali_idx=cali_idx, k_value=0.025, name=name, extruder=0, filament_id="GFA00")
  31. @pytest.fixture(autouse=True)
  32. def _clean_bookkeeping():
  33. kprofile_drift._watches.clear()
  34. kprofile_drift._default_chosen.clear()
  35. kprofile_drift._running.clear()
  36. yield
  37. kprofile_drift._watches.clear()
  38. kprofile_drift._default_chosen.clear()
  39. kprofile_drift._running.clear()
  40. @pytest.fixture
  41. def env():
  42. """Patch the printer, the database and the stored-profile lookup."""
  43. client = MagicMock()
  44. stored: dict[tuple[int, int], SlotKProfile] = {}
  45. holder = SimpleNamespace(state=None, client=client, stored=stored, model="X1C")
  46. async def lookup(_db, _printer_id, ams_id, tray_id, *_args, **_kwargs):
  47. return stored.get((ams_id, tray_id))
  48. @asynccontextmanager
  49. async def session():
  50. yield MagicMock()
  51. pm = MagicMock()
  52. pm.get_status.side_effect = lambda _pid: holder.state
  53. pm.get_client.side_effect = lambda _pid: holder.client
  54. pm.get_model.side_effect = lambda _pid: holder.model
  55. with (
  56. patch.object(kprofile_drift, "printer_manager", pm),
  57. patch.object(kprofile_drift, "async_session", session),
  58. patch.object(kprofile_drift, "find_slot_kprofile_for_extruder", AsyncMock(side_effect=lookup)) as finder,
  59. ):
  60. holder.finder = finder
  61. yield holder
  62. def _selected(client):
  63. return [
  64. (c.kwargs["ams_id"], c.kwargs["tray_id"], c.kwargs["cali_idx"])
  65. for c in client.extrusion_cali_sel.call_args_list
  66. ]
  67. @pytest.mark.asyncio
  68. async def test_lost_selection_is_restored(env):
  69. env.state = _state([_tray(0), _tray(1)])
  70. env.stored[(0, 0)] = _profile(9354)
  71. env.stored[(0, 1)] = _profile(3175)
  72. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 2
  73. assert sorted(_selected(env.client)) == [(0, 0, 9354), (0, 1, 3175)]
  74. call = env.client.extrusion_cali_sel.call_args_list[0]
  75. assert call.kwargs["filament_id"] == "GFA00"
  76. assert call.kwargs["nozzle_diameter"] == "0.4"
  77. @pytest.mark.asyncio
  78. async def test_missing_cali_idx_counts_as_lost(env):
  79. tray = _tray(0)
  80. del tray["cali_idx"]
  81. env.state = _state([tray])
  82. env.stored[(0, 0)] = _profile(9354)
  83. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  84. @pytest.mark.asyncio
  85. async def test_a_different_real_profile_is_left_alone(env):
  86. # Picked on purpose, in Bambu Studio or Bambuddy's Configure Slot dialog.
  87. env.state = _state([_tray(0, cali_idx=5)])
  88. env.stored[(0, 0)] = _profile(9354)
  89. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  90. env.client.extrusion_cali_sel.assert_not_called()
  91. @pytest.mark.asyncio
  92. async def test_slot_without_stored_profile_is_left_on_default(env):
  93. env.state = _state([_tray(0)])
  94. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  95. env.client.extrusion_cali_sel.assert_not_called()
  96. @pytest.mark.asyncio
  97. async def test_empty_slot_is_skipped(env):
  98. env.state = _state([_tray(0, tray_type="")])
  99. env.stored[(0, 0)] = _profile(9354)
  100. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  101. env.finder.assert_not_called()
  102. @pytest.mark.asyncio
  103. async def test_deliberate_default_pick_is_respected(env):
  104. env.state = _state([_tray(0)])
  105. env.stored[(0, 0)] = _profile(9354)
  106. kprofile_drift.note_slot_configured(PRINTER, 0, 0, -1)
  107. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  108. # Picking a real profile again in the dialog lifts it.
  109. kprofile_drift.note_slot_configured(PRINTER, 0, 0, 9354)
  110. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  111. @pytest.mark.asyncio
  112. async def test_default_pick_survives_the_push_that_still_shows_the_old_profile(env):
  113. env.stored[(0, 0)] = _profile(9354)
  114. kprofile_drift.note_slot_configured(PRINTER, 0, 0, -1)
  115. # The push right after the pick can still carry the previous selection.
  116. env.state = _state([_tray(0, cali_idx=9354)])
  117. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  118. env.state = _state([_tray(0)])
  119. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  120. @pytest.mark.asyncio
  121. async def test_emptying_the_slot_clears_a_default_pick(env):
  122. env.stored[(0, 0)] = _profile(9354)
  123. kprofile_drift.note_slot_configured(PRINTER, 0, 0, -1)
  124. env.state = _state([_tray(0, tray_type="")])
  125. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  126. env.state = _state([_tray(0)])
  127. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  128. @pytest.mark.asyncio
  129. async def test_retries_are_spaced_and_capped(env):
  130. env.state = _state([_tray(0)])
  131. env.stored[(0, 0)] = _profile(9354)
  132. clock = [1000.0]
  133. with patch.object(kprofile_drift.time, "monotonic", side_effect=lambda: clock[0]):
  134. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  135. # The next push, before the printer has applied it: no resend.
  136. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  137. for _ in range(kprofile_drift.MAX_ATTEMPTS + 2):
  138. clock[0] += kprofile_drift.RETRY_INTERVAL_S
  139. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  140. assert len(_selected(env.client)) == kprofile_drift.MAX_ATTEMPTS
  141. @pytest.mark.asyncio
  142. async def test_a_selection_that_sticks_resets_the_retry_count(env):
  143. env.stored[(0, 0)] = _profile(9354)
  144. env.state = _state([_tray(0)])
  145. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  146. env.state = _state([_tray(0, cali_idx=9354)])
  147. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  148. # Lost again later, e.g. the next power cycle: restored at once.
  149. env.state = _state([_tray(0)])
  150. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  151. @pytest.mark.asyncio
  152. async def test_disconnect_forgets_retries(env):
  153. env.state = _state([_tray(0)])
  154. env.stored[(0, 0)] = _profile(9354)
  155. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  156. kprofile_drift.forget_printer(PRINTER)
  157. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  158. @pytest.mark.asyncio
  159. async def test_dispatch_guard_ignores_the_retry_interval(env):
  160. env.state = _state([_tray(0)])
  161. env.stored[(0, 0)] = _profile(9354)
  162. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  163. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, throttle=False) == 1
  164. @pytest.mark.asyncio
  165. async def test_dispatch_guard_runs_while_an_idle_pass_is_in_flight(env):
  166. env.state = _state([_tray(0)])
  167. env.stored[(0, 0)] = _profile(9354)
  168. kprofile_drift._running.add(PRINTER)
  169. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  170. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, throttle=False) == 1
  171. @pytest.mark.asyncio
  172. async def test_dispatch_guard_only_touches_the_trays_the_job_uses(env):
  173. env.state = _state([_tray(0), _tray(1), _tray(2)])
  174. for tray_id in range(3):
  175. env.stored[(0, tray_id)] = _profile(100 + tray_id)
  176. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, {2}, throttle=False) == 1
  177. assert _selected(env.client) == [(0, 2, 102)]
  178. @pytest.mark.asyncio
  179. async def test_external_spool_is_covered(env):
  180. env.state = _state([], vt_tray=[{"id": "254", "tray_type": "PETG", "cali_idx": -1}])
  181. env.stored[(255, 0)] = _profile(42)
  182. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, {254}, throttle=False) == 1
  183. assert _selected(env.client) == [(255, 0, 42)]
  184. @pytest.mark.asyncio
  185. async def test_dual_nozzle_with_unknown_routing_is_not_guessed(env):
  186. env.model = "H2D"
  187. env.state = _state([_tray(0)], model_nozzles=2)
  188. env.stored[(0, 0)] = _profile(9354)
  189. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  190. env.finder.assert_not_called()
  191. @pytest.mark.asyncio
  192. async def test_disconnected_printer_is_skipped(env):
  193. env.state = _state([_tray(0)], connected=False)
  194. env.stored[(0, 0)] = _profile(9354)
  195. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
  196. @pytest.mark.asyncio
  197. async def test_single_nozzle_external_spool_finds_a_profile_under_either_extruder(env):
  198. """X1C external holder: slot_extruder says 1, the spool form stores 0."""
  199. env.state = _state([], vt_tray=[{"id": "254", "tray_type": "PETG", "cali_idx": -1}])
  200. seen = []
  201. async def lookup(_db, _pid, ams_id, tray_id, extruder, *_a, **_k):
  202. seen.append(extruder)
  203. return _profile(42) if extruder == 1 else None
  204. env.finder.side_effect = lookup
  205. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  206. assert seen == [0, 1]
  207. @pytest.mark.asyncio
  208. async def test_single_nozzle_ams_slot_asks_for_extruder_zero_only(env):
  209. env.state = _state([_tray(0)])
  210. env.stored[(0, 0)] = _profile(9354)
  211. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  212. assert [c.args[4] for c in env.finder.await_args_list] == [0]
  213. @pytest.mark.asyncio
  214. async def test_dual_nozzle_uses_only_the_slot_nozzle(env):
  215. env.model = "H2D"
  216. env.state = _state([_tray(0)], model_nozzles=2)
  217. env.state.ams_extruder_map = {"0": 1}
  218. env.stored[(0, 0)] = _profile(9354)
  219. assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
  220. assert [c.args[4] for c in env.finder.await_args_list] == [1]
  221. @pytest.mark.asyncio
  222. async def test_slot_with_nothing_stored_is_looked_up_rarely(env):
  223. env.state = _state([_tray(0)])
  224. clock = [1000.0]
  225. with patch.object(kprofile_drift.time, "monotonic", side_effect=lambda: clock[0]):
  226. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  227. clock[0] += kprofile_drift.RETRY_INTERVAL_S * 2
  228. assert kprofile_drift.needs_check(PRINTER, env.state) is False
  229. clock[0] += kprofile_drift.NO_PROFILE_RECHECK_S
  230. assert kprofile_drift.needs_check(PRINTER, env.state) is True
  231. assert env.finder.await_count == 1
  232. @pytest.mark.asyncio
  233. async def test_giving_up_is_logged_once_after_the_last_attempt_had_time_to_land(env, caplog):
  234. env.state = _state([_tray(0)])
  235. env.stored[(0, 0)] = _profile(9354)
  236. clock = [1000.0]
  237. with patch.object(kprofile_drift.time, "monotonic", side_effect=lambda: clock[0]):
  238. for _ in range(kprofile_drift.MAX_ATTEMPTS):
  239. await kprofile_drift.reapply_lost_kprofiles(PRINTER)
  240. # Not yet: the last send may still stick.
  241. assert "giving up" not in caplog.text
  242. clock[0] += kprofile_drift.RETRY_INTERVAL_S
  243. for _ in range(3):
  244. kprofile_drift.needs_check(PRINTER, env.state)
  245. clock[0] += kprofile_drift.RETRY_INTERVAL_S
  246. assert caplog.text.count("giving up") == 1
  247. class TestNeedsCheck:
  248. def test_idle_with_a_lost_slot(self):
  249. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)])) is True
  250. @pytest.mark.parametrize("gcode_state", ["RUNNING", "PAUSE", "PREPARE", "SLICING", "unknown"])
  251. def test_never_while_a_print_may_be_running(self, gcode_state):
  252. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)], state=gcode_state)) is False
  253. @pytest.mark.parametrize("gcode_state", ["IDLE", "FINISH", "FAILED"])
  254. def test_idle_states(self, gcode_state):
  255. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)], state=gcode_state)) is True
  256. def test_not_when_every_slot_has_a_profile(self):
  257. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0, cali_idx=3)])) is False
  258. def test_not_while_disconnected(self):
  259. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)], connected=False)) is False
  260. def test_not_while_a_pass_is_running(self):
  261. kprofile_drift._running.add(PRINTER)
  262. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)])) is False
  263. def test_not_again_within_the_retry_interval(self):
  264. kprofile_drift._watches[(PRINTER, 0, 0)] = kprofile_drift._SlotWatch(
  265. next_check=kprofile_drift.time.monotonic() + 30
  266. )
  267. assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)])) is False
  268. @pytest.mark.asyncio
  269. async def test_status_handler_spawns_the_check_and_forgets_on_disconnect():
  270. """The #3219 trigger lives in on_printer_status_change: every push, idle only."""
  271. from backend.app import main
  272. spawned = []
  273. def fake_spawn(coro, name=None):
  274. spawned.append(name)
  275. coro.close()
  276. with (
  277. patch.object(main, "spawn_background_task", side_effect=fake_spawn),
  278. patch.object(main.kprofile_drift, "needs_check", return_value=True) as needs_check,
  279. patch.object(main.kprofile_drift, "forget_printer") as forget,
  280. patch.object(main, "ws_manager") as ws,
  281. ):
  282. ws.send_printer_status = AsyncMock()
  283. state = MagicMock()
  284. state.connected = True
  285. state.state = "IDLE"
  286. state.nozzles = []
  287. try:
  288. await main.on_printer_status_change(PRINTER, state)
  289. except Exception:
  290. pass # Later parts of the handler need more of a real state.
  291. assert f"reapply-kprofiles-{PRINTER}" in spawned
  292. needs_check.assert_called_once_with(PRINTER, state)
  293. spawned.clear()
  294. state.connected = False
  295. try:
  296. await main.on_printer_status_change(PRINTER, state)
  297. except Exception:
  298. pass
  299. assert f"reapply-kprofiles-{PRINTER}" not in spawned
  300. forget.assert_called_with(PRINTER)
  301. @pytest.mark.asyncio
  302. async def test_a_failing_check_does_not_stop_the_status_broadcast():
  303. from backend.app import main
  304. with (
  305. patch.object(main.kprofile_drift, "needs_check", side_effect=RuntimeError("boom")),
  306. patch.object(main, "ws_manager") as ws,
  307. patch.object(main, "spawn_background_task", side_effect=lambda coro, name=None: coro.close()),
  308. ):
  309. ws.send_printer_status = AsyncMock()
  310. state = MagicMock()
  311. state.connected = True
  312. state.state = "IDLE"
  313. state.nozzles = []
  314. try:
  315. await main.on_printer_status_change(PRINTER, state)
  316. except RuntimeError as exc:
  317. pytest.fail(f"K-profile check escaped the status handler: {exc}")
  318. except Exception:
  319. pass # Later parts of the handler need more of a real state.