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