mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
900 lines
37 KiB
Python
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
|