6745e25b20
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.
189 lines
7.5 KiB
Python
189 lines
7.5 KiB
Python
"""Tests for SolarRule (sunrise/sunset automation trigger).
|
|
|
|
Covers the data model (round-trip + input validation), the engine's
|
|
``_evaluate_solar`` window logic (sunrise/sunset patched to fixed hours so the
|
|
test is location/clock-independent), the shared solar math, and the API
|
|
schema ↔ Rule mapping.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import patch
|
|
|
|
from ledgrab.api.routes.automations import _rule_from_schema, _rule_to_schema
|
|
from ledgrab.api.schemas.automations import RuleSchema
|
|
from ledgrab.core.automations import automation_engine
|
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
|
from ledgrab.storage.automation import Rule, SolarRule
|
|
from ledgrab.utils.solar import compute_solar_times
|
|
|
|
# Fixed solar times the engine math is patched to: sunrise 06:00, sunset 19:00.
|
|
_SUNRISE_MIN = 6 * 60 # 360
|
|
_SUNSET_MIN = 19 * 60 # 1140
|
|
|
|
|
|
# ── Data model ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSolarRuleModel:
|
|
def test_defaults_are_night_window(self):
|
|
r = SolarRule()
|
|
assert r.rule_type == "solar"
|
|
assert r.start_event == "sunset"
|
|
assert r.end_event == "sunrise"
|
|
assert r.start_offset_minutes == 0 and r.end_offset_minutes == 0
|
|
|
|
def test_to_dict_from_dict_round_trip(self):
|
|
r = SolarRule(
|
|
start_event="sunrise",
|
|
start_offset_minutes=-30,
|
|
end_event="sunset",
|
|
end_offset_minutes=45,
|
|
latitude=51.5,
|
|
longitude=-0.12,
|
|
days_of_week=[5, 6],
|
|
timezone="Europe/London",
|
|
)
|
|
back = SolarRule.from_dict(r.to_dict())
|
|
assert back == r
|
|
|
|
def test_from_dict_dispatches_via_base_rule(self):
|
|
d = {"rule_type": "solar", "start_event": "sunrise"}
|
|
r = Rule.from_dict(d)
|
|
assert isinstance(r, SolarRule)
|
|
assert r.start_event == "sunrise"
|
|
|
|
def test_invalid_event_falls_back_to_default(self):
|
|
r = SolarRule.from_dict({"start_event": "noon", "end_event": ""})
|
|
assert r.start_event == "sunset"
|
|
assert r.end_event == "sunrise"
|
|
|
|
def test_offsets_are_clamped(self):
|
|
r = SolarRule.from_dict({"start_offset_minutes": 9000, "end_offset_minutes": -9000})
|
|
assert r.start_offset_minutes == 1439
|
|
assert r.end_offset_minutes == -1439
|
|
|
|
def test_offsets_handle_non_numeric(self):
|
|
r = SolarRule.from_dict({"start_offset_minutes": None, "end_offset_minutes": "x"})
|
|
assert r.start_offset_minutes == 0 and r.end_offset_minutes == 0
|
|
|
|
def test_coords_are_clamped(self):
|
|
r = SolarRule.from_dict({"latitude": 200.0, "longitude": -400.0})
|
|
assert r.latitude == 90.0
|
|
assert r.longitude == -180.0
|
|
|
|
def test_days_of_week_filtered_and_sorted(self):
|
|
r = SolarRule.from_dict({"days_of_week": [6, 0, 9, -1, "x", 3, 3]})
|
|
assert r.days_of_week == [0, 3, 6]
|
|
|
|
|
|
# ── Engine evaluation ───────────────────────────────────────────────────
|
|
|
|
|
|
def _eval_at(dt: datetime, **rule_kwargs) -> bool:
|
|
rule = SolarRule.from_dict(rule_kwargs)
|
|
with (
|
|
patch.object(automation_engine, "compute_solar_times", return_value=(6.0, 19.0)),
|
|
patch.object(automation_engine, "_now_in_tz", return_value=dt),
|
|
):
|
|
return AutomationEngine._evaluate_solar(rule)
|
|
|
|
|
|
class TestSolarEvaluation:
|
|
def test_night_window_active_after_sunset(self):
|
|
# default sunset→sunrise; 22:00 is inside the evening portion
|
|
assert _eval_at(datetime(2026, 6, 15, 22, 0)) is True
|
|
|
|
def test_night_window_active_before_sunrise(self):
|
|
# 03:00 is inside the after-midnight tail
|
|
assert _eval_at(datetime(2026, 6, 15, 3, 0)) is True
|
|
|
|
def test_night_window_inactive_at_noon(self):
|
|
assert _eval_at(datetime(2026, 6, 15, 12, 0)) is False
|
|
|
|
def test_day_window_active_at_noon(self):
|
|
assert (
|
|
_eval_at(datetime(2026, 6, 15, 12, 0), start_event="sunrise", end_event="sunset")
|
|
is True
|
|
)
|
|
|
|
def test_day_window_inactive_at_night(self):
|
|
assert (
|
|
_eval_at(datetime(2026, 6, 15, 23, 0), start_event="sunrise", end_event="sunset")
|
|
is False
|
|
)
|
|
|
|
def test_start_offset_shifts_boundary_earlier(self):
|
|
# sunset-30 = 18:30; the window opens earlier
|
|
assert _eval_at(datetime(2026, 6, 15, 18, 45), start_offset_minutes=-30) is True
|
|
assert _eval_at(datetime(2026, 6, 15, 18, 15), start_offset_minutes=-30) is False
|
|
|
|
def test_weekday_restriction_evening(self):
|
|
dt = datetime(2026, 6, 15, 22, 0) # evening portion → today's weekday
|
|
assert _eval_at(dt, days_of_week=[dt.weekday()]) is True
|
|
assert _eval_at(dt, days_of_week=[(dt.weekday() + 1) % 7]) is False
|
|
|
|
def test_weekday_restriction_overnight_tail_uses_previous_day(self):
|
|
dt = datetime(2026, 6, 15, 3, 0) # after-midnight tail → previous weekday
|
|
assert _eval_at(dt, days_of_week=[(dt.weekday() - 1) % 7]) is True
|
|
assert _eval_at(dt, days_of_week=[dt.weekday()]) is False
|
|
|
|
|
|
# ── Shared solar math ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestComputeSolarTimes:
|
|
def test_midlatitude_summer_has_sane_window(self):
|
|
sunrise, sunset = compute_solar_times(50.0, 0.0, 172, 0.0) # ~solstice
|
|
assert 0.5 <= sunrise <= 11.5
|
|
assert 12.5 <= sunset <= 23.5
|
|
assert sunrise < sunset
|
|
|
|
def test_polar_winter_is_clamped_not_degenerate(self):
|
|
# High latitude, mid-winter: raw equations collapse, but the clamp
|
|
# keeps sunrise < sunset so the trigger never sees an empty window.
|
|
sunrise, sunset = compute_solar_times(80.0, 0.0, 355, 0.0)
|
|
assert sunrise <= 11.5
|
|
assert sunset >= 12.5
|
|
assert sunrise < sunset
|
|
|
|
|
|
# ── API schema mapping ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestSolarSchemaMapping:
|
|
def test_schema_to_rule_and_back_preserves_fields(self):
|
|
schema = RuleSchema(
|
|
rule_type="solar",
|
|
start_event="sunrise",
|
|
start_offset_minutes=-15,
|
|
end_event="sunset",
|
|
end_offset_minutes=15,
|
|
latitude=51.5,
|
|
longitude=-0.1,
|
|
days_of_week=[5, 6],
|
|
timezone="Europe/London",
|
|
)
|
|
rule = _rule_from_schema(schema)
|
|
assert isinstance(rule, SolarRule)
|
|
assert rule.start_event == "sunrise"
|
|
assert rule.end_offset_minutes == 15
|
|
assert rule.latitude == 51.5
|
|
|
|
# Round-trip back to schema must NOT drop the solar fields.
|
|
out = _rule_to_schema(rule)
|
|
assert out.start_event == "sunrise"
|
|
assert out.end_event == "sunset"
|
|
assert out.start_offset_minutes == -15
|
|
assert out.latitude == 51.5
|
|
assert out.timezone == "Europe/London"
|
|
|
|
def test_schema_to_rule_tolerates_missing_solar_fields(self):
|
|
# A bare solar schema (all optional fields None) must still build a
|
|
# valid default night-window rule.
|
|
rule = _rule_from_schema(RuleSchema(rule_type="solar"))
|
|
assert isinstance(rule, SolarRule)
|
|
assert rule.start_event == "sunset"
|
|
assert rule.end_event == "sunrise"
|