test_scheduler_preheat.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  1. """Tests for the preheat & heat-soak scheduler stage (#1468).
  2. Three layered concerns the stage has to get right:
  3. 1. **Override resolution** (per-item beats global beats default).
  4. 2. **Chamber-target derivation** (item-override > filament-map max > 0).
  5. 3. **Hardware-tier branching** (chamber heater vs sensor-only vs no sensor).
  6. The fixtures construct a queue item with `preheat_override` + the override
  7. target both unset; tests flip those per case. `asyncio.sleep` is patched to
  8. AsyncMock so the soak phase doesn't actually wait — assertions are on what
  9. got scheduled, not wall-clock.
  10. """
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from backend.app.services.print_scheduler import PrintScheduler
  15. @pytest.fixture
  16. def scheduler():
  17. return PrintScheduler()
  18. @pytest.fixture
  19. def item():
  20. return SimpleNamespace(
  21. id=42,
  22. preheat_override="inherit",
  23. preheat_chamber_target_override=None,
  24. )
  25. @pytest.fixture
  26. def archive():
  27. return SimpleNamespace(bed_temperature=60)
  28. def _make_printer(model: str, printer_id: int = 7):
  29. return SimpleNamespace(id=printer_id, model=model)
  30. def _make_client():
  31. client = MagicMock()
  32. client.set_bed_temperature = MagicMock(return_value=True)
  33. client.set_chamber_temperature = MagicMock(return_value=True)
  34. client.set_airduct_mode = MagicMock(return_value=True)
  35. return client
  36. def _make_state(
  37. bed_temp: float = 0.0,
  38. chamber_temp: float = 0.0,
  39. trays: list[str] | None = None,
  40. airduct_mode: int = 0,
  41. ):
  42. """Build a PrinterState-shaped namespace with optional AMS tray types.
  43. `trays` is a list of tray_type strings (each becomes one loaded slot in
  44. AMS unit 0). Empty / None gives an empty AMS — the derivation falls
  45. through to 0. `airduct_mode` is 0 (cooling, default) or 1 (heating);
  46. matches the field on PrinterState that the preheat stage reads to
  47. decide whether to fire a redundant `set_airduct_mode` call."""
  48. raw_data: dict = {}
  49. if trays is not None:
  50. raw_data["ams"] = [{"tray": [{"tray_type": t} for t in trays]}]
  51. return SimpleNamespace(
  52. temperatures={"bed": bed_temp, "chamber": chamber_temp},
  53. raw_data=raw_data,
  54. airduct_mode=airduct_mode,
  55. )
  56. def _ints(**values):
  57. """Mock side_effect for _get_int_setting that returns the kwarg value
  58. when the key matches, else the helper's `default` argument."""
  59. return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
  60. # ----------------------------------------------------------------------------
  61. # Override resolution
  62. # ----------------------------------------------------------------------------
  63. @pytest.mark.asyncio
  64. async def test_global_disabled_inherit_skips(scheduler, item, archive):
  65. """preheat_enabled=False + item.preheat_override='inherit' → no heater dispatch."""
  66. db = AsyncMock()
  67. client = _make_client()
  68. with (
  69. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
  70. patch.object(scheduler, "_get_int_setting", _ints()),
  71. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  72. ):
  73. pm.get_client.return_value = client
  74. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  75. client.set_bed_temperature.assert_not_called()
  76. client.set_chamber_temperature.assert_not_called()
  77. @pytest.mark.asyncio
  78. async def test_item_override_off_bypasses_global_on(scheduler, item, archive):
  79. """preheat_enabled=True + item.preheat_override='off' → preheat suppressed."""
  80. db = AsyncMock()
  81. client = _make_client()
  82. item.preheat_override = "off"
  83. with (
  84. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  85. patch.object(scheduler, "_get_int_setting", _ints()),
  86. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  87. ):
  88. pm.get_client.return_value = client
  89. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  90. client.set_bed_temperature.assert_not_called()
  91. client.set_chamber_temperature.assert_not_called()
  92. @pytest.mark.asyncio
  93. async def test_item_override_on_runs_despite_global_off(scheduler, item, archive):
  94. """preheat_enabled=False + item.preheat_override='on' → preheat runs (bed
  95. fires, chamber depends on the resolved target)."""
  96. db = AsyncMock()
  97. client = _make_client()
  98. item.preheat_override = "on"
  99. item.preheat_chamber_target_override = 0 # explicit no-chamber so the assertion is sharp
  100. with (
  101. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
  102. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  103. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  104. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  105. ):
  106. pm.get_client.return_value = client
  107. pm.get_status.return_value = _make_state(60.0, 0.0)
  108. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  109. client.set_bed_temperature.assert_called_once_with(60)
  110. client.set_chamber_temperature.assert_not_called()
  111. # ----------------------------------------------------------------------------
  112. # Chamber-target derivation
  113. # ----------------------------------------------------------------------------
  114. @pytest.mark.asyncio
  115. async def test_chamber_target_override_beats_filament_map(scheduler, item, archive):
  116. """item.preheat_chamber_target_override is the highest-priority source —
  117. a PLA-only print with an explicit 50°C override still heats the chamber."""
  118. db = AsyncMock()
  119. client = _make_client()
  120. item.preheat_chamber_target_override = 50
  121. with (
  122. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  123. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  124. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  125. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  126. ):
  127. pm.get_client.return_value = client
  128. # Only PLA loaded — filament map would derive 0; override forces 50.
  129. pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic"])
  130. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  131. client.set_bed_temperature.assert_called_once_with(60)
  132. client.set_chamber_temperature.assert_called_once_with(50)
  133. @pytest.mark.asyncio
  134. async def test_filament_map_picks_max_across_loaded_slots(scheduler, item, archive):
  135. """Mixed PA + PLA load: PA=50 + PLA=0 → chamber target 50 (the max).
  136. The "lowest common denominator" model is wrong here; PA's requirement
  137. is the binding constraint."""
  138. db = AsyncMock()
  139. client = _make_client()
  140. with (
  141. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  142. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  143. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  144. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  145. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  146. ):
  147. pm.get_client.return_value = client
  148. # `PA Basic` (note the space) normalises to `PA` which the bundled
  149. # map keys against; a hyphenated `PA-Generic` would normalise to
  150. # `PA-GENERIC` and fall through to `default` (0) — that's a separate
  151. # behaviour the user editor handles by adding a custom key.
  152. pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic", "PA Basic"])
  153. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  154. client.set_chamber_temperature.assert_called_once_with(50) # PA's recommendation, not PLA's
  155. @pytest.mark.asyncio
  156. async def test_pla_only_derives_zero_chamber_skips_the_whole_stage(scheduler, item, archive):
  157. """PLA-only print: the filament map returns 0, and the stage skips entirely.
  158. Not just the chamber phase (#3041). A derived 0 says the materials this
  159. print loads want no chamber conditioning, so there is nothing to soak for
  160. -- and the bed phase that used to run anyway put the bed warm-up plus the
  161. full soak ahead of the FTP upload, delaying every PLA dispatch by minutes
  162. for no gain. The print's own G-code sets the bed when it starts.
  163. The soak is left at its production default here on purpose: the old
  164. behaviour held for those 300s, and a test that zeroes the soak cannot see
  165. the difference.
  166. """
  167. db = AsyncMock()
  168. client = _make_client()
  169. sleeper = AsyncMock()
  170. with (
  171. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  172. patch.object(scheduler, "_get_int_setting", _ints()),
  173. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  174. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  175. patch("backend.app.services.print_scheduler.asyncio.sleep", sleeper),
  176. ):
  177. pm.get_client.return_value = client
  178. pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA", "PLA"])
  179. assert await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive) is True
  180. client.set_bed_temperature.assert_not_called()
  181. client.set_chamber_temperature.assert_not_called()
  182. sleeper.assert_not_awaited()
  183. @pytest.mark.asyncio
  184. async def test_a_zero_target_still_puts_the_flap_back_to_cooling(scheduler, item, archive):
  185. """Skipping the stage must not skip the flap.
  186. The airduct decision is the one thing a no-chamber print still needs: an
  187. H2D left in heating mode by the ABS job before it would cook the PLA that
  188. follows. It costs one MQTT command and no waiting, so it survives the
  189. early return that everything else takes.
  190. """
  191. db = AsyncMock()
  192. client = _make_client()
  193. with (
  194. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  195. patch.object(scheduler, "_get_int_setting", _ints()),
  196. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  197. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  198. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  199. ):
  200. pm.get_client.return_value = client
  201. pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA"], airduct_mode=1)
  202. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  203. client.set_airduct_mode.assert_called_once_with("cooling")
  204. client.set_bed_temperature.assert_not_called()
  205. @pytest.mark.asyncio
  206. async def test_an_explicit_item_zero_still_heats_the_bed_and_soaks(scheduler, item, archive):
  207. """A 0 typed into the per-item chamber override is not the same as a 0
  208. derived from the filament map.
  209. The map's 0 is a default nobody chose; the field's 0 is the user saying
  210. "warm the bed for this print, skip the chamber", which is what the queue
  211. documentation promises it does. Only the automatic path short-circuits.
  212. """
  213. db = AsyncMock()
  214. client = _make_client()
  215. item.preheat_chamber_target_override = 0
  216. with (
  217. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  218. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  219. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  220. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  221. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  222. ):
  223. pm.get_client.return_value = client
  224. pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA"])
  225. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  226. client.set_bed_temperature.assert_called_once_with(60)
  227. client.set_chamber_temperature.assert_not_called()
  228. @pytest.mark.asyncio
  229. async def test_forcing_the_item_override_on_still_heats_the_bed(scheduler, item, archive):
  230. """`preheat_override='on'` for a PLA print is also an explicit act.
  231. The user reached past the global toggle for this one print; the only thing
  232. left to give them on a print with no chamber requirement is the warm bed,
  233. so the stage runs rather than silently doing nothing.
  234. """
  235. db = AsyncMock()
  236. client = _make_client()
  237. item.preheat_override = "on"
  238. with (
  239. # Global off -- 'on' is carrying the whole decision.
  240. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
  241. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  242. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  243. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  244. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  245. ):
  246. pm.get_client.return_value = client
  247. pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA"])
  248. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  249. client.set_bed_temperature.assert_called_once_with(60)
  250. client.set_chamber_temperature.assert_not_called()
  251. @pytest.mark.asyncio
  252. async def test_unknown_filament_type_falls_to_default(scheduler, item, archive):
  253. """A loaded tray with a type not in the map uses the `default` entry —
  254. keeps users with custom filament names safe.
  255. Asserted against a tuned map rather than the bundled one: `default` ships
  256. at 0, and since #3041 a derived 0 skips the stage before any command goes
  257. out, so the bundled map cannot tell "fell through to default" apart from
  258. "found nothing at all". Raising `default` makes the fallback visible.
  259. """
  260. db = AsyncMock()
  261. client = _make_client()
  262. with (
  263. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  264. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  265. patch.object(scheduler, "_get_setting", AsyncMock(return_value='{"PLA": 0, "default": 35}')),
  266. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  267. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  268. ):
  269. pm.get_client.return_value = client
  270. pm.get_status.return_value = _make_state(60.0, 36.0, trays=["MyCustomFilament"])
  271. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  272. client.set_bed_temperature.assert_called_once_with(60)
  273. client.set_chamber_temperature.assert_called_once_with(35)
  274. @pytest.mark.asyncio
  275. async def test_custom_filament_targets_json_parses(scheduler, item, archive):
  276. """User-customised filament-target JSON overrides the bundled defaults —
  277. raising PLA to 30°C makes a PLA-only print actually heat the chamber."""
  278. db = AsyncMock()
  279. client = _make_client()
  280. custom_map = '{"PLA": 30, "default": 0}'
  281. with (
  282. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  283. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  284. patch.object(scheduler, "_get_setting", AsyncMock(return_value=custom_map)),
  285. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  286. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  287. ):
  288. pm.get_client.return_value = client
  289. pm.get_status.return_value = _make_state(60.0, 31.0, trays=["PLA Basic"])
  290. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  291. client.set_chamber_temperature.assert_called_once_with(30)
  292. @pytest.mark.asyncio
  293. async def test_malformed_filament_targets_falls_back_to_defaults(scheduler, item, archive):
  294. """A corrupted JSON in the setting must not break the scheduler — log
  295. and use bundled defaults."""
  296. db = AsyncMock()
  297. client = _make_client()
  298. with (
  299. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  300. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  301. patch.object(scheduler, "_get_setting", AsyncMock(return_value="not-json{{{")),
  302. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  303. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  304. ):
  305. pm.get_client.return_value = client
  306. # Bundled default for ABS = 45 → chamber should fire.
  307. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
  308. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  309. client.set_chamber_temperature.assert_called_once_with(45)
  310. # ----------------------------------------------------------------------------
  311. # Hardware-tier branching (unchanged from the first cut but updated for new
  312. # fixtures that include AMS data so the derivation lands at a non-zero target).
  313. # ----------------------------------------------------------------------------
  314. @pytest.mark.asyncio
  315. async def test_no_bed_temperature_but_chamber_needed_heats_bed_to_configured_temp(scheduler, item):
  316. """Archive without bed_temperature still preheats when the chamber needs heat.
  317. This branch used to return early, on the reasoning that guessing a bed
  318. temperature could wreck a print. Two things make the fallback safe, and
  319. skipping actively harmful:
  320. * Preheat's bed target is transient. The print's own gcode issues its
  321. M140/M190 the moment it starts, so preheat can never set the temperature
  322. the print actually runs at — it only decides how warm things are while
  323. the file uploads.
  324. * The fallback is gated on a non-zero chamber target, so it only applies to
  325. materials the filament map says want a hot chamber (ABS/ASA/PC …). The
  326. original concern — inventing a bed temperature for a PLA print — is still
  327. guarded, and covered by the sibling test below.
  328. Skipping meant a chamber-heated print whose slicer metadata carries no bed
  329. temperature started with a cold chamber, which is what preheat exists to
  330. prevent.
  331. """
  332. db = AsyncMock()
  333. client = _make_client()
  334. bare_archive = SimpleNamespace(bed_temperature=None)
  335. with (
  336. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  337. patch.object(
  338. scheduler,
  339. "_get_int_setting",
  340. _ints(queue_keep_warm_bed_temp=90, preheat_soak_seconds=0, preheat_max_wait_seconds=0),
  341. ),
  342. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  343. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  344. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  345. ):
  346. pm.get_client.return_value = client
  347. # Bed/chamber already at temperature so the convergence loop exits on its
  348. # first pass — its deadline is wall-clock, so a mocked `asyncio.sleep`
  349. # would otherwise spin for the full max_wait in real time.
  350. pm.get_status.return_value = _make_state(90.0, 46.0, trays=["ABS"])
  351. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
  352. client.set_bed_temperature.assert_called_once_with(90)
  353. @pytest.mark.asyncio
  354. async def test_no_bed_temperature_and_no_chamber_target_still_skips(scheduler, item):
  355. """PLA (chamber target 0) with no bed metadata → skip, as before.
  356. Preserves the original guard: with nothing to preheat *for*, no bed
  357. temperature is invented for the print.
  358. """
  359. db = AsyncMock()
  360. client = _make_client()
  361. bare_archive = SimpleNamespace(bed_temperature=None)
  362. with (
  363. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  364. patch.object(scheduler, "_get_int_setting", _ints(queue_keep_warm_bed_temp=90)),
  365. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  366. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  367. ):
  368. pm.get_client.return_value = client
  369. pm.get_status.return_value = _make_state(trays=["PLA"])
  370. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
  371. client.set_bed_temperature.assert_not_called()
  372. client.set_chamber_temperature.assert_not_called()
  373. @pytest.mark.asyncio
  374. async def test_x1c_skips_m141_but_waits_passively(scheduler, item, archive):
  375. """X1C has a chamber sensor but no active heater — M141 must NOT fire even
  376. when the filament map derives a non-zero target."""
  377. db = AsyncMock()
  378. client = _make_client()
  379. with (
  380. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  381. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  382. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  383. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  384. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  385. ):
  386. pm.get_client.return_value = client
  387. # ABS loaded → derived target 45; sensor reads 46 (already there).
  388. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
  389. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  390. client.set_bed_temperature.assert_called_once_with(60)
  391. client.set_chamber_temperature.assert_not_called()
  392. @pytest.mark.asyncio
  393. async def test_p1s_no_chamber_sensor_uses_soak_timer_only(scheduler, item, archive):
  394. """P1S has no chamber sensor — derived target is ignored for the wait
  395. loop, only the soak timer applies."""
  396. db = AsyncMock()
  397. client = _make_client()
  398. with (
  399. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  400. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=600)),
  401. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  402. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  403. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()) as sleep_mock,
  404. ):
  405. pm.get_client.return_value = client
  406. pm.get_status.return_value = _make_state(60.0, 0.0, trays=["ABS"])
  407. await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
  408. client.set_bed_temperature.assert_called_once_with(60)
  409. client.set_chamber_temperature.assert_not_called()
  410. # The soak is slept in slices so a cancellation landing mid-hold is noticed
  411. # (a single 600s sleep could not see one), so assert the total rather than a
  412. # single call of the full duration.
  413. assert sum(call.args[0] for call in sleep_mock.call_args_list) == 600
  414. @pytest.mark.asyncio
  415. async def test_lost_client_skips_silently(scheduler, item, archive):
  416. """If the MQTT client drops, the helper returns without raising."""
  417. db = AsyncMock()
  418. with (
  419. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  420. patch.object(scheduler, "_get_int_setting", _ints()),
  421. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  422. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  423. ):
  424. pm.get_client.return_value = None
  425. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  426. # No exception escaping — the disable path is silent.
  427. @pytest.mark.asyncio
  428. async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive):
  429. """H-series + X2D have a cooling/heating airduct flap that DEFAULTS to
  430. cooling. If we energise M141 without first flipping to heating, the
  431. chamber fan actively extracts the heat we're trying to put in and the
  432. chamber never converges. Verify airduct=heating fires AND lands before
  433. the chamber-target call so the heater starts in the right airflow regime."""
  434. db = AsyncMock()
  435. client = _make_client()
  436. call_order = []
  437. client.set_airduct_mode.side_effect = lambda mode: call_order.append(("airduct", mode)) or True
  438. client.set_chamber_temperature.side_effect = lambda t: call_order.append(("chamber", t)) or True
  439. with (
  440. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  441. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  442. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  443. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  444. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  445. ):
  446. pm.get_client.return_value = client
  447. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
  448. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  449. client.set_airduct_mode.assert_called_once_with("heating")
  450. client.set_chamber_temperature.assert_called_once_with(45)
  451. # Airduct heating must precede M141 — the heater enabling first while the
  452. # flap is still in cooling mode wastes minutes of fan-vs-heater tug-of-war.
  453. assert call_order == [("airduct", "heating"), ("chamber", 45)]
  454. @pytest.mark.asyncio
  455. async def test_x1c_skips_airduct_no_heater_no_call(scheduler, item, archive):
  456. """X1C has neither an active chamber heater nor an airduct flap (the
  457. frontend's airduct whitelist is P2S/X2D/H2D/H2C/H2S/H2D Pro — no X1
  458. series). The preheat stage's airduct call is gated on supports_airduct
  459. AND has_heater, so X1C gets neither call regardless. Important: a
  460. spurious set_airduct on X1C wouldn't just be wasted MQTT — there's no
  461. flap to set, so the firmware response would be undefined behaviour."""
  462. db = AsyncMock()
  463. client = _make_client()
  464. with (
  465. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  466. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  467. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  468. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  469. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  470. ):
  471. pm.get_client.return_value = client
  472. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
  473. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  474. client.set_chamber_temperature.assert_not_called()
  475. client.set_airduct_mode.assert_not_called()
  476. @pytest.mark.asyncio
  477. async def test_normalize_filament_type_strips_at_space():
  478. """`PLA Basic` and `ABS Premium` should normalise to `PLA` and `ABS` so
  479. they match the map keys. `PA-CF` has no space and stays verbatim."""
  480. s = PrintScheduler
  481. assert s._normalize_filament_type("PLA Basic") == "PLA"
  482. assert s._normalize_filament_type("ABS Premium") == "ABS"
  483. assert s._normalize_filament_type("PA-CF") == "PA-CF"
  484. assert s._normalize_filament_type("") == ""
  485. assert s._normalize_filament_type("petg") == "PETG" # case-folded
  486. @pytest.mark.asyncio
  487. @pytest.mark.parametrize("stored", [None, "", "not json at all", "[1, 2, 3]"])
  488. async def test_get_preheat_filament_targets_defaults_when_missing(scheduler, stored):
  489. """Empty / null / malformed setting → bundled defaults are used.
  490. Every path out of this function must honour the one contract its docstring
  491. states: keys upper-cased, and DEFAULT present so the resolution loop can
  492. index it unconditionally. The fallback paths used to hand the bundled
  493. constant back as declared, with its lowercase `default`, so an install that
  494. had never opened the setting returned a dict the loop could not read its
  495. fallback out of. It happened to produce the right number only because that
  496. default is 0 -- this test is what stops the constant changing and taking
  497. every unconfigured install's chamber preheat down with it.
  498. """
  499. db = AsyncMock()
  500. with patch.object(scheduler, "_get_setting", AsyncMock(return_value=stored)):
  501. targets = await scheduler._get_preheat_filament_targets(db)
  502. assert targets["PLA"] == 0
  503. assert targets["ABS"] == 45
  504. assert targets["PA-CF"] == 55
  505. assert "DEFAULT" in targets, "the resolution loop looks the fallback up by this exact key"
  506. assert targets["DEFAULT"] == PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS["default"]
  507. assert all(key == key.upper() for key in targets), targets
  508. @pytest.mark.asyncio
  509. async def test_a_configured_map_reaches_the_loop_under_the_same_contract(scheduler):
  510. """The editor writes the keys it displays, lowercase `default` included, so
  511. the parsed path has always upper-cased. Both paths agree now."""
  512. db = AsyncMock()
  513. stored = '{"PLA": 0, "abs": 60, "default": 15}'
  514. with patch.object(scheduler, "_get_setting", AsyncMock(return_value=stored)):
  515. targets = await scheduler._get_preheat_filament_targets(db)
  516. assert targets["ABS"] == 60
  517. assert targets["DEFAULT"] == 15
  518. assert all(key == key.upper() for key in targets), targets
  519. # ----------------------------------------------------------------------------
  520. # Airduct mode switch (#1468 follow-up)
  521. # ----------------------------------------------------------------------------
  522. @pytest.mark.asyncio
  523. async def test_h2d_chamber_heat_switches_airduct_to_heating(scheduler, item, archive):
  524. """H2D in cooling mode (the default; what you get after a PLA print)
  525. with chamber_target > 0 must switch the airduct to heating BEFORE the
  526. M141 dispatch — otherwise the open exhaust flap actively fights the
  527. chamber heater and the chamber never converges."""
  528. db = AsyncMock()
  529. client = _make_client()
  530. with (
  531. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  532. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  533. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  534. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  535. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  536. ):
  537. pm.get_client.return_value = client
  538. # Currently in cooling (mode 0). ABS loaded → derived target 45.
  539. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
  540. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  541. client.set_airduct_mode.assert_called_once_with("heating")
  542. client.set_chamber_temperature.assert_called_once_with(45)
  543. @pytest.mark.asyncio
  544. async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive):
  545. """H2D running a PLA print (chamber_target derives 0) on an airduct
  546. previously left in heating mode (from a prior ABS run) must switch
  547. back to cooling. Otherwise PLA prints inherit ABS's closed-flap recirc
  548. and run hot."""
  549. db = AsyncMock()
  550. client = _make_client()
  551. with (
  552. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  553. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  554. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  555. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  556. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  557. ):
  558. pm.get_client.return_value = client
  559. # Currently in heating (mode 1). PLA loaded → derived target 0.
  560. pm.get_status.return_value = _make_state(60.0, 30.0, trays=["PLA"], airduct_mode=1)
  561. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  562. client.set_airduct_mode.assert_called_once_with("cooling")
  563. client.set_chamber_temperature.assert_not_called()
  564. @pytest.mark.asyncio
  565. async def test_h2d_airduct_already_correct_idempotent(scheduler, item, archive):
  566. """If the airduct is already in the desired mode, don't re-send
  567. `set_airduct` — the firmware accepts it but it generates needless MQTT
  568. chatter and could thrash the flap motor on rapid repeats."""
  569. db = AsyncMock()
  570. client = _make_client()
  571. with (
  572. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  573. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  574. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  575. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  576. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  577. ):
  578. pm.get_client.return_value = client
  579. # Already in heating (mode 1) and ABS → derived 45 wants heating.
  580. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=1)
  581. await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
  582. client.set_airduct_mode.assert_not_called()
  583. # But M141 still fires — the airduct decision is independent.
  584. client.set_chamber_temperature.assert_called_once_with(45)
  585. @pytest.mark.asyncio
  586. async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, archive):
  587. """X1C has a chamber sensor but no airduct flap — the firmware ignores
  588. `set_airduct`. We gate on `supports_airduct(model)` to avoid sending the
  589. no-op. Regression guard: wiring this to `supports_chamber_temp` or
  590. `supports_chamber_heater` alone would have leaked the command to
  591. X1C/X1E or P2S inappropriately."""
  592. db = AsyncMock()
  593. client = _make_client()
  594. with (
  595. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  596. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  597. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  598. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  599. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  600. ):
  601. pm.get_client.return_value = client
  602. pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
  603. await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
  604. client.set_airduct_mode.assert_not_called()
  605. @pytest.mark.asyncio
  606. async def test_a_filled_variant_preheats_like_its_base_material(monkeypatch):
  607. """#2902 widened the type an AMS slot carries, so a slot that used to say
  608. "ASA" can now say "ASA-GF". The chamber map has no ASA-GF row, and an
  609. unknown type preheats to nothing -- so an ASA-GF print would have gone out
  610. with a cold chamber. The specific type is still tried first, so the rows
  611. that do exist for a variant (PETG-CF, PA-CF) are not traded down."""
  612. from backend.app.services.print_scheduler import PrintScheduler
  613. s = PrintScheduler()
  614. targets = PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS
  615. def target_for(tray_type: str) -> int:
  616. normalised = s._normalize_filament_type(tray_type)
  617. value = targets.get(normalised)
  618. if value is None:
  619. value = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
  620. return value
  621. assert target_for("ASA-GF") == targets["ASA"]
  622. assert target_for("ASA-AERO") == targets["ASA"]
  623. assert target_for("ABS-GF") == targets["ABS"]
  624. # Variants listed in their own right keep their own row.
  625. assert target_for("PETG-CF") == 40
  626. assert target_for("PA-CF") == 55
  627. # And a plain type is untouched.
  628. assert target_for("PLA") == 0