"""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"