| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- """Tests for the preheat & heat-soak scheduler stage (#1468).
- Three layered concerns the stage has to get right:
- 1. **Override resolution** (per-item beats global beats default).
- 2. **Chamber-target derivation** (item-override > filament-map max > 0).
- 3. **Hardware-tier branching** (chamber heater vs sensor-only vs no sensor).
- The fixtures construct a queue item with `preheat_override` + the override
- target both unset; tests flip those per case. `asyncio.sleep` is patched to
- AsyncMock so the soak phase doesn't actually wait — assertions are on what
- got scheduled, not wall-clock.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services.print_scheduler import PrintScheduler
- @pytest.fixture
- def scheduler():
- return PrintScheduler()
- @pytest.fixture
- def item():
- return SimpleNamespace(
- id=42,
- preheat_override="inherit",
- preheat_chamber_target_override=None,
- )
- @pytest.fixture
- def archive():
- return SimpleNamespace(bed_temperature=60)
- def _make_printer(model: str, printer_id: int = 7):
- 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: float = 0.0,
- chamber_temp: float = 0.0,
- trays: list[str] | None = None,
- airduct_mode: int = 0,
- ):
- """Build a PrinterState-shaped namespace with optional AMS tray types.
- `trays` is a list of tray_type strings (each becomes one loaded slot in
- AMS unit 0). Empty / None gives an empty AMS — the derivation falls
- through to 0. `airduct_mode` is 0 (cooling, default) or 1 (heating);
- matches the field on PrinterState that the preheat stage reads to
- decide whether to fire a redundant `set_airduct_mode` call."""
- raw_data: dict = {}
- if trays is not None:
- raw_data["ams"] = [{"tray": [{"tray_type": t} for t in trays]}]
- return SimpleNamespace(
- temperatures={"bed": bed_temp, "chamber": chamber_temp},
- raw_data=raw_data,
- airduct_mode=airduct_mode,
- )
- def _ints(**values):
- """Mock side_effect for _get_int_setting that returns the kwarg value
- when the key matches, else the helper's `default` argument."""
- return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
- # ----------------------------------------------------------------------------
- # Override resolution
- # ----------------------------------------------------------------------------
- @pytest.mark.asyncio
- async def test_global_disabled_inherit_skips(scheduler, item, archive):
- """preheat_enabled=False + item.preheat_override='inherit' → no heater dispatch."""
- db = MagicMock()
- client = _make_client()
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
- patch.object(scheduler, "_get_int_setting", _ints()),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- pm.get_client.return_value = client
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_bed_temperature.assert_not_called()
- client.set_chamber_temperature.assert_not_called()
- @pytest.mark.asyncio
- async def test_item_override_off_bypasses_global_on(scheduler, item, archive):
- """preheat_enabled=True + item.preheat_override='off' → preheat suppressed."""
- db = MagicMock()
- client = _make_client()
- item.preheat_override = "off"
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints()),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- pm.get_client.return_value = client
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_bed_temperature.assert_not_called()
- client.set_chamber_temperature.assert_not_called()
- @pytest.mark.asyncio
- async def test_item_override_on_runs_despite_global_off(scheduler, item, archive):
- """preheat_enabled=False + item.preheat_override='on' → preheat runs (bed
- fires, chamber depends on the resolved target)."""
- db = MagicMock()
- client = _make_client()
- item.preheat_override = "on"
- item.preheat_chamber_target_override = 0 # explicit no-chamber so the assertion is sharp
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
- patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 0.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_not_called()
- # ----------------------------------------------------------------------------
- # Chamber-target derivation
- # ----------------------------------------------------------------------------
- @pytest.mark.asyncio
- async def test_chamber_target_override_beats_filament_map(scheduler, item, archive):
- """item.preheat_chamber_target_override is the highest-priority source —
- a PLA-only print with an explicit 50°C override still heats the chamber."""
- db = MagicMock()
- client = _make_client()
- item.preheat_chamber_target_override = 50
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # Only PLA loaded — filament map would derive 0; override forces 50.
- pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic"])
- 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(50)
- @pytest.mark.asyncio
- async def test_filament_map_picks_max_across_loaded_slots(scheduler, item, archive):
- """Mixed PA + PLA load: PA=50 + PLA=0 → chamber target 50 (the max).
- The "lowest common denominator" model is wrong here; PA's requirement
- is the binding constraint."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # `PA Basic` (note the space) normalises to `PA` which the bundled
- # map keys against; a hyphenated `PA-Generic` would normalise to
- # `PA-GENERIC` and fall through to `default` (0) — that's a separate
- # behaviour the user editor handles by adding a custom key.
- pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic", "PA Basic"])
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_chamber_temperature.assert_called_once_with(50) # PA's recommendation, not PLA's
- @pytest.mark.asyncio
- async def test_pla_only_derives_zero_chamber_skips(scheduler, item, archive):
- """PLA-only print: filament-map lookup returns 0 → chamber phase skips
- automatically without the user touching anything."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA", "PLA"])
- 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_not_called()
- @pytest.mark.asyncio
- async def test_unknown_filament_type_falls_to_default(scheduler, item, archive):
- """A loaded tray with a type not in the map uses the `default` entry —
- keeps users with custom filament names safe (they get 0 by default,
- can be tuned via the per-filament editor)."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 0.0, trays=["MyCustomFilament"])
- 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_not_called() # default = 0
- @pytest.mark.asyncio
- async def test_custom_filament_targets_json_parses(scheduler, item, archive):
- """User-customised filament-target JSON overrides the bundled defaults —
- raising PLA to 30°C makes a PLA-only print actually heat the chamber."""
- db = MagicMock()
- client = _make_client()
- custom_map = '{"PLA": 30, "default": 0}'
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=custom_map)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 31.0, trays=["PLA Basic"])
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_chamber_temperature.assert_called_once_with(30)
- @pytest.mark.asyncio
- async def test_malformed_filament_targets_falls_back_to_defaults(scheduler, item, archive):
- """A corrupted JSON in the setting must not break the scheduler — log
- and use bundled defaults."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value="not-json{{{")),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # Bundled default for ABS = 45 → chamber should fire.
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_chamber_temperature.assert_called_once_with(45)
- # ----------------------------------------------------------------------------
- # Hardware-tier branching (unchanged from the first cut but updated for new
- # fixtures that include AMS data so the derivation lands at a non-zero target).
- # ----------------------------------------------------------------------------
- @pytest.mark.asyncio
- async def test_no_bed_temperature_in_archive_skips(scheduler, item):
- """Archive without bed_temperature metadata skips entirely rather than
- guessing a default that might wreck a non-PLA print."""
- db = MagicMock()
- client = _make_client()
- bare_archive = SimpleNamespace(bed_temperature=None)
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints()),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(trays=["ABS"])
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
- client.set_bed_temperature.assert_not_called()
- client.set_chamber_temperature.assert_not_called()
- @pytest.mark.asyncio
- async def test_x1c_skips_m141_but_waits_passively(scheduler, item, archive):
- """X1C has a chamber sensor but no active heater — M141 must NOT fire even
- when the filament map derives a non-zero target."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # ABS loaded → derived target 45; sensor reads 46 (already there).
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
- 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()
- @pytest.mark.asyncio
- async def test_p1s_no_chamber_sensor_uses_soak_timer_only(scheduler, item, archive):
- """P1S has no chamber sensor — derived target is ignored for the wait
- loop, only the soak timer applies."""
- db = MagicMock()
- client = _make_client()
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=600)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()) as sleep_mock,
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 0.0, trays=["ABS"])
- await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
- client.set_bed_temperature.assert_called_once_with(60)
- client.set_chamber_temperature.assert_not_called()
- assert 600 in [call.args[0] for call in sleep_mock.call_args_list]
- @pytest.mark.asyncio
- async def test_lost_client_skips_silently(scheduler, item, archive):
- """If the MQTT client drops, the helper returns without raising."""
- db = MagicMock()
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints()),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- pm.get_client.return_value = None
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- # No exception escaping — the disable path is silent.
- @pytest.mark.asyncio
- async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive):
- """H-series + X2D have a cooling/heating airduct flap that DEFAULTS to
- cooling. If we energise M141 without first flipping to heating, the
- chamber fan actively extracts the heat we're trying to put in and the
- chamber never converges. Verify airduct=heating fires AND lands before
- the chamber-target call so the heater starts in the right airflow regime."""
- db = MagicMock()
- client = _make_client()
- call_order = []
- client.set_airduct_mode.side_effect = lambda mode: call_order.append(("airduct", mode)) or True
- client.set_chamber_temperature.side_effect = lambda t: call_order.append(("chamber", t)) or True
- with (
- patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
- patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_airduct_mode.assert_called_once_with("heating")
- client.set_chamber_temperature.assert_called_once_with(45)
- # Airduct heating must precede M141 — the heater enabling first while the
- # flap is still in cooling mode wastes minutes of fan-vs-heater tug-of-war.
- assert call_order == [("airduct", "heating"), ("chamber", 45)]
- @pytest.mark.asyncio
- async def test_x1c_skips_airduct_no_heater_no_call(scheduler, item, archive):
- """X1C has neither an active chamber heater nor an airduct flap (the
- frontend's airduct whitelist is P2S/X2D/H2D/H2C/H2S/H2D Pro — no X1
- series). The preheat stage's airduct call is gated on supports_airduct
- AND has_heater, so X1C gets neither call regardless. Important: a
- spurious set_airduct on X1C wouldn't just be wasted MQTT — there's no
- flap to set, so the firmware response would be undefined behaviour."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
- await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
- client.set_chamber_temperature.assert_not_called()
- client.set_airduct_mode.assert_not_called()
- @pytest.mark.asyncio
- async def test_normalize_filament_type_strips_at_space():
- """`PLA Basic` and `ABS Premium` should normalise to `PLA` and `ABS` so
- they match the map keys. `PA-CF` has no space and stays verbatim."""
- s = PrintScheduler
- assert s._normalize_filament_type("PLA Basic") == "PLA"
- assert s._normalize_filament_type("ABS Premium") == "ABS"
- assert s._normalize_filament_type("PA-CF") == "PA-CF"
- assert s._normalize_filament_type("") == ""
- assert s._normalize_filament_type("petg") == "PETG" # case-folded
- @pytest.mark.asyncio
- async def test_get_preheat_filament_targets_defaults_when_missing(scheduler):
- """Empty / null setting → bundled defaults are used. _get_preheat_filament_targets
- upper-cases the keys, so the bundled `default` becomes `DEFAULT` on the
- returned dict — keep both spellings synced."""
- db = MagicMock()
- with patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)):
- targets = await scheduler._get_preheat_filament_targets(db)
- # The bundled defaults dict is kept as-is on the "no setting" path, so
- # `default` (lowercase) is what callers see for that fallback.
- assert targets["PLA"] == 0
- assert targets["ABS"] == 45
- assert targets["PA-CF"] == 55
- # Either casing must resolve to the fallback 0.
- assert targets.get("default", targets.get("DEFAULT")) == 0
- # ----------------------------------------------------------------------------
- # Airduct mode switch (#1468 follow-up)
- # ----------------------------------------------------------------------------
- @pytest.mark.asyncio
- async def test_h2d_chamber_heat_switches_airduct_to_heating(scheduler, item, archive):
- """H2D in cooling mode (the default; what you get after a PLA print)
- with chamber_target > 0 must switch the airduct to heating BEFORE the
- M141 dispatch — otherwise the open exhaust flap actively fights the
- chamber heater and the chamber never converges."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # Currently in cooling (mode 0). ABS loaded → derived target 45.
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_airduct_mode.assert_called_once_with("heating")
- client.set_chamber_temperature.assert_called_once_with(45)
- @pytest.mark.asyncio
- async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive):
- """H2D running a PLA print (chamber_target derives 0) on an airduct
- previously left in heating mode (from a prior ABS run) must switch
- back to cooling. Otherwise PLA prints inherit ABS's closed-flap recirc
- and run hot."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # Currently in heating (mode 1). PLA loaded → derived target 0.
- pm.get_status.return_value = _make_state(60.0, 30.0, trays=["PLA"], airduct_mode=1)
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_airduct_mode.assert_called_once_with("cooling")
- client.set_chamber_temperature.assert_not_called()
- @pytest.mark.asyncio
- async def test_h2d_airduct_already_correct_idempotent(scheduler, item, archive):
- """If the airduct is already in the desired mode, don't re-send
- `set_airduct` — the firmware accepts it but it generates needless MQTT
- chatter and could thrash the flap motor on rapid repeats."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- # Already in heating (mode 1) and ABS → derived 45 wants heating.
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=1)
- await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
- client.set_airduct_mode.assert_not_called()
- # But M141 still fires — the airduct decision is independent.
- client.set_chamber_temperature.assert_called_once_with(45)
- @pytest.mark.asyncio
- async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, archive):
- """X1C has a chamber sensor but no airduct flap — the firmware ignores
- `set_airduct`. We gate on `supports_airduct(model)` to avoid sending the
- no-op. Regression guard: wiring this to `supports_chamber_temp` or
- `supports_chamber_heater` alone would have leaked the command to
- X1C/X1E or P2S inappropriately."""
- db = MagicMock()
- 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)),
- patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
- ):
- pm.get_client.return_value = client
- pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
- await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
- client.set_airduct_mode.assert_not_called()
|