Some checks failed
Lint & Test / test (push) Failing after 1m33s
All store tests were passing file paths instead of Database objects after the JSON-to-SQLite migration. Updated fixtures to create temp Database instances, rewrote backup e2e tests for binary .db format, and fixed config tests for the simplified StorageConfig.
289 lines
9.1 KiB
Python
289 lines
9.1 KiB
Python
"""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_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(
|
|
"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
|