test_scheduler_preheat_fast_path.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. """Tests for the `_preheat_and_soak` fast-path short-circuit.
  2. When the chamber has already been at temperature for the full soak duration
  3. AND the bed is currently at target, the preheat stage skips the convergence
  4. wait and soak entirely. Before the fix it returned WITHOUT sending M140,
  5. airduct, or M141 — the bed cooled while the 3MF uploaded. The regression
  6. guard here is: fast path fires ⇒ all applicable heater/flap commands are
  7. sent, and the slow path (convergence wait + soak) is skipped.
  8. Distinguishing the paths is done via `db.commit` — the slow path commits
  9. before the convergence loop (releases the pooled connection during the
  10. sleep-heavy wait) so `db.commit.await_count == 0` is a reliable signal
  11. that the fast path returned early.
  12. """
  13. from collections import deque
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.services.print_scheduler import (
  18. _AIRDUCT_MODE_COOLING,
  19. _AIRDUCT_MODE_HEATING,
  20. PrintScheduler,
  21. )
  22. NOW = 10_000.0
  23. PRINTER_ID = 7
  24. @pytest.fixture
  25. def scheduler():
  26. return PrintScheduler()
  27. @pytest.fixture
  28. def item():
  29. return SimpleNamespace(
  30. id=42,
  31. preheat_override="inherit",
  32. preheat_chamber_target_override=60, # forces chamber_target=60, do_chamber=True
  33. )
  34. @pytest.fixture
  35. def archive():
  36. return SimpleNamespace(bed_temperature=60)
  37. def _make_printer(model: str, printer_id: int = PRINTER_ID):
  38. return SimpleNamespace(id=printer_id, model=model)
  39. def _make_client():
  40. client = MagicMock()
  41. client.set_bed_temperature = MagicMock(return_value=True)
  42. client.set_chamber_temperature = MagicMock(return_value=True)
  43. client.set_airduct_mode = MagicMock(return_value=True)
  44. return client
  45. def _make_state(*, bed_temp=0.0, chamber_temp=0.0, airduct_mode=_AIRDUCT_MODE_COOLING):
  46. return SimpleNamespace(
  47. temperatures={"bed": bed_temp, "chamber": chamber_temp},
  48. raw_data={},
  49. airduct_mode=airduct_mode,
  50. )
  51. def _ints(**values):
  52. return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
  53. def _preload_dense_history(scheduler, *, printer_id=PRINTER_ID, chamber_temp=62.0, duration=1800, interval=30):
  54. """Pre-fill scheduler._chamber_history so _chamber_soak_remaining returns 0.
  55. Uses dense samples (30s apart) covering the full soak window so the
  56. contiguity guard sees an unbroken run.
  57. """
  58. d: deque = deque()
  59. ts = NOW - duration
  60. while ts <= NOW:
  61. d.append((ts, float(chamber_temp)))
  62. ts += interval
  63. scheduler._chamber_history[printer_id] = d
  64. # ---------------------------------------------------------------------------
  65. # Fast path fires — sends all applicable targets
  66. # ---------------------------------------------------------------------------
  67. @pytest.mark.asyncio
  68. async def test_fast_path_fires_sends_bed_airduct_chamber_on_h2d(scheduler, item, archive):
  69. """H2D (heater + airduct + sensor) hits the fast path with all three commands."""
  70. _preload_dense_history(scheduler)
  71. db = AsyncMock()
  72. client = _make_client()
  73. with (
  74. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  75. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
  76. patch("backend.app.services.print_scheduler.time") as t,
  77. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  78. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  79. ):
  80. t.monotonic.return_value = NOW
  81. pm.get_client.return_value = client
  82. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  83. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  84. client.set_bed_temperature.assert_called_once_with(60)
  85. client.set_chamber_temperature.assert_called_once_with(60)
  86. client.set_airduct_mode.assert_called_once_with("heating")
  87. # Slow path commits `db` before the convergence wait; fast path returns first.
  88. assert db.commit.await_count == 0
  89. @pytest.mark.asyncio
  90. async def test_fast_path_fires_sends_bed_only_on_x1c(scheduler, item, archive):
  91. """X1C has a chamber sensor but no heater and no airduct — only M140 fires."""
  92. _preload_dense_history(scheduler)
  93. db = AsyncMock()
  94. client = _make_client()
  95. with (
  96. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  97. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
  98. patch("backend.app.services.print_scheduler.time") as t,
  99. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  100. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  101. ):
  102. t.monotonic.return_value = NOW
  103. pm.get_client.return_value = client
  104. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  105. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  106. client.set_bed_temperature.assert_called_once_with(60)
  107. client.set_chamber_temperature.assert_not_called()
  108. client.set_airduct_mode.assert_not_called()
  109. assert db.commit.await_count == 0
  110. @pytest.mark.asyncio
  111. async def test_fast_path_skips_airduct_when_already_in_heating(scheduler, item, archive):
  112. """Airduct already reported as heating → do NOT publish set_airduct_mode."""
  113. _preload_dense_history(scheduler)
  114. db = AsyncMock()
  115. client = _make_client()
  116. with (
  117. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  118. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
  119. patch("backend.app.services.print_scheduler.time") as t,
  120. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  121. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  122. ):
  123. t.monotonic.return_value = NOW
  124. pm.get_client.return_value = client
  125. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0, airduct_mode=_AIRDUCT_MODE_HEATING)
  126. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  127. client.set_bed_temperature.assert_called_once_with(60)
  128. client.set_chamber_temperature.assert_called_once_with(60)
  129. client.set_airduct_mode.assert_not_called() # idempotence guard
  130. # ---------------------------------------------------------------------------
  131. # Fast path DOES NOT fire — falls through to slow path
  132. # ---------------------------------------------------------------------------
  133. @pytest.mark.asyncio
  134. async def test_fast_path_skipped_when_no_history(scheduler, item, archive):
  135. """Empty chamber history → _chamber_soak_remaining returns full soak → slow path."""
  136. db = AsyncMock()
  137. client = _make_client()
  138. with (
  139. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  140. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
  141. patch("backend.app.services.print_scheduler.time") as t,
  142. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  143. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  144. ):
  145. t.monotonic.return_value = NOW
  146. pm.get_client.return_value = client
  147. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  148. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  149. # Slow path commits `db` before the convergence wait.
  150. assert db.commit.await_count >= 1
  151. @pytest.mark.asyncio
  152. async def test_fast_path_skipped_when_bed_too_cold(scheduler, item, archive):
  153. """Bed below target - 2 → cannot skip preheat, falls through to slow path."""
  154. _preload_dense_history(scheduler)
  155. db = AsyncMock()
  156. client = _make_client()
  157. with (
  158. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  159. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
  160. patch("backend.app.services.print_scheduler.time") as t,
  161. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  162. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  163. ):
  164. t.monotonic.return_value = NOW
  165. pm.get_client.return_value = client
  166. # Bed at 30°C, way below 60°C target — fast path condition fails.
  167. pm.get_status.return_value = _make_state(bed_temp=30.0, chamber_temp=62.0)
  168. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  169. assert db.commit.await_count >= 1
  170. @pytest.mark.asyncio
  171. async def test_fast_path_skipped_when_chamber_currently_below_target(scheduler, item, archive):
  172. """Chamber history shows history but current chamber reading is cold → slow path."""
  173. _preload_dense_history(scheduler) # history says "hot"
  174. db = AsyncMock()
  175. client = _make_client()
  176. with (
  177. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  178. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
  179. patch("backend.app.services.print_scheduler.time") as t,
  180. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  181. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  182. ):
  183. t.monotonic.return_value = NOW
  184. pm.get_client.return_value = client
  185. # Bed at target, but current chamber reading is 40°C (below 58 = 60-2).
  186. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=40.0)
  187. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  188. assert db.commit.await_count >= 1
  189. @pytest.mark.asyncio
  190. async def test_fast_path_skipped_when_no_sensor_model(scheduler, item, archive):
  191. """P1S has no chamber sensor → has_sensor=False → fast path condition fails."""
  192. _preload_dense_history(scheduler)
  193. db = AsyncMock()
  194. client = _make_client()
  195. with (
  196. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  197. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
  198. patch("backend.app.services.print_scheduler.time") as t,
  199. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  200. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  201. ):
  202. t.monotonic.return_value = NOW
  203. pm.get_client.return_value = client
  204. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  205. await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
  206. assert db.commit.await_count >= 1
  207. @pytest.mark.asyncio
  208. async def test_fast_path_skipped_when_soak_seconds_zero(scheduler, item, archive):
  209. """soak_seconds=0 disables the fast path (nothing to skip) — slow path runs."""
  210. _preload_dense_history(scheduler)
  211. db = AsyncMock()
  212. client = _make_client()
  213. with (
  214. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  215. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
  216. patch("backend.app.services.print_scheduler.time") as t,
  217. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  218. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  219. ):
  220. t.monotonic.return_value = NOW
  221. pm.get_client.return_value = client
  222. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  223. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  224. assert db.commit.await_count >= 1
  225. # ---------------------------------------------------------------------------
  226. # Preheat rollback pin: fast path populates it correctly for `_dispatch_one`
  227. # ---------------------------------------------------------------------------
  228. @pytest.mark.asyncio
  229. async def test_fast_path_registers_all_actions_in_pin_on_h2d(scheduler, item, archive):
  230. """H2D fast path fires bed + airduct + chamber → pin has all three keys."""
  231. _preload_dense_history(scheduler)
  232. db = AsyncMock()
  233. client = _make_client()
  234. with (
  235. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  236. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
  237. patch("backend.app.services.print_scheduler.time") as t,
  238. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  239. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  240. ):
  241. t.monotonic.return_value = NOW
  242. pm.get_client.return_value = client
  243. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  244. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  245. assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed", "airduct", "chamber"}
  246. @pytest.mark.asyncio
  247. async def test_fast_path_registers_only_bed_in_pin_on_x1c(scheduler, item, archive):
  248. """X1C fast path fires bed only (no heater, no airduct) → pin has just 'bed'."""
  249. _preload_dense_history(scheduler)
  250. db = AsyncMock()
  251. client = _make_client()
  252. with (
  253. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  254. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
  255. patch("backend.app.services.print_scheduler.time") as t,
  256. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  257. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  258. ):
  259. t.monotonic.return_value = NOW
  260. pm.get_client.return_value = client
  261. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  262. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  263. assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed"}
  264. @pytest.mark.asyncio
  265. async def test_fast_path_skips_airduct_pin_when_already_heating(scheduler, item, archive):
  266. """Airduct already in heating → not published, not added to pin (nothing to unwind)."""
  267. _preload_dense_history(scheduler)
  268. db = AsyncMock()
  269. client = _make_client()
  270. with (
  271. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  272. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
  273. patch("backend.app.services.print_scheduler.time") as t,
  274. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  275. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  276. ):
  277. t.monotonic.return_value = NOW
  278. pm.get_client.return_value = client
  279. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0, airduct_mode=_AIRDUCT_MODE_HEATING)
  280. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  281. assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed", "chamber"}
  282. # ---------------------------------------------------------------------------
  283. # _rollback_preheat_pin: unwinds every registered action, best-effort, no raise
  284. # ---------------------------------------------------------------------------
  285. def test_rollback_preheat_pin_unwinds_all_three_actions(scheduler):
  286. """Pin contains all three keys → three cleanup commands fire, pin dict shrinks."""
  287. scheduler._preheat_pin[PRINTER_ID] = {"bed", "chamber", "airduct"}
  288. client = _make_client()
  289. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  290. pm.get_client.return_value = client
  291. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  292. client.set_bed_temperature.assert_called_once_with(0)
  293. client.set_chamber_temperature.assert_called_once_with(0)
  294. client.set_airduct_mode.assert_called_once_with("cooling")
  295. assert PRINTER_ID not in scheduler._preheat_pin
  296. def test_rollback_preheat_pin_only_unwinds_registered_keys(scheduler):
  297. """Pin has only {bed} → only that command fires; chamber/airduct untouched."""
  298. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  299. client = _make_client()
  300. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  301. pm.get_client.return_value = client
  302. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  303. client.set_bed_temperature.assert_called_once_with(0)
  304. client.set_chamber_temperature.assert_not_called()
  305. client.set_airduct_mode.assert_not_called()
  306. def test_rollback_preheat_pin_noop_when_pin_absent(scheduler):
  307. """No pin entry for this printer → no client lookup, no commands, no crash."""
  308. client = _make_client()
  309. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  310. pm.get_client.return_value = client
  311. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  312. client.set_bed_temperature.assert_not_called()
  313. def test_rollback_preheat_pin_noop_when_client_missing(scheduler):
  314. """Client is None (e.g. printer deregistered mid-dispatch) → no crash, pin still popped."""
  315. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  316. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  317. pm.get_client.return_value = None
  318. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  319. # Pin was consumed even though there was nothing to send to.
  320. assert PRINTER_ID not in scheduler._preheat_pin
  321. def test_rollback_preheat_pin_swallows_setter_exceptions(scheduler):
  322. """A setter raising must not propagate — the interesting exception is upstream."""
  323. scheduler._preheat_pin[PRINTER_ID] = {"bed", "chamber", "airduct"}
  324. client = _make_client()
  325. client.set_bed_temperature.side_effect = RuntimeError("mqtt down")
  326. client.set_chamber_temperature.side_effect = RuntimeError("mqtt down")
  327. client.set_airduct_mode.side_effect = RuntimeError("mqtt down")
  328. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  329. pm.get_client.return_value = client
  330. # Must not raise.
  331. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  332. assert PRINTER_ID not in scheduler._preheat_pin
  333. # ---------------------------------------------------------------------------
  334. # Missing bed_temperature metadata: heat the bed anyway when the chamber needs it
  335. # ---------------------------------------------------------------------------
  336. @pytest.mark.asyncio
  337. async def test_preheat_falls_back_to_configured_bed_temp_when_metadata_missing(scheduler, item):
  338. """No parsed bed temperature + chamber target > 0 → heat the bed to the configured temp.
  339. Previously preheat returned immediately ("archive has no bed_temperature
  340. metadata"), so the chamber phase never ran and the print started cold —
  341. the exact outcome preheat exists to prevent. The bed is how the chamber
  342. gets hot, so a missing bed temperature must not disable the stage.
  343. """
  344. db = AsyncMock()
  345. client = _make_client()
  346. archive_no_bed = SimpleNamespace(bed_temperature=None)
  347. with (
  348. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  349. patch.object(
  350. scheduler,
  351. "_get_int_setting",
  352. _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
  353. ),
  354. patch("backend.app.services.print_scheduler.time") as t,
  355. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  356. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  357. ):
  358. t.monotonic.return_value = NOW
  359. pm.get_client.return_value = client
  360. pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
  361. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive_no_bed)
  362. client.set_bed_temperature.assert_called_once_with(90)
  363. # The stage ran rather than returning early (slow path commits before waiting).
  364. assert db.commit.await_count >= 1
  365. @pytest.mark.asyncio
  366. async def test_preheat_fallback_honours_configured_temp(scheduler, item):
  367. """The fallback reads `queue_keep_warm_bed_temp`; it is not hard-coded."""
  368. db = AsyncMock()
  369. client = _make_client()
  370. archive_no_bed = SimpleNamespace(bed_temperature=None)
  371. with (
  372. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  373. patch.object(
  374. scheduler,
  375. "_get_int_setting",
  376. _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=100),
  377. ),
  378. patch("backend.app.services.print_scheduler.time") as t,
  379. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  380. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  381. ):
  382. t.monotonic.return_value = NOW
  383. pm.get_client.return_value = client
  384. pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
  385. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive_no_bed)
  386. client.set_bed_temperature.assert_called_once_with(100)
  387. @pytest.mark.asyncio
  388. async def test_preheat_still_skips_when_no_bed_temp_and_no_chamber_target(scheduler):
  389. """No bed metadata AND no chamber requirement → nothing to preheat for; skip.
  390. Guards the unchanged half of the branch: a PLA print with no parsed bed
  391. temperature must not have one invented for it.
  392. """
  393. db = AsyncMock()
  394. client = _make_client()
  395. pla_item = SimpleNamespace(id=43, preheat_override="inherit", preheat_chamber_target_override=0)
  396. archive_no_bed = SimpleNamespace(bed_temperature=None)
  397. with (
  398. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  399. patch.object(
  400. scheduler,
  401. "_get_int_setting",
  402. _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
  403. ),
  404. patch("backend.app.services.print_scheduler.time") as t,
  405. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  406. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  407. ):
  408. t.monotonic.return_value = NOW
  409. pm.get_client.return_value = client
  410. pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
  411. await scheduler._preheat_and_soak(db, pla_item, _make_printer("X1C"), archive_no_bed)
  412. client.set_bed_temperature.assert_not_called()
  413. assert db.commit.await_count == 0
  414. @pytest.mark.asyncio
  415. async def test_preheat_prefers_parsed_bed_temp_over_fallback(scheduler, item, archive):
  416. """A parsed bed temperature is used as-is — the fallback only fills a gap."""
  417. db = AsyncMock()
  418. client = _make_client()
  419. with (
  420. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  421. patch.object(
  422. scheduler,
  423. "_get_int_setting",
  424. _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
  425. ),
  426. patch("backend.app.services.print_scheduler.time") as t,
  427. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  428. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  429. ):
  430. t.monotonic.return_value = NOW
  431. pm.get_client.return_value = client
  432. pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
  433. # archive fixture carries bed_temperature=60
  434. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  435. client.set_bed_temperature.assert_called_once_with(60)
  436. # ---------------------------------------------------------------------------
  437. # Cancellation during preheat: stop heating, abandon the dispatch
  438. # ---------------------------------------------------------------------------
  439. @pytest.mark.asyncio
  440. async def test_preheat_aborts_when_item_cancelled_during_soak(scheduler, item, archive):
  441. """Cancelling mid-soak stops the wait instead of holding the full duration.
  442. Cancelling only writes `status` to the database — it cannot interrupt a
  443. coroutine parked in `asyncio.sleep`. Before this, the stage slept out the
  444. remaining soak (up to 30 min) with the heaters on, and kept the printer in
  445. `busy_printers` the whole time, blocking every other queued item.
  446. """
  447. db = AsyncMock()
  448. client = _make_client()
  449. scheduler._inflight[item.id] = (MagicMock(), PRINTER_ID)
  450. scheduler.notify_dispatch_cancelled(item.id)
  451. slept: list[float] = []
  452. async def _fake_sleep(secs):
  453. slept.append(secs)
  454. with (
  455. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  456. patch.object(
  457. scheduler,
  458. "_get_int_setting",
  459. _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
  460. ),
  461. # The queue route has flagged this dispatch as cancelled.
  462. patch("backend.app.services.print_scheduler.time") as t,
  463. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  464. patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep),
  465. ):
  466. t.monotonic.return_value = NOW
  467. pm.get_client.return_value = client
  468. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  469. proceed = await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  470. assert proceed is False
  471. # Bailed after the first slice rather than sleeping the whole soak.
  472. assert sum(slept) <= 10.0, f"slept {sum(slept)}s — should abort on the first check"
  473. @pytest.mark.asyncio
  474. async def test_preheat_completes_when_item_stays_live(scheduler, item, archive):
  475. """The happy path still returns True so the dispatch proceeds to upload."""
  476. db = AsyncMock()
  477. client = _make_client()
  478. with (
  479. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  480. patch.object(
  481. scheduler,
  482. "_get_int_setting",
  483. _ints(preheat_soak_seconds=20, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
  484. ),
  485. patch("backend.app.services.print_scheduler.time") as t,
  486. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  487. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  488. ):
  489. t.monotonic.return_value = NOW
  490. pm.get_client.return_value = client
  491. pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
  492. proceed = await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  493. assert proceed is True
  494. @pytest.mark.asyncio
  495. async def test_preheat_skip_paths_still_return_true(scheduler, archive):
  496. """`preheat_override='off'` skips the stage but must NOT abandon the dispatch."""
  497. db = AsyncMock()
  498. off_item = SimpleNamespace(id=44, preheat_override="off", preheat_chamber_target_override=60)
  499. with patch("backend.app.services.print_scheduler.printer_manager"):
  500. proceed = await scheduler._preheat_and_soak(db, off_item, _make_printer("X1C"), archive)
  501. assert proceed is True
  502. @pytest.mark.asyncio
  503. async def test_preheat_sleep_slices_and_stops_on_cancel(scheduler):
  504. """`_preheat_sleep` chops a long wait up and bails at the first check after the flag lands.
  505. The slicing is the whole point: a single `asyncio.sleep(1800)` cannot
  506. observe a cancellation that arrives while it is parked.
  507. """
  508. slept: list[float] = []
  509. # The dispatch is in flight, which is the only state a cancellation is
  510. # recorded for.
  511. scheduler._inflight[1] = (MagicMock(), PRINTER_ID)
  512. async def _fake_sleep(secs):
  513. slept.append(secs)
  514. # Cancellation lands part-way through, as it would from the API.
  515. if len(slept) == 3:
  516. scheduler.notify_dispatch_cancelled(1)
  517. with patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep):
  518. ok = await scheduler._preheat_sleep(item_id=1, seconds=1800)
  519. assert ok is False
  520. assert len(slept) == 3, "should stop at the check following the cancellation"
  521. assert max(slept) <= 10.0, "each slice is bounded by the cancel-check interval"
  522. def test_notify_dispatch_cancelled_is_scoped_to_the_item(scheduler):
  523. """The flag names one item; an unrelated dispatch must not see it."""
  524. scheduler._inflight[42] = (MagicMock(), PRINTER_ID)
  525. scheduler.notify_dispatch_cancelled(42)
  526. assert 42 in scheduler._cancelled_dispatches
  527. assert 43 not in scheduler._cancelled_dispatches
  528. def test_notify_dispatch_cancelled_ignores_items_not_in_flight(scheduler):
  529. """Cancelling a merely-pending item records nothing.
  530. Every cancel and delete calls this, but only a dispatch that is already
  531. running can be interrupted by it. Recording the rest would grow the set
  532. once per cancelled item for the life of the process, and buys nothing:
  533. `_claim_for_dispatch` only claims rows that are still `pending`, and the
  534. caller has committed a terminal status (or deleted the row) first.
  535. """
  536. scheduler.notify_dispatch_cancelled(99)
  537. assert scheduler._cancelled_dispatches == set()
  538. @pytest.mark.asyncio
  539. async def test_preheat_sleep_runs_to_completion_when_not_cancelled(scheduler):
  540. """No flag set → the full duration is slept and True is returned."""
  541. slept: list[float] = []
  542. async def _fake_sleep(secs):
  543. slept.append(secs)
  544. with patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep):
  545. ok = await scheduler._preheat_sleep(item_id=7, seconds=25)
  546. assert ok is True
  547. assert sum(slept) == pytest.approx(25.0)