"""Tests for AutomationEngine — rule evaluation in isolation.""" from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from ledgrab.core.automations.automation_engine import AutomationEngine from ledgrab.storage.automation import ( ApplicationRule, Automation, DisplayStateRule, StartupRule, SystemIdleRule, TimeOfDayRule, WebhookRule, ) from ledgrab.storage.automation_store import AutomationStore # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_store(tmp_db) -> AutomationStore: return AutomationStore(tmp_db) @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("ledgrab.core.automations.automation_engine.PlatformDetector"): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, ) return eng # --------------------------------------------------------------------------- # Rule evaluation (unit-level) # --------------------------------------------------------------------------- class TestRuleEvaluation: """Test _evaluate_rule for each rule type individually.""" def _make_automation(self, rules): now = datetime.now(timezone.utc) return Automation( id="test_auto", name="Test", enabled=True, rule_logic="or", rules=rules, scene_preset_id=None, deactivation_mode="none", deactivation_scene_preset_id=None, created_at=now, updated_at=now, ) def _eval(self, engine, rule, **kwargs): """Invoke the private _evaluate_rule 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_rule( rule, defaults["running_procs"], defaults["topmost_proc"], defaults["topmost_fullscreen"], defaults["fullscreen_procs"], defaults["idle_seconds"], defaults["display_state"], ) def test_startup_true(self, engine): assert self._eval(engine, StartupRule()) is True def test_application_running_match(self, engine): rule = ApplicationRule(apps=["chrome.exe"], match_type="running") result = self._eval( engine, rule, running_procs={"chrome.exe", "explorer.exe"}, ) assert result is True def test_application_running_no_match(self, engine): rule = ApplicationRule(apps=["chrome.exe"], match_type="running") result = self._eval( engine, rule, running_procs={"explorer.exe"}, ) assert result is False def test_application_topmost_match(self, engine): rule = ApplicationRule(apps=["game.exe"], match_type="topmost") result = self._eval( engine, rule, topmost_proc="game.exe", ) assert result is True def test_application_topmost_no_match(self, engine): rule = ApplicationRule(apps=["game.exe"], match_type="topmost") result = self._eval( engine, rule, topmost_proc="chrome.exe", ) assert result is False def test_time_of_day_within_range(self, engine): rule = TimeOfDayRule(start_time="00:00", end_time="23:59") result = self._eval(engine, rule) assert result is True def test_system_idle_when_idle(self, engine): rule = SystemIdleRule(idle_minutes=5, when_idle=True) result = self._eval(engine, rule, idle_seconds=600.0) # 10 minutes idle assert result is True def test_system_idle_not_idle(self, engine): rule = SystemIdleRule(idle_minutes=5, when_idle=True) result = self._eval(engine, rule, 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.""" rule = SystemIdleRule(idle_minutes=5, when_idle=False) result = self._eval(engine, rule, idle_seconds=60.0) # 1 min idle (not yet 5) assert result is True def test_display_state_match(self, engine): rule = DisplayStateRule(state="on") result = self._eval(engine, rule, display_state="on") assert result is True def test_display_state_no_match(self, engine): rule = DisplayStateRule(state="off") result = self._eval(engine, rule, display_state="on") assert result is False def test_webhook_active(self, engine): rule = WebhookRule(token="tok123") engine._webhook_states["tok123"] = True result = self._eval(engine, rule) assert result is True def test_webhook_inactive(self, engine): rule = WebhookRule(token="tok123") # Not in _webhook_states → False result = self._eval(engine, rule) assert result is False # --------------------------------------------------------------------------- # Rule logic (AND / OR) # --------------------------------------------------------------------------- class TestRuleLogic: def _make_automation(self, rules, logic="or"): now = datetime.now(timezone.utc) return Automation( id="logic_auto", name="Logic", enabled=True, rule_logic=logic, rules=rules, 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( [ ApplicationRule(apps=["missing.exe"], match_type="running"), StartupRule(), ], logic="or", ) result = engine._evaluate_rules( 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( [ StartupRule(), ApplicationRule(apps=["missing.exe"], match_type="running"), ], logic="and", ) result = engine._evaluate_rules( 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 # --------------------------------------------------------------------------- # HTTP poll rule — operator helpers + integration with mocked value stream # --------------------------------------------------------------------------- class TestApplyOperator: """Unit tests for the rule-comparison operator dispatch.""" def test_operator_equals(self): from ledgrab.core.automations.automation_engine import _apply_operator assert _apply_operator("equals", "ok", "ok") is True assert _apply_operator("equals", 42, "42") is True # str coercion assert _apply_operator("equals", "ok", "fail") is False def test_operator_contains(self): from ledgrab.core.automations.automation_engine import _apply_operator assert _apply_operator("contains", "hello world", "world") is True assert _apply_operator("contains", "hello", "xyz") is False def test_operator_regex(self): from ledgrab.core.automations.automation_engine import _apply_operator assert _apply_operator("regex", "abc123", r"\d+") is True assert _apply_operator("regex", "abc", r"\d+") is False assert _apply_operator("regex", "abc", "[") is False # invalid regex def test_operator_gt_lt(self): from ledgrab.core.automations.automation_engine import _apply_operator assert _apply_operator("gt", 5, "3") is True assert _apply_operator("gt", 2, "3") is False assert _apply_operator("lt", 2, "3") is True assert _apply_operator("gt", "not-a-number", "3") is False def test_operator_not_equals(self): from ledgrab.core.automations.automation_engine import _apply_operator assert _apply_operator("not_equals", "a", "b") is True assert _apply_operator("not_equals", "a", "a") is False class TestHTTPPollRuleEvaluation: """_evaluate_http_poll behavior with a mocked ValueStreamManager. Rule now points at an HTTPValueSource via ``value_source_id``; the engine reads the cached raw value from the corresponding ValueStream and compares with ``_apply_operator``. """ def _engine_with_vsm(self, mock_store, mock_manager, vsm): with patch("ledgrab.core.automations.automation_engine.PlatformDetector"): return AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, value_stream_manager=vsm, ) def _vsm_with_stream(self, vs_id: str, raw_value): """Build a mock ValueStreamManager whose ``peek(vs_id)`` returns a stream whose ``get_raw_value()`` yields *raw_value*.""" vsm = MagicMock() stream = MagicMock() stream.get_raw_value.return_value = raw_value vsm.peek.side_effect = lambda q: stream if q == vs_id else None return vsm def test_no_manager_returns_false(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule with patch("ledgrab.core.automations.automation_engine.PlatformDetector"): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, ) rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok") assert eng._evaluate_http_poll(rule) is False def test_no_value_source_id_returns_false(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = MagicMock() vsm.peek.return_value = None eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="", operator="equals", value="ok") assert eng._evaluate_http_poll(rule) is False def test_stream_missing_returns_false(self, mock_store, mock_manager): """If the value stream hasn't been acquired yet, the rule is False.""" from ledgrab.storage.automation import HTTPPollRule vsm = MagicMock() vsm.peek.return_value = None # nothing acquired eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok") assert eng._evaluate_http_poll(rule) is False def test_raw_value_none_returns_false(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = self._vsm_with_stream("vs_x", None) eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok") assert eng._evaluate_http_poll(rule) is False def test_equals_match(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = self._vsm_with_stream("vs_x", "playing") eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="playing") assert eng._evaluate_http_poll(rule) is True def test_exists_when_value_present(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = self._vsm_with_stream("vs_x", 1) eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="exists") assert eng._evaluate_http_poll(rule) is True def test_exists_when_value_none(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = self._vsm_with_stream("vs_x", None) eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="exists") assert eng._evaluate_http_poll(rule) is False def test_gt_numeric(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = self._vsm_with_stream("vs_x", 72.5) eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="gt", value="70") assert eng._evaluate_http_poll(rule) is True def test_stream_without_raw_value_attr_returns_false(self, mock_store, mock_manager): """A non-HTTP stream type that has no ``get_raw_value`` short-circuits.""" from ledgrab.storage.automation import HTTPPollRule plain_stream = object() # no get_raw_value attribute vsm = MagicMock() vsm.peek.return_value = plain_stream eng = self._engine_with_vsm(mock_store, mock_manager, vsm) rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok") assert eng._evaluate_http_poll(rule) is False class TestSyncValueStreamRefs: """Engine acquire/release diffing against the ValueStreamManager.""" def test_no_manager_is_noop(self, mock_store, mock_manager): with patch("ledgrab.core.automations.automation_engine.PlatformDetector"): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, ) eng._sync_value_stream_refs() # should not raise def test_acquires_referenced_sources(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = MagicMock() with patch("ledgrab.core.automations.automation_engine.PlatformDetector"): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, value_stream_manager=vsm, ) mock_store.create_automation( name="poll1", enabled=True, rule_logic="or", rules=[ HTTPPollRule( value_source_id="vs_abc", operator="equals", value="ok", ), ], scene_preset_id=None, deactivation_mode="none", deactivation_scene_preset_id=None, ) eng._sync_value_stream_refs() vsm.acquire.assert_called_once_with("vs_abc") assert eng._value_sources_acquired == {"vs_abc"} def test_releases_unreferenced_sources(self, mock_store, mock_manager): """When a rule is removed, the engine releases its formerly-held source.""" vsm = MagicMock() with patch("ledgrab.core.automations.automation_engine.PlatformDetector"): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, value_stream_manager=vsm, ) eng._value_sources_acquired = {"vs_old"} eng._sync_value_stream_refs() # no rules reference it vsm.release.assert_called_once_with("vs_old") assert eng._value_sources_acquired == set() def test_disabled_automation_ignored(self, mock_store, mock_manager): from ledgrab.storage.automation import HTTPPollRule vsm = MagicMock() with patch("ledgrab.core.automations.automation_engine.PlatformDetector"): eng = AutomationEngine( automation_store=mock_store, processor_manager=mock_manager, poll_interval=0.1, value_stream_manager=vsm, ) mock_store.create_automation( name="off", enabled=False, rule_logic="or", rules=[ HTTPPollRule( value_source_id="vs_xyz", operator="equals", value="", ), ], scene_preset_id=None, deactivation_mode="none", deactivation_scene_preset_id=None, ) eng._sync_value_stream_refs() vsm.acquire.assert_not_called() class TestHTTPValueStreamExtraction: """Unit tests for the dot-path extractor used by HTTPValueStream.""" def test_empty_path_returns_body_text(self): from ledgrab.core.processing.value_stream import _extract_simple_path assert _extract_simple_path(None, "", "raw body") == "raw body" def test_simple_key(self): from ledgrab.core.processing.value_stream import _extract_simple_path assert _extract_simple_path({"status": "ok"}, "status", "") == "ok" def test_nested_with_array_index(self): from ledgrab.core.processing.value_stream import _extract_simple_path body = {"MediaContainer": {"size": 2, "Metadata": [{"title": "Show"}]}} assert _extract_simple_path(body, "MediaContainer.size", "") == 2 assert _extract_simple_path(body, "MediaContainer.Metadata[0].title", "") == "Show" def test_chained_indices(self): from ledgrab.core.processing.value_stream import _extract_simple_path body = {"matrix": [[1, 2, 3], [4, 5, 6]]} assert _extract_simple_path(body, "matrix[0][1]", "") == 2 assert _extract_simple_path(body, "matrix[1][2]", "") == 6 def test_missing_returns_none(self): from ledgrab.core.processing.value_stream import _extract_simple_path assert _extract_simple_path({"a": 1}, "b", "") is None assert _extract_simple_path({"a": [1]}, "a[5]", "") is None