"""Tests for AutomationEngine — condition evaluation in isolation.""" from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.storage.automation import ( AlwaysCondition, ApplicationCondition, Automation, DisplayStateCondition, StartupCondition, SystemIdleCondition, TimeOfDayCondition, WebhookCondition, ) from wled_controller.storage.automation_store import AutomationStore # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_store(tmp_path) -> AutomationStore: return AutomationStore(str(tmp_path / "auto.json")) @pytest.fixture def mock_manager(): m = MagicMock() m.fire_event = MagicMock() return m @pytest.fixture def engine(mock_store, mock_manager) -> AutomationEngine: """Build an AutomationEngine with the PlatformDetector mocked out. PlatformDetector starts a Windows display-power listener thread that causes access violations in the test environment, so we replace it with a simple MagicMock. """ with patch( "wled_controller.core.automations.automation_engine.PlatformDetector" ): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, ) return eng # --------------------------------------------------------------------------- # Condition evaluation (unit-level) # --------------------------------------------------------------------------- class TestConditionEvaluation: """Test _evaluate_condition for each condition type individually.""" def _make_automation(self, conditions): now = datetime.now(timezone.utc) return Automation( id="test_auto", name="Test", enabled=True, condition_logic="or", conditions=conditions, scene_preset_id=None, deactivation_mode="none", deactivation_scene_preset_id=None, created_at=now, updated_at=now, ) def _eval(self, engine, condition, **kwargs): """Invoke the private _evaluate_condition method.""" defaults = dict( running_procs=set(), topmost_proc=None, topmost_fullscreen=False, fullscreen_procs=set(), idle_seconds=None, display_state=None, ) defaults.update(kwargs) return engine._evaluate_condition( condition, defaults["running_procs"], defaults["topmost_proc"], defaults["topmost_fullscreen"], defaults["fullscreen_procs"], defaults["idle_seconds"], defaults["display_state"], ) def test_always_true(self, engine): assert self._eval(engine, AlwaysCondition()) is True def test_startup_true(self, engine): assert self._eval(engine, StartupCondition()) is True def test_application_running_match(self, engine): cond = ApplicationCondition(apps=["chrome.exe"], match_type="running") result = self._eval( engine, cond, running_procs={"chrome.exe", "explorer.exe"}, ) assert result is True def test_application_running_no_match(self, engine): cond = ApplicationCondition(apps=["chrome.exe"], match_type="running") result = self._eval( engine, cond, running_procs={"explorer.exe"}, ) assert result is False def test_application_topmost_match(self, engine): cond = ApplicationCondition(apps=["game.exe"], match_type="topmost") result = self._eval( engine, cond, topmost_proc="game.exe", ) assert result is True def test_application_topmost_no_match(self, engine): cond = ApplicationCondition(apps=["game.exe"], match_type="topmost") result = self._eval( engine, cond, topmost_proc="chrome.exe", ) assert result is False def test_time_of_day_within_range(self, engine): cond = TimeOfDayCondition(start_time="00:00", end_time="23:59") result = self._eval(engine, cond) assert result is True def test_system_idle_when_idle(self, engine): cond = SystemIdleCondition(idle_minutes=5, when_idle=True) result = self._eval(engine, cond, idle_seconds=600.0) # 10 minutes idle assert result is True def test_system_idle_not_idle(self, engine): cond = SystemIdleCondition(idle_minutes=5, when_idle=True) result = self._eval(engine, cond, idle_seconds=60.0) # 1 minute idle assert result is False def test_system_idle_when_not_idle(self, engine): """when_idle=False means active when user is NOT idle.""" cond = SystemIdleCondition(idle_minutes=5, when_idle=False) result = self._eval(engine, cond, idle_seconds=60.0) # 1 min idle (not yet 5) assert result is True def test_display_state_match(self, engine): cond = DisplayStateCondition(state="on") result = self._eval(engine, cond, display_state="on") assert result is True def test_display_state_no_match(self, engine): cond = DisplayStateCondition(state="off") result = self._eval(engine, cond, display_state="on") assert result is False def test_webhook_active(self, engine): cond = WebhookCondition(token="tok123") engine._webhook_states["tok123"] = True result = self._eval(engine, cond) assert result is True def test_webhook_inactive(self, engine): cond = WebhookCondition(token="tok123") # Not in _webhook_states → False result = self._eval(engine, cond) assert result is False # --------------------------------------------------------------------------- # Condition logic (AND / OR) # --------------------------------------------------------------------------- class TestConditionLogic: def _make_automation(self, conditions, logic="or"): now = datetime.now(timezone.utc) return Automation( id="logic_auto", name="Logic", enabled=True, condition_logic=logic, conditions=conditions, scene_preset_id=None, deactivation_mode="none", deactivation_scene_preset_id=None, created_at=now, updated_at=now, ) def test_or_any_true(self, engine): auto = self._make_automation( [ ApplicationCondition(apps=["missing.exe"], match_type="running"), AlwaysCondition(), ], logic="or", ) result = engine._evaluate_conditions( auto, running_procs=set(), topmost_proc=None, topmost_fullscreen=False, fullscreen_procs=set(), idle_seconds=None, display_state=None, ) assert result is True def test_and_all_must_be_true(self, engine): auto = self._make_automation( [ AlwaysCondition(), ApplicationCondition(apps=["missing.exe"], match_type="running"), ], logic="and", ) result = engine._evaluate_conditions( auto, running_procs=set(), topmost_proc=None, topmost_fullscreen=False, fullscreen_procs=set(), idle_seconds=None, display_state=None, ) assert result is False # --------------------------------------------------------------------------- # Webhook state management # --------------------------------------------------------------------------- class TestWebhookState: @pytest.mark.asyncio async def test_set_webhook_state_activate(self, engine): await engine.set_webhook_state("tok_1", True) assert engine._webhook_states["tok_1"] is True @pytest.mark.asyncio async def test_set_webhook_state_deactivate(self, engine): engine._webhook_states["tok_1"] = True await engine.set_webhook_state("tok_1", False) assert engine._webhook_states["tok_1"] is False # --------------------------------------------------------------------------- # Start / Stop lifecycle # --------------------------------------------------------------------------- class TestEngineLifecycle: @pytest.mark.asyncio async def test_start_creates_task(self, engine): await engine.start() assert engine._task is not None await engine.stop() @pytest.mark.asyncio async def test_stop_cancels_task(self, engine): await engine.start() await engine.stop() assert engine._task is None @pytest.mark.asyncio async def test_double_start_is_safe(self, engine): await engine.start() await engine.start() # no-op await engine.stop() @pytest.mark.asyncio async def test_stop_without_start_is_safe(self, engine): await engine.stop() # no-op