|
| 1 | +import itertools |
| 2 | +import re |
1 | 3 | from datetime import date, datetime, timedelta |
2 | 4 | from functools import partial |
| 5 | +from typing import Literal |
3 | 6 |
|
4 | 7 | import pytest |
5 | | -from appdaemon import utils |
6 | 8 | from astral import SunDirection |
7 | 9 | from astral.location import Location |
8 | 10 | from pytz import BaseTzInfo |
9 | 11 |
|
| 12 | +from appdaemon import utils |
| 13 | + |
10 | 14 | pytestmark = [ |
11 | 15 | pytest.mark.ci, |
12 | 16 | pytest.mark.unit, |
13 | 17 | ] |
14 | 18 |
|
| 19 | +hour_params = { |
| 20 | + "input_": [f"{i:02}:00" for i in range(0, 24, 6)], |
| 21 | + "aware": [True, False], |
| 22 | + "today": [True, False] |
| 23 | +} |
| 24 | +hour_params = (tuple(hour_params.keys()), list(itertools.product(*hour_params.values()))) |
| 25 | + |
| 26 | + |
| 27 | +offsets = [str(utils.parse_timedelta(t)) for t in [1, timedelta(minutes=30), timedelta(hours=1)]] |
| 28 | +src = { |
| 29 | + "str_offset": offsets, |
| 30 | + "sign": [True, False], |
| 31 | + "today": [True, False], |
| 32 | + "aware": [True, False], |
| 33 | +} |
| 34 | +sunrise_params = list(itertools.product(*src.values())) |
| 35 | +sunrise_params = ( |
| 36 | + (f"sunrise {'+' if sign else '-'} {offset}", today, aware) |
| 37 | + for offset, sign, today, aware in sunrise_params |
| 38 | +) |
| 39 | +sunrise_params = (("input_", "today", "aware"), list(sunrise_params)) |
| 40 | + |
| 41 | +offsets = [timedelta(seconds=1), timedelta(minutes=1), timedelta(hours=1.5)] |
| 42 | +offsets = [td.total_seconds() for td in offsets] |
| 43 | +offsets = [(n * s) for n, s in itertools.product(offsets, [1, -1])] |
| 44 | +offsets = sorted(offsets) |
| 45 | + |
| 46 | +sun_params = itertools.product(["sunrise", "sunset"], offsets) |
| 47 | +sun_params = { |
| 48 | + "now_str": ["early", "midday", "late"], |
| 49 | + "when": ["today", "next"], |
| 50 | + "input_": [f'{type_} {'+' if str_offset > 0 else '-'} {utils.parse_timedelta(abs(str_offset))}' for type_, str_offset in sun_params], |
| 51 | +} |
| 52 | + |
| 53 | +class TestParseDatetime: |
| 54 | + @pytest.mark.parametrize(*hour_params) |
| 55 | + def test_parse_hour( |
| 56 | + self, |
| 57 | + input_: str, |
| 58 | + aware: bool, |
| 59 | + today: bool, |
| 60 | + default_now: datetime, |
| 61 | + parser: partial[datetime], |
| 62 | + ) -> None: |
| 63 | + result = parser(input_, aware=aware, today=today) |
| 64 | + if default_now.time() > result.time(): |
| 65 | + if not today: |
| 66 | + assert result.date() == (default_now + timedelta(days=1)).date() |
| 67 | + return |
| 68 | + |
| 69 | + assert result.date() == default_now.date() |
| 70 | + |
| 71 | + @pytest.mark.parametrize(tuple(sun_params.keys()), itertools.product(*sun_params.values())) |
| 72 | + def test_parse_sun_offsets( |
| 73 | + self, |
| 74 | + now_str: str, |
| 75 | + input_: str, |
| 76 | + when: Literal["today", "next"], |
| 77 | + default_now: datetime, |
| 78 | + location: Location, |
| 79 | + parser: partial[datetime], |
| 80 | + ) -> None: |
| 81 | + today_sunrise = location.sunrise(date=default_now.date(), local=True) |
| 82 | + assert today_sunrise.isoformat() == "2025-06-20T05:25:07.925165-04:00" |
| 83 | + |
| 84 | + tomorrow_sunrise = location.sunrise(date=(default_now + timedelta(days=1)).date(), local=True) |
| 85 | + assert tomorrow_sunrise.isoformat() == "2025-06-21T05:25:20.585440-04:00" |
| 86 | + |
| 87 | + today_sunset = location.sunset(date=default_now.date(), local=True) |
| 88 | + assert today_sunset.isoformat() == "2025-06-20T20:30:19.662056-04:00" |
| 89 | + |
| 90 | + tomorrow_sunset = location.sunset(date=(default_now + timedelta(days=1)).date(), local=True) |
| 91 | + assert tomorrow_sunset.isoformat() == "2025-06-21T20:30:31.933561-04:00" |
| 92 | + |
| 93 | + match now_str: |
| 94 | + case "early": |
| 95 | + now = default_now.replace(hour=3) |
| 96 | + case "midday": |
| 97 | + now = default_now.replace(hour=12) |
| 98 | + case "late": |
| 99 | + now = default_now.replace(hour=23) |
| 100 | + |
| 101 | + parser.keywords["now"] = now |
| 102 | + |
| 103 | + if when == "today": |
| 104 | + parser.keywords["today"] = True |
| 105 | + |
| 106 | + result = parser(input_, location=location, aware=True) |
| 107 | + assert result.tzinfo is not None |
| 108 | + |
| 109 | + type_ = input_.split()[0] |
| 110 | + offset = utils.parse_offset(input_) |
| 111 | + |
| 112 | + match now_str, when, type_: |
| 113 | + case (_, "today", "sunrise"): |
| 114 | + assert result == (today_sunrise + offset) |
| 115 | + case (_, "today", "sunset"): |
| 116 | + assert result == (today_sunset + offset) |
| 117 | + |
| 118 | + case ("early", _, "sunrise"): |
| 119 | + assert result == (today_sunrise + offset) |
| 120 | + case ("midday" | "late", _, "sunrise"): |
| 121 | + assert result == (tomorrow_sunrise + offset) |
| 122 | + |
| 123 | + case ("early" | "midday", _, "sunset"): |
| 124 | + assert result == (today_sunset + offset) |
| 125 | + case ("late", "next", "sunset"): |
| 126 | + assert result == (tomorrow_sunset + offset) |
| 127 | + |
| 128 | + case _: |
| 129 | + # This makes sure all the cases get handled. |
| 130 | + assert False |
| 131 | + |
| 132 | + match when: |
| 133 | + case "today": |
| 134 | + assert result.date() == now.date() |
| 135 | + case "next": |
| 136 | + assert result > now |
| 137 | + |
15 | 138 |
|
16 | 139 | def test_time_parse(default_now: datetime, parser: partial[datetime]) -> None: |
17 | 140 | test_time = default_now.replace(hour=20) |
@@ -210,3 +333,5 @@ def test_exact_sun_event(default_date: date, location: Location, tz: BaseTzInfo) |
210 | 333 | today_sunset = location.sunset(date=default_date, local=True) |
211 | 334 | next_sunset = parser("sunset", now=today_sunset) |
212 | 335 | assert next_sunset.date() != default_date, "Next sunset should be tomorrow" |
| 336 | + assert next_sunset.date() != default_date, "Next sunset should be tomorrow" |
| 337 | + assert next_sunset.date() != default_date, "Next sunset should be tomorrow" |
0 commit comments