feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical (backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests: - automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared with the daylight cycle; window logic mirrors TimeOfDayRule) - ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest (release.yml; amd64 path untouched, continue-on-error) - game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown) - ui: color-harmony gradient generator (complementary/analogous/triadic/...) - effects: audio-reactive palette modulation (new audio_energy_tap; brightness/ saturation modulation across all 12 procedural effects) - capture: linear-light blending + spatio-temporal dithering, opt-in per calibration (new utils/linear_light.py, utils/dither.py) - devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode) Also bundles the pending 2026-06-18 production-review fixes and other in-progress work already in the working tree (manual-trigger rule, etc.), since they share files and could not be cleanly separated. Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing test (automation manual_trigger handler coverage) is a separate in-progress item owned elsewhere, intentionally left as-is.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Tests for AutomationEngine — rule evaluation in isolation."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -10,6 +10,7 @@ from ledgrab.storage.automation import (
|
||||
ApplicationRule,
|
||||
Automation,
|
||||
DisplayStateRule,
|
||||
ManualTriggerRule,
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
@@ -552,6 +553,130 @@ class TestHTTPValueStreamExtraction:
|
||||
assert _extract_simple_path(body, "MediaContainer.size", "") == 2
|
||||
assert _extract_simple_path(body, "MediaContainer.Metadata[0].title", "") == "Show"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manual trigger — one-shot apply gated by the automation's rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestManualTrigger:
|
||||
"""fire_manual_trigger: evaluate rules with the manual term True, then
|
||||
apply the scene once (without entering the sticky active state)."""
|
||||
|
||||
def _make(self, rules, *, enabled=True, logic="or", scene=None, aid="auto_manual"):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id=aid,
|
||||
name="Manual",
|
||||
enabled=enabled,
|
||||
rule_logic=logic,
|
||||
rules=rules,
|
||||
scene_preset_id=scene,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def test_handle_manual_inert_by_default(self, engine):
|
||||
# Outside a manual fire the rule reads False, so a manual-only
|
||||
# automation never activates from the background tick.
|
||||
assert engine._handle_manual(ManualTriggerRule(), None) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fires_no_scene_one_shot(self, engine):
|
||||
auto = self._make([ManualTriggerRule()])
|
||||
status, errors = await engine.fire_manual_trigger(auto)
|
||||
assert status == "triggered"
|
||||
assert errors == []
|
||||
# One-shot: records last_activated but does NOT enter the sticky state
|
||||
# (so the background tick has nothing to reconcile away → no bounce).
|
||||
assert auto.id not in engine._active_automations
|
||||
assert auto.id in engine._last_activated
|
||||
# The transient flag is always cleared after the evaluation.
|
||||
assert engine._manual_fire_active is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skipped_when_and_companion_false(self, engine):
|
||||
# manual AND an unset webhook → AND fails → nothing applied.
|
||||
auto = self._make([ManualTriggerRule(), WebhookRule(token="nope")], logic="and")
|
||||
status, errors = await engine.fire_manual_trigger(auto)
|
||||
assert status == "skipped"
|
||||
assert errors == []
|
||||
assert auto.id not in engine._last_activated
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fires_or_when_companion_false(self, engine):
|
||||
# manual OR an unset webhook → manual alone satisfies "or".
|
||||
auto = self._make([ManualTriggerRule(), WebhookRule(token="nope")], logic="or")
|
||||
status, _errors = await engine.fire_manual_trigger(auto)
|
||||
assert status == "triggered"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_works_when_disabled(self, engine):
|
||||
# enabled gates only the background loop; a manual trigger ignores it.
|
||||
auto = self._make([ManualTriggerRule()], enabled=False)
|
||||
status, _errors = await engine.fire_manual_trigger(auto)
|
||||
assert status == "triggered"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_only_automation_inert_in_background_tick(self, engine, mock_store):
|
||||
created = mock_store.create_automation(
|
||||
name="manual-bg",
|
||||
enabled=True,
|
||||
rule_logic="or",
|
||||
rules=[ManualTriggerRule()],
|
||||
scene_preset_id=None,
|
||||
)
|
||||
await engine.trigger_evaluate()
|
||||
assert created.id not in engine._active_automations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audits_with_user_actor(self, engine):
|
||||
rec = MagicMock()
|
||||
with patch("ledgrab.core.activity_log.recorder.get_module_recorder", return_value=rec):
|
||||
await engine.fire_manual_trigger(self._make([ManualTriggerRule()]))
|
||||
rec.record.assert_called_once()
|
||||
kwargs = rec.record.call_args.kwargs
|
||||
assert kwargs["action"] == "automation.triggered"
|
||||
# No explicit actor → recorder resolves the current user (not "system").
|
||||
assert "actor" not in kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_applies_scene_activated_maps_to_triggered(self, engine):
|
||||
engine._scene_preset_store = MagicMock()
|
||||
preset = MagicMock()
|
||||
preset.name = "Scene"
|
||||
engine._scene_preset_store.get_preset.return_value = preset
|
||||
engine._target_store = MagicMock()
|
||||
engine._device_store = MagicMock()
|
||||
auto = self._make([ManualTriggerRule()], scene="scene_x")
|
||||
with patch(
|
||||
"ledgrab.core.scenes.scene_activator.apply_scene_state",
|
||||
new=AsyncMock(return_value=("activated", [])),
|
||||
) as apply_mock:
|
||||
status, errors = await engine.fire_manual_trigger(auto)
|
||||
apply_mock.assert_awaited_once()
|
||||
assert status == "triggered"
|
||||
assert errors == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_applies_scene_partial_passthrough(self, engine):
|
||||
engine._scene_preset_store = MagicMock()
|
||||
preset = MagicMock()
|
||||
preset.name = "Scene"
|
||||
engine._scene_preset_store.get_preset.return_value = preset
|
||||
engine._target_store = MagicMock()
|
||||
engine._device_store = MagicMock()
|
||||
auto = self._make([ManualTriggerRule()], scene="scene_x")
|
||||
with patch(
|
||||
"ledgrab.core.scenes.scene_activator.apply_scene_state",
|
||||
new=AsyncMock(return_value=("partial", ["dev1: timeout"])),
|
||||
):
|
||||
status, errors = await engine.fire_manual_trigger(auto)
|
||||
assert status == "partial"
|
||||
assert errors == ["dev1: timeout"]
|
||||
|
||||
def test_chained_indices(self):
|
||||
from ledgrab.core.processing.value_stream import _extract_simple_path
|
||||
|
||||
|
||||
Reference in New Issue
Block a user