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:
2026-06-22 23:21:24 +03:00
parent 126d8f2449
commit 6745e25b20
91 changed files with 4390 additions and 540 deletions
@@ -132,8 +132,18 @@ def test_prune_by_max_entries(tmp_db):
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(10):
repo.record(_make_entry())
# Give each entry a distinct, increasing timestamp and capture insertion
# order so we can assert *which* five survive — not just the count. The
# engine settings→prune path must keep the NEWEST five (keeping the wrong
# half of an audit log would otherwise pass a count-only assertion).
from ledgrab.storage.activity_log import ActivityLogFilters
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
ids = []
for i in range(10):
e = _make_entry(ts=base + timedelta(hours=i))
ids.append(e.id)
repo.record(e)
assert repo.count() == 10
@@ -141,6 +151,10 @@ def test_prune_by_max_entries(tmp_db):
engine._prune()
assert repo.count() == 5
remaining = {r.id for r in repo.query(ActivityLogFilters(), limit=20)}
# Newest five (highest seq / latest ts) survive; oldest five are pruned.
assert all(sid in remaining for sid in ids[5:])
assert all(sid not in remaining for sid in ids[:5])
def test_prune_disabled_is_noop(tmp_db):
@@ -467,13 +467,18 @@ def test_activity_logged_event_payload_shape():
def test_entry_id_format():
"""Entry IDs must be 'al_' followed by 8 hex characters."""
"""Entry IDs must be 'al_' followed by the full 32-hex uuid4.
Widened from 8 hex (32 bits) to the full 128-bit uuid4 so a collision on the
UNIQUE id column — which the best-effort recorder would silently drop — is
astronomically unlikely even against the full retention window.
"""
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
entry_id = persisted[0].id
assert entry_id.startswith("al_"), f"id does not start with 'al_': {entry_id!r}"
suffix = entry_id[3:]
assert len(suffix) == 8, f"id suffix length is {len(suffix)}, expected 8: {entry_id!r}"
assert len(suffix) == 32, f"id suffix length is {len(suffix)}, expected 32: {entry_id!r}"
assert all(c in "0123456789abcdef" for c in suffix), f"id suffix is not hex: {suffix!r}"
+126 -1
View File
@@ -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
@@ -21,6 +21,7 @@ from ledgrab.storage.automation import (
HTTPPollRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -31,6 +32,7 @@ EXPECTED_RULE_TYPES = {
StartupRule,
ApplicationRule,
TimeOfDayRule,
SolarRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,