| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899 |
- """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
|