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
@@ -0,0 +1,102 @@
"""Tests for the manual-trigger automation route (POST /automations/{id}/trigger).
The AutomationEngine is replaced with a lightweight fake so the route layer is
tested without driving the real evaluation loop or scene application.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes.automations import router
from ledgrab.storage.automation import ManualTriggerRule
from ledgrab.storage.automation_store import AutomationStore
class FakeEngine:
"""Stand-in exposing only what the trigger route calls."""
def __init__(self, result=("triggered", [])):
self.result = result
self.calls = []
async def fire_manual_trigger(self, automation):
self.calls.append(automation.id)
return self.result
@pytest.fixture
def _route_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def automation_store(_route_db) -> AutomationStore:
store = AutomationStore(_route_db)
store.create_automation(
name="Manual one",
enabled=True,
rule_logic="or",
rules=[ManualTriggerRule()],
scene_preset_id=None,
)
return store
@pytest.fixture
def fake_engine():
return FakeEngine()
@pytest.fixture
def client(automation_store, fake_engine):
app = FastAPI()
app.include_router(router)
from ledgrab.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
app.dependency_overrides[deps.get_automation_store] = lambda: automation_store
app.dependency_overrides[deps.get_automation_engine] = lambda: fake_engine
# Routes may fire entity events through the processor manager; give it a stub.
deps._deps["processor_manager"] = None
return TestClient(app, raise_server_exceptions=False)
def _first_id(store: AutomationStore) -> str:
return store.get_all_automations()[0].id
class TestTriggerRoute:
def test_trigger_returns_status(self, client, automation_store, fake_engine):
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
assert resp.json() == {"status": "triggered", "errors": []}
assert fake_engine.calls == [aid]
def test_trigger_skipped(self, client, automation_store, fake_engine):
fake_engine.result = ("skipped", [])
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
assert resp.json()["status"] == "skipped"
def test_trigger_partial_errors(self, client, automation_store, fake_engine):
fake_engine.result = ("partial", ["dev1: timeout"])
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "partial"
assert body["errors"] == ["dev1: timeout"]
def test_trigger_unknown_id_404(self, client, fake_engine):
resp = client.post("/api/v1/automations/auto_ghost/trigger")
assert resp.status_code == 404
assert fake_engine.calls == []
@@ -161,10 +161,42 @@ class TestCreateIntegration:
description="My game",
tags=["fps"],
)
assert data["adapter_config"] == {"auth_token": "secret123"}
# The auth_token is a live shared secret and must NEVER be echoed back
# over the API — it is masked to "" in every response.
assert data["adapter_config"] == {"auth_token": ""}
assert data["description"] == "My game"
assert data["tags"] == ["fps"]
def test_update_with_blank_token_preserves_secret(self, client, game_store):
"""The API masks secrets, so the edit form re-submits a blank token for
an unchanged secret. The update must PRESERVE the stored secret rather
than overwrite it with the blank (otherwise a no-op edit wipes the key).
"""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"name": "Renamed", "adapter_config": {"auth_token": ""}},
)
assert resp.status_code == 200, resp.text
# The stored (decrypted) secret is unchanged despite the blank submit.
cfg = game_store.get_integration(gi_id)
assert cfg.adapter_config.get("auth_token") == "secret123"
def test_update_with_new_token_replaces_secret(self, client, game_store):
"""A non-empty token in the update is a deliberate change and is kept."""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"adapter_config": {"auth_token": "rotated456"}},
)
assert resp.status_code == 200, resp.text
assert game_store.get_integration(gi_id).adapter_config.get("auth_token") == "rotated456"
def test_create_duplicate_name(self, client):
_create_integration(client, name="Unique")
resp = client.post(