Files
bambuddy/backend/tests/unit/test_scheduler_keep_warm.py
maziggy b03603c4e2 Release the keep-warm bed on the dispatch paths that skip the rollback (#2727)
Selecting an item hands any keep-warm hold on its printer over to the preheat
pin: `_sweep_keep_warm` drops the `_keep_warm` entry and records "bed" instead,
on the promise that `_dispatch_one` will unwind it on any non-success exit. Two
of that function's exits never reached the `finally` that keeps the promise --
the claim failure returns before the `try` opens, and the vanished-row return
sat inside it but left `item_printer_id` at None, which the rollback guards on.

Either one left the bed hot with nothing tracking it. The keep-warm entry was
already gone, so the max-duration cap no longer applied and `_release_keep_warm`
had nothing to act on; if the cancelled item was that printer's last pending
one, the printer also dropped out of the candidate set, and nothing would ever
switch the bed off. Reachable whenever a cancel or delete lands between
selection and the claim -- narrow, but the outcome is exactly what the cap was
added to prevent.

`_dispatch_one` now takes the printer it was selected for. `_launch_uploads`
already had it (it stores the same value in `_inflight`), so nothing new is
plumbed, and the parameter is optional so the tests that call `_dispatch_one`
directly keep their existing behaviour.

Two pieces of hardening found while tracing that:

  * The rollback switched the bed off unconditionally, where the keep-warm
    release deliberately checks first that firmware still reports the target it
    set. It now records what it pinned and declines when someone else owns the
    bed. Every uncertain case still switches off -- no recorded target, or a
    status that cannot be read -- because a bed left hot with no owner is the
    worse failure, and this runs in a `finally` where raising would mask the
    real exception. That is why the status read is factored out into a total
    helper returning None for "no evidence" rather than 0.

  * `_apply_keep_warm` ran unguarded between selection and `_launch_uploads`, so
    anything raising there discarded the tick's selections, computed AMS
    mappings included, and on a persistent fault stopped the queue dispatching
    altogether. Wrapped, for the same reason the deficit check is: an auxiliary
    comfort feature must never wedge dispatch.

Also documents why the max-duration check sits behind the FINISH and client
guards rather than ahead of them, since the ordering looks like a hole and is
not: with no client there is no M140 to send and the elapsed check fires on the
first tick after the printer returns, and leaving FINISH means the plate was
cleared, which routes the printer to `_release_keep_warm` instead. The invariant
to preserve if that is ever reordered is that every path out of an engaged hold
ends in a bed-off.

Six tests: the handover recording its target, both early returns releasing, a
call with no printer id staying a no-op, the reassigned-bed skip, the matching
and unreadable cases switching off, and eviction on deregistration.
2026-08-10 13:54:44 +02:00

900 lines
37 KiB
Python

"""Tests for the keep-bed-warm loop that fires between queued prints.
`_apply_keep_warm()` is the per-tick helper that holds the bed hot on a
printer sitting in FINISH awaiting a plate-clear — so the chamber does not
cool down between back-to-back chamber-heated prints.
The hold temperature is `queue_keep_warm_bed_temp` (default 90 °C), raised to
the next item's own parsed bed_temperature when that is higher. The bed is
the chamber's heat source here, not a print surface, so an item with no
bed_temperature metadata still gets a hold — what gates the feature is
whether the next print needs chamber heat.
Gates the whole block on three settings AND-ed together
(`queue_keep_bed_warm`, `require_plate_clear`, `preheat_enabled`) so a user
who turns off the plate-clear or preheat gate stops holding heat without
having to also toggle keep-warm. Bounded by `queue_keep_warm_max_minutes`, and
skips the MQTT publish when the firmware already has the target.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import (
PrintScheduler,
_KeepWarmEntry,
)
PRINTER_ID = 7
# Archive bed temperature of the next queued item. Deliberately ABOVE HOLD_TEMP
# so the default fixtures exercise the "item's own bed temp wins" branch.
BED_TARGET = 100
# The configured `queue_keep_warm_bed_temp` floor used by `_run`.
HOLD_TEMP = 90
# The configured `queue_keep_warm_max_minutes` used by `_run`, in minutes and
# the seconds the scheduler derives from it.
MAX_HOLD_MINUTES = 120
MAX_HOLD_SECONDS = MAX_HOLD_MINUTES * 60
NOW = 10_000.0
@pytest.fixture
def scheduler():
return PrintScheduler()
def _make_item(
item_id: int = 1,
printer_id: int = PRINTER_ID,
bed_temperature: int | None = BED_TARGET,
preheat_chamber_target_override: int | None = 60,
):
"""Build a queue-item-shaped namespace with an archive.
``preheat_chamber_target_override`` at a non-zero int makes the chamber-
needed check pass without any AMS-derivation mocking. Set to ``None`` in
tests that specifically want to exercise the derivation branch.
"""
archive = SimpleNamespace(bed_temperature=bed_temperature)
return SimpleNamespace(
id=item_id,
printer_id=printer_id,
archive=archive,
preheat_chamber_target_override=preheat_chamber_target_override,
)
def _make_state(*, state="FINISH", bed_target=0.0, chamber=55.0):
"""PrinterState-shaped namespace with just what the keep-warm loop reads."""
return SimpleNamespace(
state=state,
temperatures={"bed_target": bed_target, "chamber": chamber},
raw_data={},
)
def _make_client():
client = MagicMock()
client.set_bed_temperature = MagicMock(return_value=True)
return client
def _bool_settings(**overrides):
"""AsyncMock side_effect returning per-key bool values.
Defaults enable the full stack; pass ``queue_keep_bed_warm=False`` etc
to switch individual gates off.
"""
defaults = {
"queue_keep_bed_warm": True,
"preheat_enabled": True,
}
defaults.update(overrides)
return AsyncMock(side_effect=lambda _db, key, default: defaults.get(key, default))
def _int_settings(hold_temp, max_hold_minutes):
return {
"queue_keep_warm_bed_temp": hold_temp,
"queue_keep_warm_max_minutes": max_hold_minutes,
}
async def _run(
scheduler,
*,
items=None,
dispatch_ids=None,
busy_printers=None,
require_plate_clear=True,
bool_settings=None,
hold_temp=HOLD_TEMP,
max_hold_minutes=MAX_HOLD_MINUTES,
):
"""Invoke `_apply_keep_warm` with sensible defaults and standard patches."""
if items is None:
items = [_make_item()]
if dispatch_ids is None:
dispatch_ids = []
if busy_printers is None:
busy_printers = {PRINTER_ID}
if bool_settings is None:
bool_settings = _bool_settings()
db = AsyncMock()
with (
patch.object(scheduler, "_get_bool_setting", bool_settings),
patch.object(
scheduler,
"_get_int_setting",
AsyncMock(
side_effect=lambda _db, key, default=0: _int_settings(hold_temp, max_hold_minutes).get(key, default)
),
),
patch("backend.app.services.print_scheduler.time") as t,
):
t.monotonic.return_value = NOW
await scheduler._apply_keep_warm(db, items, dispatch_ids, busy_printers, require_plate_clear)
# ---------------------------------------------------------------------------
# Gating: all three settings AND-ed together
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_skips_when_feature_disabled(scheduler):
"""queue_keep_bed_warm=False → no MQTT publish, no state change."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, bool_settings=_bool_settings(queue_keep_bed_warm=False))
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_skips_when_require_plate_clear_off(scheduler):
"""require_plate_clear=False → skip even if keep-warm and preheat are on."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, require_plate_clear=False)
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_skips_when_preheat_disabled(scheduler):
"""preheat_enabled=False → skip regardless of the toggle."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, bool_settings=_bool_settings(preheat_enabled=False))
client.set_bed_temperature.assert_not_called()
# ---------------------------------------------------------------------------
# Per-printer skip conditions
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_skips_when_printer_not_in_finish(scheduler):
"""Only FINISH printers keep warm — a printer that's still printing is not held."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(state="RUNNING")
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_holds_configured_temp_when_archive_has_no_bed_temp(scheduler):
"""No parsed bed_temperature → still hold, at the configured keep-warm temp.
The bed is the chamber's heat source during the hold, not a print surface,
so missing slicer metadata must not disable the feature. OrcaSlicer
`.gcode.3mf` exports parse without a bed temperature and would otherwise
never keep warm even though their filament requires chamber heat.
"""
client = _make_client()
item = _make_item(bed_temperature=None)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item])
client.set_bed_temperature.assert_called_once_with(HOLD_TEMP)
assert scheduler._keep_warm[PRINTER_ID].held_target == HOLD_TEMP
@pytest.mark.asyncio
async def test_keep_warm_uses_item_bed_temp_when_higher_than_configured(scheduler):
"""Item's own bed temp (100) > configured hold (90) → hold at 100.
The hold must never run cooler than the print itself will, or the chamber
would dip right before dispatch.
"""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(bed_temperature=100)])
client.set_bed_temperature.assert_called_once_with(100)
@pytest.mark.asyncio
async def test_keep_warm_uses_configured_temp_when_item_bed_temp_lower(scheduler):
"""Item's bed temp (60) < configured hold (90) → hold at 90.
A cool-plate ASA profile still needs the chamber hot; the floor wins.
"""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(bed_temperature=60)])
client.set_bed_temperature.assert_called_once_with(HOLD_TEMP)
@pytest.mark.asyncio
async def test_keep_warm_honours_custom_configured_hold_temp(scheduler):
"""`queue_keep_warm_bed_temp` is read from settings, not hard-coded."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(bed_temperature=None)], hold_temp=105)
client.set_bed_temperature.assert_called_once_with(105)
@pytest.mark.asyncio
async def test_keep_warm_skips_when_no_client(scheduler):
"""No live client (e.g. printer just deregistered) → skip silently."""
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = None
await _run(scheduler)
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_skips_when_chamber_override_zero(scheduler):
"""Per-item override of 0 → 'no chamber even if filament wants it' → skip."""
client = _make_client()
item = _make_item(preheat_chamber_target_override=0)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item])
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_skips_when_dispatched_this_cycle(scheduler):
"""Printers being dispatched this tick are excluded — _preheat_and_soak owns their bed."""
client = _make_client()
item = _make_item(item_id=42)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item], dispatch_ids=[42])
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_skips_when_chamber_derivation_yields_zero(scheduler):
"""No per-item override + _derive_chamber_target returns 0 → skip."""
client = _make_client()
item = _make_item(preheat_chamber_target_override=None)
with (
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_get_preheat_filament_targets", AsyncMock(return_value={})),
patch.object(scheduler, "_get_printer", AsyncMock(return_value=SimpleNamespace(id=PRINTER_ID, model="H2D"))),
patch.object(scheduler, "_derive_chamber_target", return_value=0),
):
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item])
client.set_bed_temperature.assert_not_called()
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_publishes_bed_target(scheduler):
"""Full-stack happy path: gates on, printer in FINISH, chamber needed → M140 sent."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state() # bed_target=0 → publish fires
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_called_once_with(BED_TARGET)
assert PRINTER_ID in scheduler._keep_warm
entry = scheduler._keep_warm[PRINTER_ID]
assert entry.held_target == BED_TARGET
assert entry.expired is False
# ---------------------------------------------------------------------------
# Idempotence guard
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_skips_when_firmware_already_at_target(scheduler):
"""state.temperatures['bed_target'] already equals the desired target → no publish."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_not_called()
# ---------------------------------------------------------------------------
# Max-duration timeout — publish bed → 0 once, latch expired, do NOT re-arm
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_publishes_bed_off_and_latches_on_timeout(scheduler):
"""After MAX_HOLD_SECONDS: publish bed → 0, latch expired, keep the entry.
The old behaviour popped the entry — but that meant the next tick's
``setdefault`` re-seeded ``started`` and the 2 h window restarted forever.
The entry must stay so subsequent ticks skip re-engagement.
"""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (MAX_HOLD_SECONDS + 1),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware still holds our target → bed-off publish fires.
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID in scheduler._keep_warm
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_timeout_does_not_rearm_on_next_tick(scheduler):
"""Multi-tick regression guard: the tick AFTER a timeout must NOT re-engage.
This is the bug the review flagged: popping on timeout let the next
tick's ``setdefault(pid, now_mono)`` re-seed the clock, restarting the
2 h window. Latching ``expired=True`` on the entry (kept in place)
prevents that.
"""
original_started = NOW - (MAX_HOLD_SECONDS + 1)
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=original_started,
held_target=BED_TARGET,
expired=True, # already latched by previous tick's timeout
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
# No re-engagement, no bed-off (already sent in the prior tick), and the
# entry keeps its ORIGINAL started timestamp — no clock re-seed.
client.set_bed_temperature.assert_not_called()
assert scheduler._keep_warm[PRINTER_ID].started == original_started
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_timeout_skips_bed_off_when_firmware_target_changed(scheduler):
"""Firmware bed_target != held_target on timeout → don't clobber user's change."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (MAX_HOLD_SECONDS + 1),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware target no longer matches held_target.
pm.get_status.return_value = _make_state(bed_target=42.0)
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_not_called()
# Latch still fires so we don't re-engage next tick.
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_starts_timer_on_first_tick(scheduler):
"""First tick for a printer creates a _KeepWarmEntry with started=NOW."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler)
assert PRINTER_ID in scheduler._keep_warm
entry = scheduler._keep_warm[PRINTER_ID]
assert entry.started == NOW
assert entry.held_target == BED_TARGET
assert entry.expired is False
@pytest.mark.asyncio
async def test_keep_warm_preserves_existing_timer(scheduler):
"""Subsequent ticks must NOT reset started — otherwise timeout never fires."""
started_earlier = NOW - 3600
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=started_earlier,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware already at our target → idempotence skips the publish;
# the entry is preserved as-is.
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
assert scheduler._keep_warm[PRINTER_ID].started == started_earlier
# ---------------------------------------------------------------------------
# Release sweep — bed → 0 when printer leaves the candidate set / gate off
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_releases_bed_when_printer_leaves_candidate_set(scheduler):
"""Owned printer no longer in candidates → publish bed → 0, drop entry."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_release_skipped_when_printer_was_dispatched(scheduler):
"""Dispatched printers exit candidates but _preheat_and_soak owns the bed — no bed-off."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
item = _make_item(item_id=42)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
# Item 42 is being dispatched this tick — release must NOT publish.
await _run(scheduler, items=[item], dispatch_ids=[42])
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._keep_warm # tracking dropped either way
@pytest.mark.asyncio
async def test_keep_warm_hands_bed_ownership_to_preheat_pin_on_dispatch(scheduler):
"""Handing a hot bed to dispatch must register it for preheat rollback.
Keep-warm stops tracking the printer the moment it is dispatched, and
`_preheat_and_soak` may never claim the bed itself (it returns early when
the item has no bed_temperature metadata). Without this transfer, an
aborted dispatch — failed upload, cancelled item — would leave the bed hot
with no owner and nothing to turn it off.
"""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 300, held_target=BED_TARGET)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(item_id=42)], dispatch_ids=[42])
assert "bed" in scheduler._preheat_pin.get(PRINTER_ID, set())
@pytest.mark.asyncio
async def test_keep_warm_release_does_not_touch_preheat_pin(scheduler):
"""A genuine release (not a dispatch) turns the bed off — no pin entry needed."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 300, held_target=BED_TARGET)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._preheat_pin
@pytest.mark.asyncio
async def test_keep_warm_release_skipped_when_firmware_target_changed(scheduler):
"""Firmware bed_target != held_target on release → don't clobber user's change."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=42.0)
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._keep_warm # tracking still dropped
@pytest.mark.asyncio
async def test_keep_warm_release_fires_when_feature_toggled_off_mid_hold(scheduler):
"""queue_keep_bed_warm turned off while a printer is owned → release still fires."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, bool_settings=_bool_settings(queue_keep_bed_warm=False))
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_release_fires_when_plate_clear_toggled_off_mid_hold(scheduler):
"""require_plate_clear=False mid-hold → release still fires."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, require_plate_clear=False)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._keep_warm
# ---------------------------------------------------------------------------
# Candidate-set eviction of stale state
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_keeps_entry_when_printer_is_unreachable(scheduler):
"""An unreachable printer keeps its entry so a later tick can still release it.
Printer 99 left the candidate set, but `get_status` returns None — it is
briefly offline, not gone. Its bed may still be hot, so dropping the entry
here would stop the max-duration timeout applying and leave nothing
tracking it. The entry is kept and the release retried later;
`_sample_chamber_temps` is the only place that gives up, once the printer
has left the manager entirely.
"""
scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.side_effect = lambda pid: _make_state(bed_target=float(BED_TARGET)) if pid == PRINTER_ID else None
pm.get_client.side_effect = lambda pid: client if pid == PRINTER_ID else None
await _run(scheduler, busy_printers={PRINTER_ID})
assert 99 in scheduler._keep_warm, "unreachable printer must stay tracked"
assert PRINTER_ID in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_release_retries_after_a_failed_publish(scheduler):
"""A failed bed-off keeps the entry so the next tick tries again."""
scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
client = _make_client()
client.set_bed_temperature.side_effect = RuntimeError("mqtt down")
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_called_once_with(0)
assert 99 in scheduler._keep_warm, "a failed release must not silently drop the entry"
def test_sample_chamber_temps_evicts_preheat_pin_for_removed_printer(scheduler):
"""Per-printer preheat state is evicted with the rest when a printer disappears."""
scheduler._preheat_pin[99] = {"bed"}
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: SimpleNamespace(connected=True, temperatures={"chamber": 40.0})}
scheduler._sample_chamber_temps()
assert 99 not in scheduler._preheat_pin
assert PRINTER_ID in scheduler._preheat_pin
# ---------------------------------------------------------------------------
# Lazy filament target fetch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_does_not_fetch_filament_targets_when_all_overrides(scheduler):
"""Per-item overrides supply chamber_needed → skip the DB round-trip."""
client = _make_client()
fetch_targets = AsyncMock(return_value={})
with (
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_get_preheat_filament_targets", fetch_targets),
):
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
# Item has an explicit chamber override, so derivation is not needed.
await _run(scheduler)
fetch_targets.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_fetches_filament_targets_once_per_tick(scheduler):
"""When derivation is needed for multiple printers, only fetch targets once."""
items = [
_make_item(item_id=1, printer_id=1, preheat_chamber_target_override=None),
_make_item(item_id=2, printer_id=2, preheat_chamber_target_override=None),
]
client1 = _make_client()
client2 = _make_client()
fetch_targets = AsyncMock(return_value={"ASA": 60})
with (
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_get_preheat_filament_targets", fetch_targets),
patch.object(scheduler, "_get_printer", AsyncMock(return_value=SimpleNamespace(id=1, model="H2D"))),
patch.object(scheduler, "_derive_chamber_target", return_value=60),
):
pm.get_status.return_value = _make_state()
pm.get_client.side_effect = lambda pid: {1: client1, 2: client2}[pid]
await _run(scheduler, items=items, busy_printers={1, 2})
assert fetch_targets.call_count == 1
@pytest.mark.asyncio
async def test_keep_warm_timeout_honours_configured_minutes(scheduler):
"""A 15-minute limit stops the hold at 15 minutes, not at the default.
The whole point of `queue_keep_warm_max_minutes`: a user who does not want
a bed sitting hot while they are away sets a short window, and the heaters
go off when it elapses.
"""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (15 * 60 + 1),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, max_hold_minutes=15)
client.set_bed_temperature.assert_called_once_with(0)
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_holds_within_configured_window(scheduler):
"""Just inside the configured window the hold continues untouched."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (15 * 60 - 60),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware already holds the target, so an untouched hold means no publish.
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, max_hold_minutes=15)
client.set_bed_temperature.assert_not_called()
assert scheduler._keep_warm[PRINTER_ID].expired is False
# ---------------------------------------------------------------------------
# Handing the hold to the preheat pin, and getting it back when dispatch bails
# ---------------------------------------------------------------------------
#
# `_sweep_keep_warm` gives up the keep-warm entry for a printer being dispatched
# this tick and pins "bed" instead, on the promise that `_dispatch_one` unwinds
# it on any non-success exit. Two of `_dispatch_one`'s exits used to break that
# promise by returning before the `finally` could run, which left the bed hot
# with the entry already gone -- so neither the max-duration cap nor
# `_release_keep_warm` applied, and on the printer's last pending item nothing
# would ever switch it off.
def test_dispatch_handover_records_the_held_target(scheduler):
"""The pin remembers what keep-warm was holding, not just that it held."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW, held_target=HOLD_TEMP)
scheduler._sweep_keep_warm(active_candidates=set(), dispatched={PRINTER_ID})
assert PRINTER_ID not in scheduler._keep_warm
assert scheduler._preheat_pin[PRINTER_ID] == {"bed"}
assert scheduler._preheat_pin_bed[PRINTER_ID] == HOLD_TEMP
@pytest.mark.asyncio
async def test_unclaimable_item_releases_the_handed_over_bed(scheduler):
"""A cancel landing between selection and the claim must not strand the bed.
`_claim_for_dispatch` returning False exits before the try/finally, so the
rollback has to fire on that path explicitly.
"""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with (
patch("backend.app.services.print_scheduler.async_session") as session_factory,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=False)),
):
session_factory.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
await scheduler._dispatch_one(42, selected_printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._preheat_pin
assert PRINTER_ID not in scheduler._preheat_pin_bed
@pytest.mark.asyncio
async def test_unclaimable_item_without_a_known_printer_is_a_noop(scheduler):
"""Direct callers that pass no printer keep the old behaviour."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
client = MagicMock()
with (
patch("backend.app.services.print_scheduler.async_session") as session_factory,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=False)),
):
session_factory.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
pm.get_client.return_value = client
await scheduler._dispatch_one(42)
client.set_bed_temperature.assert_not_called()
assert scheduler._preheat_pin[PRINTER_ID] == {"bed"}
@pytest.mark.asyncio
async def test_vanished_item_releases_the_handed_over_bed(scheduler):
"""The row disappearing after a successful claim takes the same exit.
That return is inside the try, but `item_printer_id` used to still be None
there, so the rollback was skipped by its own guard.
"""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
item_db = MagicMock()
item_db.get = AsyncMock(return_value=None)
with (
patch("backend.app.services.print_scheduler.async_session") as session_factory,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)),
patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()),
patch.object(scheduler, "_release_unconfirmed_budget_reservation", AsyncMock()),
):
session_factory.return_value.__aenter__ = AsyncMock(return_value=item_db)
session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
await scheduler._dispatch_one(42, selected_printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._preheat_pin
# ---------------------------------------------------------------------------
# Rollback leaves a bed somebody else now owns alone
# ---------------------------------------------------------------------------
def test_rollback_leaves_a_reassigned_bed_alone(scheduler):
"""Firmware reports a target we did not set → the bed belongs to someone else."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": 45})
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._preheat_pin
assert PRINTER_ID not in scheduler._preheat_pin_bed
def test_rollback_switches_off_when_the_target_still_matches(scheduler):
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
def test_rollback_switches_off_when_the_target_cannot_be_read(scheduler):
"""No evidence is not evidence of reassignment -- err towards a cold bed."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = None
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
def test_unregistered_printer_evicts_the_recorded_bed_target(scheduler):
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_all_statuses.return_value = {}
scheduler._sample_chamber_temps()
assert PRINTER_ID not in scheduler._preheat_pin
assert PRINTER_ID not in scheduler._preheat_pin_bed