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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user