| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225 |
- """Unit tests for SmartPlugManager service.
- These tests specifically target the auto-off behavior and toggle functionality
- that were identified as common regression points.
- """
- import asyncio
- from datetime import datetime, timezone
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services.smart_plug_manager import SmartPlugManager
- class TestSmartPlugManager:
- """Tests for SmartPlugManager class."""
- @pytest.fixture
- def manager(self):
- """Create a fresh SmartPlugManager instance."""
- return SmartPlugManager()
- @pytest.fixture
- def mock_plug(self):
- """Create a mock SmartPlug object."""
- plug = MagicMock()
- plug.id = 1
- plug.name = "Test Plug"
- plug.ip_address = "192.168.1.100"
- plug.username = None
- plug.password = None
- plug.enabled = True
- plug.auto_on = True
- plug.auto_off = True
- plug.off_delay_mode = "time"
- plug.off_delay_minutes = 5
- plug.off_temp_threshold = 70
- plug.printer_id = 1
- plug.auto_off_executed = False
- plug.auto_off_pending = False
- plug.last_state = "ON"
- plug.last_checked = None
- # #1349: drying defaults match the new schema — both off until the
- # user opts in, so existing tests don't accidentally activate the
- # post-drying path.
- plug.plug_type = "tasmota"
- plug.ha_entity_id = None
- plug.auto_off_after_drying = False
- plug.off_delay_after_drying_minutes = 10
- return plug
- @pytest.fixture
- def mock_db(self):
- """Create a mock database session."""
- db = AsyncMock()
- db.commit = AsyncMock()
- db.refresh = AsyncMock()
- return db
- # ========================================================================
- # Tests for on_print_start
- # ========================================================================
- @pytest.mark.asyncio
- async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
- """Verify plug is turned ON when print starts with auto_on enabled."""
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_get_plug.return_value = [mock_plug]
- mock_tasmota.turn_on = AsyncMock(return_value=True)
- await manager.on_print_start(printer_id=1, db=mock_db)
- mock_tasmota.turn_on.assert_called_once_with(mock_plug)
- @pytest.mark.asyncio
- async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db):
- """Verify plug is NOT turned on when auto_on is disabled."""
- mock_plug.auto_on = False
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_get_plug.return_value = [mock_plug]
- mock_tasmota.turn_on = AsyncMock()
- await manager.on_print_start(printer_id=1, db=mock_db)
- mock_tasmota.turn_on.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
- """Verify plug is NOT turned on when plug.enabled is False."""
- mock_plug.enabled = False
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_get_plug.return_value = [mock_plug]
- mock_tasmota.turn_on = AsyncMock()
- await manager.on_print_start(printer_id=1, db=mock_db)
- mock_tasmota.turn_on.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):
- """Verify graceful handling when no plug is linked to printer."""
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_get_plug.return_value = []
- mock_tasmota.turn_on = AsyncMock()
- # Should not raise any exception
- await manager.on_print_start(printer_id=999, db=mock_db)
- mock_tasmota.turn_on.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db):
- """Verify starting a new print cancels any pending auto-off."""
- # Set up a pending task
- mock_task = MagicMock()
- manager._pending_off[mock_plug.id] = mock_task
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_get_plug.return_value = [mock_plug]
- mock_tasmota.turn_on = AsyncMock(return_value=True)
- await manager.on_print_start(printer_id=1, db=mock_db)
- mock_task.cancel.assert_called_once()
- assert mock_plug.id not in manager._pending_off
- @pytest.mark.asyncio
- async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db):
- """Verify auto_off_executed flag is reset when turning on."""
- mock_plug.auto_off_executed = True
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_get_plug.return_value = [mock_plug]
- mock_tasmota.turn_on = AsyncMock(return_value=True)
- await manager.on_print_start(printer_id=1, db=mock_db)
- assert mock_plug.auto_off_executed is False
- # ========================================================================
- # Tests for on_print_complete
- # ========================================================================
- @pytest.mark.asyncio
- async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db):
- """Verify time-based auto-off is scheduled when print completes."""
- mock_plug.off_delay_mode = "time"
- mock_plug.off_delay_minutes = 5
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
- mock_schedule.assert_called_once_with(mock_plug, 1, 300) # 5 min * 60 sec
- @pytest.mark.asyncio
- async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db):
- """Verify temperature-based auto-off is scheduled when print completes."""
- mock_plug.off_delay_mode = "temperature"
- mock_plug.off_temp_threshold = 70
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
- mock_schedule.assert_called_once_with(mock_plug, 1, 70)
- @pytest.mark.asyncio
- async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db):
- """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
- This is a key regression test - the toggle must respect the setting.
- """
- mock_plug.auto_off = False
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- patch.object(manager, "_schedule_temp_based_off") as mock_temp,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
- mock_schedule.assert_not_called()
- mock_temp.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
- """Verify auto-off does NOT trigger when plug is disabled."""
- mock_plug.enabled = False
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
- mock_schedule.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):
- """Verify auto-off does NOT trigger on failed prints for investigation."""
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="failed", db=mock_db)
- mock_schedule.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):
- """Verify auto-off does NOT trigger on aborted prints."""
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db)
- mock_schedule.assert_not_called()
- # ========================================================================
- # Tests for on_drying_complete (#1349)
- # ========================================================================
- @pytest.mark.asyncio
- async def test_on_drying_complete_schedules_delayed_off_when_enabled(self, manager, mock_plug, mock_db):
- """Plug with ``auto_off_after_drying=True`` gets a delayed-off scheduled
- using its drying-specific delay (independent of print-finish delay)."""
- mock_plug.auto_off_after_drying = True
- mock_plug.off_delay_after_drying_minutes = 15
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_drying_complete(printer_id=1, db=mock_db)
- mock_schedule.assert_called_once_with(mock_plug, 1, 15 * 60)
- @pytest.mark.asyncio
- async def test_on_drying_complete_skipped_when_toggle_off(self, manager, mock_plug, mock_db):
- """Default state — toggle off → nothing scheduled. This is the regression
- guard for users who only enable the print-finish auto-off and don't
- want the AMS-drying path silently running on the same plug."""
- mock_plug.auto_off_after_drying = False
- # auto_off itself is True (existing print-finish behaviour) — the
- # drying path must still be a no-op without its own toggle.
- mock_plug.auto_off = True
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_drying_complete(printer_id=1, db=mock_db)
- mock_schedule.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_drying_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
- """Drying auto-off honours the master ``enabled`` flag."""
- mock_plug.auto_off_after_drying = True
- mock_plug.enabled = False
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_drying_complete(printer_id=1, db=mock_db)
- mock_schedule.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_drying_complete_skipped_for_ha_script_entity(self, manager, mock_plug, mock_db):
- """HA script entities can be triggered but not turned off — same
- guard the print-finish path has."""
- mock_plug.auto_off_after_drying = True
- mock_plug.plug_type = "homeassistant"
- mock_plug.ha_entity_id = "script.lights_off"
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = [mock_plug]
- await manager.on_drying_complete(printer_id=1, db=mock_db)
- mock_schedule.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_drying_complete_no_op_when_no_plugs(self, manager, mock_db):
- """Printer without any linked plugs is a silent no-op (not an error)."""
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get_plug.return_value = []
- await manager.on_drying_complete(printer_id=1, db=mock_db)
- mock_schedule.assert_not_called()
- # ========================================================================
- # Tests for _cancel_pending_off
- # ========================================================================
- @pytest.mark.asyncio
- async def test_cancel_pending_off_removes_task(self, manager, mock_plug):
- """Verify pending off tasks can be cancelled."""
- mock_task = MagicMock()
- manager._pending_off[mock_plug.id] = mock_task
- with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
- manager._cancel_pending_off(mock_plug.id)
- assert mock_plug.id not in manager._pending_off
- mock_task.cancel.assert_called_once()
- @pytest.mark.asyncio
- async def test_cancel_pending_off_handles_missing_task(self, manager):
- """Verify no error when cancelling non-existent task."""
- # Should not raise any exception
- with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
- manager._cancel_pending_off(999) # Non-existent plug ID
- # ========================================================================
- # Tests for scheduler
- # ========================================================================
- def test_start_scheduler(self, manager):
- """Verify scheduler can be started."""
- assert manager._scheduler_task is None
- # The patched loops are AsyncMocks, so each call still builds a
- # coroutine: the stand-in create_task closes it, or it is reported
- # unawaited in a later test.
- def _close_and_stand_in(coro, **_):
- coro.close()
- return MagicMock()
- with (
- patch.object(manager, "_schedule_loop") as mock_loop,
- patch.object(manager, "_snapshot_loop"),
- patch.object(manager, "_power_alert_loop") as mock_power_alert,
- patch.object(manager, "_relay_energy_loop") as mock_relay_energy,
- patch("asyncio.create_task", side_effect=_close_and_stand_in),
- ):
- manager.start_scheduler()
- assert manager._scheduler_task is not None
- mock_loop.assert_called_once()
- # Power alerts are checked in the background, not only while a page is open.
- assert manager._power_alert_task is not None
- mock_power_alert.assert_called_once()
- # Feeds the MQTT relay's smart_plugs/energy topic.
- assert manager._relay_energy_task is not None
- mock_relay_energy.assert_called_once()
- def test_stop_scheduler(self, manager):
- """Verify scheduler can be stopped."""
- mock_task = MagicMock()
- manager._scheduler_task = mock_task
- manager.stop_scheduler()
- mock_task.cancel.assert_called_once()
- assert manager._scheduler_task is None
- def test_start_scheduler_idempotent(self, manager):
- """Verify starting scheduler twice doesn't create multiple tasks."""
- mock_schedule_task = MagicMock()
- mock_snapshot_task = MagicMock()
- manager._scheduler_task = mock_schedule_task
- manager._snapshot_task = mock_snapshot_task
- manager._power_alert_task = MagicMock()
- manager._relay_energy_task = MagicMock()
- # Mock the loop coroutines to avoid unawaited coroutine warnings
- with (
- patch.object(manager, "_schedule_loop") as mock_loop,
- patch.object(manager, "_snapshot_loop") as mock_snapshot,
- patch.object(manager, "_power_alert_loop") as mock_power_alert,
- patch.object(manager, "_relay_energy_loop") as mock_relay_energy,
- patch("asyncio.create_task") as mock_create,
- ):
- manager.start_scheduler()
- mock_create.assert_not_called() # Should not create new tasks
- mock_loop.assert_not_called()
- mock_snapshot.assert_not_called()
- mock_power_alert.assert_not_called()
- mock_relay_energy.assert_not_called()
- def test_stop_scheduler_cancels_snapshot_task(self, manager):
- """Verify stopping scheduler also cancels the snapshot loop (#941)."""
- mock_schedule_task = MagicMock()
- mock_snapshot_task = MagicMock()
- manager._scheduler_task = mock_schedule_task
- manager._snapshot_task = mock_snapshot_task
- mock_power_alert_task = MagicMock()
- manager._power_alert_task = mock_power_alert_task
- mock_relay_energy_task = MagicMock()
- manager._relay_energy_task = mock_relay_energy_task
- manager.stop_scheduler()
- mock_schedule_task.cancel.assert_called_once()
- mock_snapshot_task.cancel.assert_called_once()
- mock_power_alert_task.cancel.assert_called_once()
- mock_relay_energy_task.cancel.assert_called_once()
- assert manager._power_alert_task is None
- assert manager._relay_energy_task is None
- assert manager._scheduler_task is None
- assert manager._snapshot_task is None
- class TestGetPlugsForPrinter:
- """Tests for _get_plugs_for_printer — returns all plugs for a printer (#903)."""
- @pytest.fixture
- def manager(self):
- return SmartPlugManager()
- @pytest.mark.asyncio
- async def test_returns_empty_list_when_no_plugs(self, manager):
- """Verify empty list is returned when no plugs are linked to printer."""
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = []
- mock_db.execute = AsyncMock(return_value=mock_result)
- result = await manager._get_plugs_for_printer(1, mock_db)
- assert result == []
- @pytest.mark.asyncio
- async def test_returns_single_plug_as_list(self, manager):
- """Verify single plug is returned in a list."""
- plug = MagicMock()
- plug.plug_type = "tasmota"
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- result = await manager._get_plugs_for_printer(1, mock_db)
- assert result == [plug]
- @pytest.mark.asyncio
- async def test_returns_all_plugs(self, manager):
- """Verify all plugs are returned when multiple exist (#903)."""
- plug1 = MagicMock()
- plug1.plug_type = "homeassistant"
- plug1.ha_entity_id = "switch.printer"
- plug2 = MagicMock()
- plug2.plug_type = "homeassistant"
- plug2.ha_entity_id = "switch.filter"
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [plug1, plug2]
- mock_db.execute = AsyncMock(return_value=mock_result)
- result = await manager._get_plugs_for_printer(1, mock_db)
- assert result == [plug1, plug2]
- class TestAutoOffPersistent:
- """Tests for persistent auto-off behavior (Issue #826).
- When auto_off_persistent is True, auto_off should remain enabled after
- execution instead of being disabled (one-shot default).
- """
- @pytest.fixture
- def manager(self):
- return SmartPlugManager()
- @pytest.mark.asyncio
- async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
- """Default one-shot: auto_off should be set to False after execution."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.auto_off = True
- mock_plug.auto_off_persistent = False
- mock_plug.auto_off_executed = False
- mock_plug.auto_off_pending = True
- mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
- with patch("backend.app.core.database.async_session") as mock_session_ctx:
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalar_one_or_none.return_value = mock_plug
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- await manager._mark_auto_off_executed(1)
- assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
- assert mock_plug.auto_off_pending is False
- assert mock_plug.auto_off_pending_since is None
- mock_db.commit.assert_called_once()
- @pytest.mark.asyncio
- async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
- """Persistent mode: auto_off should remain True after execution."""
- mock_plug = MagicMock()
- mock_plug.id = 2
- mock_plug.auto_off = True
- mock_plug.auto_off_persistent = True
- mock_plug.auto_off_executed = False
- mock_plug.auto_off_pending = True
- mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
- with patch("backend.app.core.database.async_session") as mock_session_ctx:
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalar_one_or_none.return_value = mock_plug
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- await manager._mark_auto_off_executed(2)
- assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
- assert mock_plug.auto_off_pending is False
- assert mock_plug.auto_off_pending_since is None
- mock_db.commit.assert_called_once()
- @pytest.mark.asyncio
- async def test_persistent_auto_off_full_cycle(self, manager):
- """Verify persistent auto-off survives a full print cycle.
- Simulates: print start → print complete → auto-off executes → next print start.
- auto_off should remain True throughout for persistent plugs.
- """
- mock_plug = MagicMock()
- mock_plug.id = 3
- mock_plug.name = "HA BentoBox Filter"
- mock_plug.plug_type = "homeassistant"
- mock_plug.ha_entity_id = "switch.bentobox_filter"
- mock_plug.ip_address = None
- mock_plug.username = None
- mock_plug.password = None
- mock_plug.enabled = True
- mock_plug.auto_on = True
- mock_plug.auto_off = True
- mock_plug.auto_off_persistent = True
- mock_plug.off_delay_mode = "time"
- mock_plug.off_delay_minutes = 1
- mock_plug.off_temp_threshold = 70
- mock_plug.printer_id = 1
- mock_plug.auto_off_executed = False
- mock_plug.auto_off_pending = False
- mock_plug.last_state = "OFF"
- mock_plug.last_checked = None
- mock_db = AsyncMock()
- mock_db.commit = AsyncMock()
- # Step 1: Print starts — plug turns on
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
- ):
- mock_get.return_value = [mock_plug]
- mock_service = AsyncMock()
- mock_service.turn_on = AsyncMock(return_value=True)
- mock_svc.return_value = mock_service
- await manager.on_print_start(printer_id=1, db=mock_db)
- assert mock_plug.auto_off_executed is False
- assert mock_plug.auto_off is True # Still enabled
- # Step 2: Print completes — auto-off is scheduled
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
- patch.object(manager, "_schedule_delayed_off") as mock_schedule,
- ):
- mock_get.return_value = [mock_plug]
- await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
- mock_schedule.assert_called_once()
- assert mock_plug.auto_off is True # Still enabled after scheduling
- # Step 3: Auto-off executes via _mark_auto_off_executed
- with patch("backend.app.core.database.async_session") as mock_session_ctx:
- mock_db2 = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalar_one_or_none.return_value = mock_plug
- mock_db2.execute = AsyncMock(return_value=mock_result)
- mock_db2.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- await manager._mark_auto_off_executed(3)
- # KEY ASSERTION: auto_off stays True for persistent mode
- assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
- assert mock_plug.auto_off_pending is False
- class TestScheduleLoop:
- """Tests for the schedule-based plug control."""
- @pytest.fixture
- def manager(self):
- return SmartPlugManager()
- @pytest.mark.asyncio
- async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
- """Verify scheduled on-time turns plug on."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.name = "Test Plug"
- mock_plug.enabled = True
- mock_plug.schedule_enabled = True
- mock_plug.schedule_on_time = "08:00"
- mock_plug.schedule_off_time = "22:00"
- mock_plug.printer_id = None
- mock_plug.last_state = "OFF"
- with (
- patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- # Set current time to 08:00
- mock_now = MagicMock()
- mock_now.strftime.return_value = "08:00"
- mock_datetime.now.return_value = mock_now
- mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
- # Set up async session mock
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [mock_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_on = AsyncMock(return_value=True)
- await manager._check_schedules()
- mock_tasmota.turn_on.assert_called_once_with(mock_plug)
- @pytest.mark.asyncio
- async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
- """Verify scheduled off-time turns plug off."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.name = "Test Plug"
- mock_plug.enabled = True
- mock_plug.schedule_enabled = True
- mock_plug.schedule_on_time = "08:00"
- mock_plug.schedule_off_time = "22:00"
- mock_plug.printer_id = 1
- mock_plug.last_state = "ON"
- with (
- patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- ):
- # Set current time to 22:00
- mock_now = MagicMock()
- mock_now.strftime.return_value = "22:00"
- mock_datetime.now.return_value = mock_now
- mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
- # Set up async session mock
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [mock_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_off = AsyncMock(return_value=True)
- mock_pm.mark_printer_offline = MagicMock()
- await manager._check_schedules()
- mock_tasmota.turn_off.assert_called_once_with(mock_plug)
- @pytest.mark.asyncio
- async def test_check_schedules_skipped_when_disabled(self, manager):
- """Verify schedule is skipped when schedule_enabled is False."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.enabled = True
- mock_plug.schedule_enabled = False # Disabled
- with (
- patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_now = MagicMock()
- mock_now.strftime.return_value = "08:00"
- mock_datetime.now.return_value = mock_now
- # Set up async session mock - returns no plugs (filtered by schedule_enabled)
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = []
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_on = AsyncMock()
- await manager._check_schedules()
- mock_tasmota.turn_on.assert_not_called()
- class TestPendingAutoOffPersistence:
- """Tests for auto-off pending state persistence (restart recovery)."""
- @pytest.fixture
- def manager(self):
- return SmartPlugManager()
- @pytest.mark.asyncio
- async def test_resume_pending_auto_offs_temperature_mode(self, manager):
- """Verify temperature-based pending auto-offs are resumed on startup."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.name = "Test Plug"
- mock_plug.ip_address = "192.168.1.100"
- mock_plug.username = None
- mock_plug.password = None
- mock_plug.printer_id = 1
- mock_plug.auto_off_pending = True
- mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
- mock_plug.off_delay_mode = "temperature"
- mock_plug.off_temp_threshold = 70
- with (
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
- ):
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [mock_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- await manager.resume_pending_auto_offs()
- mock_schedule.assert_called_once_with(mock_plug, 1, 70)
- @pytest.mark.asyncio
- async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
- """Verify time-based pending auto-offs turn off immediately on resume."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.name = "Test Plug"
- mock_plug.ip_address = "192.168.1.100"
- mock_plug.username = None
- mock_plug.password = None
- mock_plug.printer_id = 1
- mock_plug.auto_off_pending = True
- mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
- mock_plug.off_delay_mode = "time"
- with (
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- ):
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [mock_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_off = AsyncMock(return_value=True)
- mock_pm.is_print_active.return_value = False # printer idle on restart
- await manager.resume_pending_auto_offs()
- mock_tasmota.turn_off.assert_called_once()
- mock_mark.assert_called_once_with(1)
- @pytest.mark.asyncio
- async def test_resume_pending_auto_off_skipped_when_printing(self, manager):
- """#1890: on restart, a stale pending off must NOT power off a live print;
- the pending flag is cleared instead."""
- mock_plug = MagicMock()
- mock_plug.id = 1
- mock_plug.name = "Test Plug"
- mock_plug.printer_id = 1
- mock_plug.auto_off_pending = True
- mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
- mock_plug.off_delay_mode = "time"
- with (
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch.object(manager, "_schedule_temp_based_off") as mock_temp,
- ):
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [mock_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_off = AsyncMock(return_value=True)
- mock_pm.is_print_active.return_value = True # printer printing again on restart
- mock_pm.get_status.return_value = MagicMock(state="RUNNING")
- await manager.resume_pending_auto_offs()
- mock_tasmota.turn_off.assert_not_called() # never cut power on the live print
- mock_temp.assert_not_called()
- assert mock_plug.auto_off_pending is False # stale pending cleared
- class TestActivePrintGuard:
- """#1890 — auto-off must never cut power while a print is loaded/running.
- Covers the two off-executors (`_delayed_off`, `_temp_based_off`), the new
- queue-override scheduler that honours per-plug settings, and the
- on_print_start cancellation gap.
- """
- @pytest.fixture
- def manager(self):
- return SmartPlugManager()
- @pytest.fixture
- def mock_plug(self):
- plug = MagicMock()
- plug.id = 1
- plug.name = "Test Plug"
- plug.ip_address = "192.168.1.100"
- plug.username = None
- plug.password = None
- plug.enabled = True
- plug.auto_on = True
- plug.auto_off = True
- plug.off_delay_mode = "time"
- plug.off_delay_minutes = 5
- plug.off_temp_threshold = 70
- plug.printer_id = 1
- plug.plug_type = "tasmota"
- plug.ha_entity_id = None
- return plug
- # ---- _delayed_off (time mode) ----------------------------------------
- @pytest.mark.asyncio
- async def test_delayed_off_skips_when_printer_printing_again(self, manager):
- """Time-delay fires after N min; if a reprint is running, skip the off."""
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc,
- patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock) as mock_mark_pending,
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark_exec,
- ):
- mock_pm.is_print_active.return_value = True
- mock_pm.get_status.return_value = MagicMock(state="RUNNING")
- await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0)
- mock_get_svc.assert_not_called() # never even resolved a service to turn off
- mock_mark_exec.assert_not_called()
- mock_mark_pending.assert_awaited_with(1, False) # pending flag cleared
- @pytest.mark.asyncio
- async def test_delayed_off_powers_off_when_idle(self, manager):
- """When the printer is genuinely idle, the delayed off still fires."""
- mock_service = AsyncMock()
- mock_service.turn_off = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
- ):
- mock_pm.is_print_active.return_value = False
- await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0)
- mock_service.turn_off.assert_awaited_once()
- mock_pm.mark_printer_offline.assert_called_once_with(1)
- # ---- _temp_based_off (temperature mode) ------------------------------
- @pytest.mark.asyncio
- async def test_temp_based_off_defers_while_printing_even_if_cool(self, manager):
- """Nozzle can dip below threshold during a reprint's PREPARE/heat phase;
- the guard must defer rather than cut power on the loaded print."""
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc,
- ):
- # Cool enough to trip the threshold, but a print is active.
- mock_pm.get_status.return_value = MagicMock(state="PREPARE", temperatures={"nozzle": 30})
- mock_pm.is_print_active.return_value = True
- # Break the poll loop after the first deferral so the test terminates.
- mock_sleep.side_effect = asyncio.CancelledError()
- await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=70)
- mock_get_svc.assert_not_called() # never turned off despite temp < threshold
- @pytest.mark.asyncio
- async def test_temp_based_off_powers_off_when_cool_and_idle(self, manager):
- """Cool nozzle + idle printer → turn off using the plug's threshold."""
- mock_service = AsyncMock()
- mock_service.turn_off = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
- ):
- mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
- mock_pm.is_print_active.return_value = False
- await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=55)
- mock_service.turn_off.assert_awaited_once()
- # ---- schedule_off_after_queue_job (uses plug settings, not hardcoded 50/600)
- @pytest.mark.asyncio
- async def test_queue_off_uses_time_mode_regardless_of_global_auto_off(self, manager, mock_plug):
- """Queue 'auto off after this job' is a per-job override — it schedules
- even when the plug's global auto_off is disabled, and honours the plug's
- configured time-delay mode."""
- mock_plug.auto_off = False
- mock_plug.off_delay_mode = "time"
- mock_plug.off_delay_minutes = 8
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
- patch.object(manager, "_schedule_delayed_off") as mock_delayed,
- patch.object(manager, "_schedule_temp_based_off") as mock_temp,
- ):
- await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
- mock_delayed.assert_called_once_with(mock_plug, 1, 8 * 60) # plug's minutes, not hardcoded
- mock_temp.assert_not_called()
- @pytest.mark.asyncio
- async def test_queue_off_uses_configured_temp_threshold(self, manager, mock_plug):
- """Temperature mode passes the plug's off_temp_threshold, not a hardcoded 50."""
- mock_plug.off_delay_mode = "temperature"
- mock_plug.off_temp_threshold = 65
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
- patch.object(manager, "_schedule_delayed_off") as mock_delayed,
- patch.object(manager, "_schedule_temp_based_off") as mock_temp,
- ):
- await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
- mock_temp.assert_called_once_with(mock_plug, 1, 65)
- mock_delayed.assert_not_called()
- @pytest.mark.asyncio
- async def test_queue_off_skips_disabled_and_ha_script_plugs(self, manager, mock_plug):
- """Disabled plugs and HA-script entities are never scheduled."""
- disabled = MagicMock(id=2, name="disabled", enabled=False, plug_type="tasmota", ha_entity_id=None)
- ha_script = MagicMock(
- id=3, name="ha", enabled=True, plug_type="homeassistant", ha_entity_id="script.printer_off"
- )
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[disabled, ha_script]),
- patch.object(manager, "_schedule_off_per_mode") as mock_sched,
- ):
- await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
- mock_sched.assert_not_called()
- # ---- on_print_start cancellation gap ---------------------------------
- @pytest.mark.asyncio
- async def test_reprint_cancels_pending_off_even_when_auto_on_disabled(self, manager, mock_plug):
- """A reprint must abort a scheduled auto-off regardless of auto_on (#1890).
- Previously the cancel lived behind the auto_on gate, so a plug with
- auto_on disabled kept its pending off and cut power mid-reprint.
- """
- mock_plug.auto_on = False
- mock_task = MagicMock()
- manager._pending_off[mock_plug.id] = mock_task
- with (
- patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
- patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- ):
- mock_tasmota.turn_on = AsyncMock()
- await manager.on_print_start(printer_id=1, db=AsyncMock())
- mock_task.cancel.assert_called_once() # cancelled despite auto_on=False
- assert mock_plug.id not in manager._pending_off
- mock_tasmota.turn_on.assert_not_called() # but not powered on
- class TestAccessoryPlugDoesNotMarkPrinterOffline:
- """#2629 — a plug linked to a printer is not necessarily its power supply.
- Filter fans, chamber lights and enclosure heaters are linked so they follow
- the print cycle. Marking the printer offline when one of those switches off
- blanks the printer state and stalls the queue until a manual Force Refresh.
- """
- @pytest.fixture
- def manager(self):
- return SmartPlugManager()
- @pytest.fixture
- def accessory_plug(self):
- plug = MagicMock()
- plug.id = 1
- plug.name = "BentoBox Filter"
- plug.ip_address = "192.168.1.100"
- plug.username = None
- plug.password = None
- plug.enabled = True
- plug.auto_off = True
- plug.off_delay_mode = "time"
- plug.off_delay_minutes = 1
- plug.off_temp_threshold = 70
- plug.printer_id = 1
- plug.plug_type = "tasmota"
- plug.ha_entity_id = None
- plug.controls_printer_power = False
- return plug
- @pytest.mark.asyncio
- async def test_delayed_off_skips_offline_mark_for_accessory(self, manager):
- mock_service = AsyncMock()
- mock_service.turn_off = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
- ):
- mock_pm.is_print_active.return_value = False
- await manager._delayed_off(
- 1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, controls_printer_power=False
- )
- mock_service.turn_off.assert_awaited_once() # the plug still switches off
- mock_pm.mark_printer_offline.assert_not_called() # but the printer is untouched
- @pytest.mark.asyncio
- async def test_temp_based_off_skips_offline_mark_for_accessory(self, manager):
- mock_service = AsyncMock()
- mock_service.turn_off = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
- patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
- ):
- mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
- mock_pm.is_print_active.return_value = False
- await manager._temp_based_off(
- 1,
- "tasmota",
- "1.2.3.4",
- None,
- None,
- None,
- printer_id=1,
- temp_threshold=55,
- controls_printer_power=False,
- )
- mock_service.turn_off.assert_awaited_once()
- mock_pm.mark_printer_offline.assert_not_called()
- @pytest.mark.asyncio
- async def test_schedulers_forward_the_flag(self, manager, accessory_plug):
- """The flag lives on the plug row; both schedulers must pass it into the
- detached task, which only receives primitives."""
- with (
- patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
- patch.object(manager, "_delayed_off", new_callable=AsyncMock) as mock_delayed,
- patch.object(manager, "_temp_based_off", new_callable=AsyncMock) as mock_temp,
- ):
- manager._schedule_delayed_off(accessory_plug, 1, 60)
- manager._schedule_temp_based_off(accessory_plug, 1, 70)
- assert mock_delayed.call_args.kwargs["controls_printer_power"] is False
- assert mock_temp.call_args.kwargs["controls_printer_power"] is False
- @pytest.mark.asyncio
- async def test_scheduled_off_skips_offline_mark_for_accessory(self, manager, accessory_plug):
- """The time-of-day schedule path has its own turn-off + offline mark."""
- accessory_plug.schedule_enabled = True
- accessory_plug.schedule_on_time = None
- accessory_plug.schedule_off_time = "22:00"
- with (
- patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- ):
- mock_now = MagicMock()
- mock_now.strftime.return_value = "22:00"
- mock_datetime.now.return_value = mock_now
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [accessory_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_off = AsyncMock(return_value=True)
- await manager._check_schedules()
- mock_tasmota.turn_off.assert_awaited_once_with(accessory_plug)
- mock_pm.mark_printer_offline.assert_not_called()
- @pytest.mark.asyncio
- async def test_scheduled_off_still_marks_offline_for_power_plug(self, manager, accessory_plug):
- """Default (a plug that really feeds the printer) keeps the old behaviour."""
- accessory_plug.controls_printer_power = True
- accessory_plug.schedule_enabled = True
- accessory_plug.schedule_on_time = None
- accessory_plug.schedule_off_time = "22:00"
- with (
- patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
- patch("backend.app.core.database.async_session") as mock_session_ctx,
- patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
- patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
- ):
- mock_now = MagicMock()
- mock_now.strftime.return_value = "22:00"
- mock_datetime.now.return_value = mock_now
- mock_db = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = [accessory_plug]
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.commit = AsyncMock()
- mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.return_value.__aexit__ = AsyncMock()
- mock_tasmota.turn_off = AsyncMock(return_value=True)
- await manager._check_schedules()
- mock_pm.mark_printer_offline.assert_called_once_with(1)
|