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:
@@ -7,6 +7,7 @@ shape, and self-referential exclusion.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -95,7 +96,7 @@ class TestNoSecretLeakage:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
assert len(persisted) >= 1, "Expected at least one auth record"
|
||||
for entry in persisted:
|
||||
@@ -121,7 +122,7 @@ class TestNoSecretLeakage:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, None)
|
||||
asyncio.run(verify_api_key(req, None))
|
||||
|
||||
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
||||
assert len(warnings) >= 1
|
||||
@@ -152,7 +153,7 @@ class TestNoSecretLeakage:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
||||
assert len(warnings) >= 1
|
||||
@@ -284,7 +285,7 @@ class TestNoSecretLeakage:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
for entry in persisted:
|
||||
for v in entry.metadata.values():
|
||||
@@ -367,7 +368,7 @@ class TestBestEffortResilience:
|
||||
|
||||
# Should raise the HTTP 401, not the recorder RuntimeError
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
verify_api_key(req, None) # missing creds
|
||||
asyncio.run(verify_api_key(req, None)) # missing creds
|
||||
|
||||
# Must be an HTTPException (401), NOT the RuntimeError from the recorder
|
||||
assert "RuntimeError" not in type(exc_info.value).__name__
|
||||
@@ -734,7 +735,7 @@ class TestNoDuplicateRecords:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
||||
assert len(rejected) == 1, f"Expected exactly 1 auth.rejected record, got {len(rejected)}"
|
||||
@@ -768,7 +769,7 @@ class TestMetadataShape:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
||||
assert len(rejected) >= 1
|
||||
@@ -800,7 +801,7 @@ class TestMetadataShape:
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
||||
assert rejected[0].metadata["client"] == _EXPECTED_IP
|
||||
@@ -1041,7 +1042,7 @@ class TestCategorySeverityContract:
|
||||
cfg.auth.api_keys = {"dev": "good"}
|
||||
mock_cfg.return_value = cfg
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
self._check_all_entries(persisted)
|
||||
auth_records = [e for e in persisted if e.category == "auth"]
|
||||
@@ -1384,7 +1385,7 @@ class TestAuthFailureThrottle:
|
||||
cfg.auth.api_keys = {"dev": "correct-key"}
|
||||
mock_cfg.return_value = cfg
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
return persisted_ref
|
||||
|
||||
@@ -1414,7 +1415,7 @@ class TestAuthFailureThrottle:
|
||||
cfg.auth.api_keys = {"dev": "real-key"}
|
||||
mock_cfg.return_value = cfg
|
||||
try:
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
except Exception:
|
||||
exceptions_raised += 1
|
||||
|
||||
@@ -1444,7 +1445,7 @@ class TestAuthFailureThrottle:
|
||||
cfg.auth.api_keys = {"dev": "right"}
|
||||
mock_cfg.return_value = cfg
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
|
||||
assert len(rejected) == len(
|
||||
@@ -1477,7 +1478,7 @@ class TestAuthFailureThrottle:
|
||||
cfg.auth.api_keys = {"dev": "correct"}
|
||||
mock_cfg.return_value = cfg
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
asyncio.run(verify_api_key(req, creds))
|
||||
|
||||
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
|
||||
assert (
|
||||
|
||||
Reference in New Issue
Block a user