Files
ledgrab/server/tests/test_solar_rule.py
T
alexei.dolgolyov 6745e25b20 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.
2026-06-22 23:21:24 +03:00

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"