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 == []
|
||||
Reference in New Issue
Block a user