Some checks failed
Lint & Test / test (push) Failing after 9s
Auto-fixed 138 unused imports and f-string issues. Manually fixed: ambiguous variable names (l→layer), availability-check imports using importlib.util.find_spec, unused Color import, ImagePool forward ref via TYPE_CHECKING, multi-statement semicolons, and E402 suppression.
289 lines
9.2 KiB
Python
289 lines
9.2 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_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
|