Files
bambuddy/backend/tests/unit/test_scheduler_preheat_fast_path.py
Thomas Scott Williams f86f5a7c34 feat(queue): keep the chamber warm between prints and skip redundant soak
Back-to-back prints in chamber-heated materials (ASA, ABS, PA, PC) each paid
a full heat-soak from cold, even when the print that just finished had left
the chamber at temperature. Two changes remove that cost.

Keep bed warm between prints
  While a printer sits in FINISH awaiting plate-clear and the next queued item
  needs chamber heat, hold the bed hot so the chamber does not cool during the
  bed-clearing window. The bed is the chamber's heating element here, not a
  print surface, so the hold runs at the new `queue_keep_warm_bed_temp`
  (default 90C, which also satisfies bed-threshold-linked aftermarket chamber
  heaters), raised to the item's own bed temperature when that is higher.

  Gated on `queue_keep_bed_warm` AND `require_plate_clear` AND
  `preheat_enabled`, all re-checked in the backend so a stale UI cannot leave
  the feature running. `queue_keep_warm_max_minutes` (default 120) bounds the
  hold: when it elapses the bed is switched off and the hold latches until the
  printer is next a candidate, so a plate nobody clears cannot leave the bed
  hot indefinitely. The hold is also released when the item is deleted, the
  queue empties, or a gate is toggled off mid-hold, and never when firmware
  reports a target other than the one it set — a temperature the user or a
  print changed is left alone. Publishing is idempotent.

Smart soak reduction from chamber history
  The scheduler samples each connected printer's chamber temperature every tick
  into a 2h rolling history. Preheat credits time the chamber has already spent
  at temperature against the configured soak, shortening or skipping it.

  Credit starts no earlier than the newest sample, the most recent unbroken run
  of samples, or the end of the last real dip below target. A dip only counts
  once it outlasts a grace period: an enclosed chamber cannot lose and regain
  several degrees quickly (measured on an X1C, cooling from 55C to below 48C
  takes 23-73 minutes, ~0.2 C/min), so a brief low reading is a door opening or
  sensor noise rather than lost soak — and a plate swap, which is exactly when
  keep-warm runs, produces one. A stale history credits nothing: at that
  cooling rate the chamber can cross the threshold unobserved, so the full soak
  runs instead.

Three supporting changes to preheat itself:

  * Cancelling or deleting a queued item now stops a preheat already running
    for it. Those routes only write `status` to the database, which a dispatch
    coroutine parked in `asyncio.sleep` cannot observe, so the heaters ran for
    the rest of max_wait + soak — 45 minutes at the default settings — and the
    printer stayed in `busy_printers`, blocking every other queued item behind
    a print that was not happening. The routes now signal the scheduler
    directly, and the stage sleeps in slices so it notices promptly and
    abandons the dispatch, letting the existing rollback shut the heaters off.

  * A chamber-heated print whose slicer metadata carries no bed temperature
    (common for Orca-exported 3MFs) used to skip preheat entirely and start
    with a cold chamber. It now heats the bed to `queue_keep_warm_bed_temp`.
    A parsed bed temperature still wins, and a print with no chamber
    requirement still skips — no bed temperature is invented for the print
    itself. Preheat's bed target is transient regardless: the print's own
    gcode issues its M140/M190 at start.

  * Preheat records which commands it sent (bed, chamber, airduct) and unwinds
    them if the dispatch aborts before the print starts — a failed upload, a
    cancelled item, an exception — instead of leaving the printer heating for
    a job that is not happening.
2026-08-09 17:07:34 -04:00

694 lines
29 KiB
Python

"""Tests for the `_preheat_and_soak` fast-path short-circuit.
When the chamber has already been at temperature for the full soak duration
AND the bed is currently at target, the preheat stage skips the convergence
wait and soak entirely. Before the fix it returned WITHOUT sending M140,
airduct, or M141 — the bed cooled while the 3MF uploaded. The regression
guard here is: fast path fires ⇒ all applicable heater/flap commands are
sent, and the slow path (convergence wait + soak) is skipped.
Distinguishing the paths is done via `db.commit` — the slow path commits
before the convergence loop (releases the pooled connection during the
sleep-heavy wait) so `db.commit.await_count == 0` is a reliable signal
that the fast path returned early.
"""
from collections import deque
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import (
_AIRDUCT_MODE_COOLING,
_AIRDUCT_MODE_HEATING,
PrintScheduler,
)
NOW = 10_000.0
PRINTER_ID = 7
@pytest.fixture
def scheduler():
return PrintScheduler()
@pytest.fixture
def item():
return SimpleNamespace(
id=42,
preheat_override="inherit",
preheat_chamber_target_override=60, # forces chamber_target=60, do_chamber=True
)
@pytest.fixture
def archive():
return SimpleNamespace(bed_temperature=60)
def _make_printer(model: str, printer_id: int = PRINTER_ID):
return SimpleNamespace(id=printer_id, model=model)
def _make_client():
client = MagicMock()
client.set_bed_temperature = MagicMock(return_value=True)
client.set_chamber_temperature = MagicMock(return_value=True)
client.set_airduct_mode = MagicMock(return_value=True)
return client
def _make_state(*, bed_temp=0.0, chamber_temp=0.0, airduct_mode=_AIRDUCT_MODE_COOLING):
return SimpleNamespace(
temperatures={"bed": bed_temp, "chamber": chamber_temp},
raw_data={},
airduct_mode=airduct_mode,
)
def _ints(**values):
return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
def _preload_dense_history(scheduler, *, printer_id=PRINTER_ID, chamber_temp=62.0, duration=1800, interval=30):
"""Pre-fill scheduler._chamber_history so _chamber_soak_remaining returns 0.
Uses dense samples (30s apart) covering the full soak window so the
contiguity guard sees an unbroken run.
"""
d: deque = deque()
ts = NOW - duration
while ts <= NOW:
d.append((ts, float(chamber_temp)))
ts += interval
scheduler._chamber_history[printer_id] = d
# ---------------------------------------------------------------------------
# Fast path fires — sends all applicable targets
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fast_path_fires_sends_bed_airduct_chamber_on_h2d(scheduler, item, archive):
"""H2D (heater + airduct + sensor) hits the fast path with all three commands."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_bed_temperature.assert_called_once_with(60)
client.set_chamber_temperature.assert_called_once_with(60)
client.set_airduct_mode.assert_called_once_with("heating")
# Slow path commits `db` before the convergence wait; fast path returns first.
assert db.commit.await_count == 0
@pytest.mark.asyncio
async def test_fast_path_fires_sends_bed_only_on_x1c(scheduler, item, archive):
"""X1C has a chamber sensor but no heater and no airduct — only M140 fires."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
client.set_bed_temperature.assert_called_once_with(60)
client.set_chamber_temperature.assert_not_called()
client.set_airduct_mode.assert_not_called()
assert db.commit.await_count == 0
@pytest.mark.asyncio
async def test_fast_path_skips_airduct_when_already_in_heating(scheduler, item, archive):
"""Airduct already reported as heating → do NOT publish set_airduct_mode."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0, airduct_mode=_AIRDUCT_MODE_HEATING)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_bed_temperature.assert_called_once_with(60)
client.set_chamber_temperature.assert_called_once_with(60)
client.set_airduct_mode.assert_not_called() # idempotence guard
# ---------------------------------------------------------------------------
# Fast path DOES NOT fire — falls through to slow path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fast_path_skipped_when_no_history(scheduler, item, archive):
"""Empty chamber history → _chamber_soak_remaining returns full soak → slow path."""
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
# Slow path commits `db` before the convergence wait.
assert db.commit.await_count >= 1
@pytest.mark.asyncio
async def test_fast_path_skipped_when_bed_too_cold(scheduler, item, archive):
"""Bed below target - 2 → cannot skip preheat, falls through to slow path."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
# Bed at 30°C, way below 60°C target — fast path condition fails.
pm.get_status.return_value = _make_state(bed_temp=30.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
assert db.commit.await_count >= 1
@pytest.mark.asyncio
async def test_fast_path_skipped_when_chamber_currently_below_target(scheduler, item, archive):
"""Chamber history shows history but current chamber reading is cold → slow path."""
_preload_dense_history(scheduler) # history says "hot"
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
# Bed at target, but current chamber reading is 40°C (below 58 = 60-2).
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=40.0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
assert db.commit.await_count >= 1
@pytest.mark.asyncio
async def test_fast_path_skipped_when_no_sensor_model(scheduler, item, archive):
"""P1S has no chamber sensor → has_sensor=False → fast path condition fails."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
assert db.commit.await_count >= 1
@pytest.mark.asyncio
async def test_fast_path_skipped_when_soak_seconds_zero(scheduler, item, archive):
"""soak_seconds=0 disables the fast path (nothing to skip) — slow path runs."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
assert db.commit.await_count >= 1
# ---------------------------------------------------------------------------
# Preheat rollback pin: fast path populates it correctly for `_dispatch_one`
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fast_path_registers_all_actions_in_pin_on_h2d(scheduler, item, archive):
"""H2D fast path fires bed + airduct + chamber → pin has all three keys."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed", "airduct", "chamber"}
@pytest.mark.asyncio
async def test_fast_path_registers_only_bed_in_pin_on_x1c(scheduler, item, archive):
"""X1C fast path fires bed only (no heater, no airduct) → pin has just 'bed'."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed"}
@pytest.mark.asyncio
async def test_fast_path_skips_airduct_pin_when_already_heating(scheduler, item, archive):
"""Airduct already in heating → not published, not added to pin (nothing to unwind)."""
_preload_dense_history(scheduler)
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0, airduct_mode=_AIRDUCT_MODE_HEATING)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed", "chamber"}
# ---------------------------------------------------------------------------
# _rollback_preheat_pin: unwinds every registered action, best-effort, no raise
# ---------------------------------------------------------------------------
def test_rollback_preheat_pin_unwinds_all_three_actions(scheduler):
"""Pin contains all three keys → three cleanup commands fire, pin dict shrinks."""
scheduler._preheat_pin[PRINTER_ID] = {"bed", "chamber", "airduct"}
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
client.set_chamber_temperature.assert_called_once_with(0)
client.set_airduct_mode.assert_called_once_with("cooling")
assert PRINTER_ID not in scheduler._preheat_pin
def test_rollback_preheat_pin_only_unwinds_registered_keys(scheduler):
"""Pin has only {bed} → only that command fires; chamber/airduct untouched."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
client.set_chamber_temperature.assert_not_called()
client.set_airduct_mode.assert_not_called()
def test_rollback_preheat_pin_noop_when_pin_absent(scheduler):
"""No pin entry for this printer → no client lookup, no commands, no crash."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_not_called()
def test_rollback_preheat_pin_noop_when_client_missing(scheduler):
"""Client is None (e.g. printer deregistered mid-dispatch) → no crash, pin still popped."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = None
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
# Pin was consumed even though there was nothing to send to.
assert PRINTER_ID not in scheduler._preheat_pin
def test_rollback_preheat_pin_swallows_setter_exceptions(scheduler):
"""A setter raising must not propagate — the interesting exception is upstream."""
scheduler._preheat_pin[PRINTER_ID] = {"bed", "chamber", "airduct"}
client = _make_client()
client.set_bed_temperature.side_effect = RuntimeError("mqtt down")
client.set_chamber_temperature.side_effect = RuntimeError("mqtt down")
client.set_airduct_mode.side_effect = RuntimeError("mqtt down")
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
# Must not raise.
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
assert PRINTER_ID not in scheduler._preheat_pin
# ---------------------------------------------------------------------------
# Missing bed_temperature metadata: heat the bed anyway when the chamber needs it
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_preheat_falls_back_to_configured_bed_temp_when_metadata_missing(scheduler, item):
"""No parsed bed temperature + chamber target > 0 → heat the bed to the configured temp.
Previously preheat returned immediately ("archive has no bed_temperature
metadata"), so the chamber phase never ran and the print started cold —
the exact outcome preheat exists to prevent. The bed is how the chamber
gets hot, so a missing bed temperature must not disable the stage.
"""
db = AsyncMock()
client = _make_client()
archive_no_bed = SimpleNamespace(bed_temperature=None)
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive_no_bed)
client.set_bed_temperature.assert_called_once_with(90)
# The stage ran rather than returning early (slow path commits before waiting).
assert db.commit.await_count >= 1
@pytest.mark.asyncio
async def test_preheat_fallback_honours_configured_temp(scheduler, item):
"""The fallback reads `queue_keep_warm_bed_temp`; it is not hard-coded."""
db = AsyncMock()
client = _make_client()
archive_no_bed = SimpleNamespace(bed_temperature=None)
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=100),
),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive_no_bed)
client.set_bed_temperature.assert_called_once_with(100)
@pytest.mark.asyncio
async def test_preheat_still_skips_when_no_bed_temp_and_no_chamber_target(scheduler):
"""No bed metadata AND no chamber requirement → nothing to preheat for; skip.
Guards the unchanged half of the branch: a PLA print with no parsed bed
temperature must not have one invented for it.
"""
db = AsyncMock()
client = _make_client()
pla_item = SimpleNamespace(id=43, preheat_override="inherit", preheat_chamber_target_override=0)
archive_no_bed = SimpleNamespace(bed_temperature=None)
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
await scheduler._preheat_and_soak(db, pla_item, _make_printer("X1C"), archive_no_bed)
client.set_bed_temperature.assert_not_called()
assert db.commit.await_count == 0
@pytest.mark.asyncio
async def test_preheat_prefers_parsed_bed_temp_over_fallback(scheduler, item, archive):
"""A parsed bed temperature is used as-is — the fallback only fills a gap."""
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
# archive fixture carries bed_temperature=60
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
client.set_bed_temperature.assert_called_once_with(60)
# ---------------------------------------------------------------------------
# Cancellation during preheat: stop heating, abandon the dispatch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_preheat_aborts_when_item_cancelled_during_soak(scheduler, item, archive):
"""Cancelling mid-soak stops the wait instead of holding the full duration.
Cancelling only writes `status` to the database — it cannot interrupt a
coroutine parked in `asyncio.sleep`. Before this, the stage slept out the
remaining soak (up to 30 min) with the heaters on, and kept the printer in
`busy_printers` the whole time, blocking every other queued item.
"""
db = AsyncMock()
client = _make_client()
scheduler._inflight[item.id] = (MagicMock(), PRINTER_ID)
scheduler.notify_dispatch_cancelled(item.id)
slept: list[float] = []
async def _fake_sleep(secs):
slept.append(secs)
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
),
# The queue route has flagged this dispatch as cancelled.
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
proceed = await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
assert proceed is False
# Bailed after the first slice rather than sleeping the whole soak.
assert sum(slept) <= 10.0, f"slept {sum(slept)}s — should abort on the first check"
@pytest.mark.asyncio
async def test_preheat_completes_when_item_stays_live(scheduler, item, archive):
"""The happy path still returns True so the dispatch proceeds to upload."""
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(preheat_soak_seconds=20, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
),
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
t.monotonic.return_value = NOW
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
proceed = await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
assert proceed is True
@pytest.mark.asyncio
async def test_preheat_skip_paths_still_return_true(scheduler, archive):
"""`preheat_override='off'` skips the stage but must NOT abandon the dispatch."""
db = AsyncMock()
off_item = SimpleNamespace(id=44, preheat_override="off", preheat_chamber_target_override=60)
with patch("backend.app.services.print_scheduler.printer_manager"):
proceed = await scheduler._preheat_and_soak(db, off_item, _make_printer("X1C"), archive)
assert proceed is True
@pytest.mark.asyncio
async def test_preheat_sleep_slices_and_stops_on_cancel(scheduler):
"""`_preheat_sleep` chops a long wait up and bails at the first check after the flag lands.
The slicing is the whole point: a single `asyncio.sleep(1800)` cannot
observe a cancellation that arrives while it is parked.
"""
slept: list[float] = []
# The dispatch is in flight, which is the only state a cancellation is
# recorded for.
scheduler._inflight[1] = (MagicMock(), PRINTER_ID)
async def _fake_sleep(secs):
slept.append(secs)
# Cancellation lands part-way through, as it would from the API.
if len(slept) == 3:
scheduler.notify_dispatch_cancelled(1)
with patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep):
ok = await scheduler._preheat_sleep(item_id=1, seconds=1800)
assert ok is False
assert len(slept) == 3, "should stop at the check following the cancellation"
assert max(slept) <= 10.0, "each slice is bounded by the cancel-check interval"
def test_notify_dispatch_cancelled_is_scoped_to_the_item(scheduler):
"""The flag names one item; an unrelated dispatch must not see it."""
scheduler._inflight[42] = (MagicMock(), PRINTER_ID)
scheduler.notify_dispatch_cancelled(42)
assert 42 in scheduler._cancelled_dispatches
assert 43 not in scheduler._cancelled_dispatches
def test_notify_dispatch_cancelled_ignores_items_not_in_flight(scheduler):
"""Cancelling a merely-pending item records nothing.
Every cancel and delete calls this, but only a dispatch that is already
running can be interrupted by it. Recording the rest would grow the set
once per cancelled item for the life of the process, and buys nothing:
`_claim_for_dispatch` only claims rows that are still `pending`, and the
caller has committed a terminal status (or deleted the row) first.
"""
scheduler.notify_dispatch_cancelled(99)
assert scheduler._cancelled_dispatches == set()
@pytest.mark.asyncio
async def test_preheat_sleep_runs_to_completion_when_not_cancelled(scheduler):
"""No flag set → the full duration is slept and True is returned."""
slept: list[float] = []
async def _fake_sleep(secs):
slept.append(secs)
with patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep):
ok = await scheduler._preheat_sleep(item_id=7, seconds=25)
assert ok is True
assert sum(slept) == pytest.approx(25.0)