From c1227aaf1fdc91bf517d4f6eeed041b9d45da408 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Mon, 8 Dec 2025 23:41:58 +0200 Subject: [PATCH] Jewish Calendar coordinator (#152434) --- .../components/jewish_calendar/__init__.py | 17 +- .../jewish_calendar/binary_sensor.py | 3 +- .../components/jewish_calendar/coordinator.py | 105 ++++++++++ .../components/jewish_calendar/diagnostics.py | 2 +- .../components/jewish_calendar/entity.py | 72 ++----- .../components/jewish_calendar/sensor.py | 38 ++-- tests/components/jewish_calendar/__init__.py | 18 ++ tests/components/jewish_calendar/conftest.py | 54 +++++- .../snapshots/test_diagnostics.ambr | 150 +++++++-------- .../jewish_calendar/test_binary_sensor.py | 180 ++++++++++++------ .../components/jewish_calendar/test_sensor.py | 41 ++-- 11 files changed, 437 insertions(+), 243 deletions(-) create mode 100644 homeassistant/components/jewish_calendar/coordinator.py diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 8e01b6b6ae0..34189d4ab09 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -29,7 +29,8 @@ from .const import ( DEFAULT_LANGUAGE, DOMAIN, ) -from .entity import JewishCalendarConfigEntry, JewishCalendarData +from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator +from .entity import JewishCalendarConfigEntry from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -69,7 +70,7 @@ async def async_setup_entry( ) ) - config_entry.runtime_data = JewishCalendarData( + data = JewishCalendarData( language, diaspora, location, @@ -77,8 +78,11 @@ async def async_setup_entry( havdalah_offset, ) - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data) + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -86,7 +90,12 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + coordinator = config_entry.runtime_data + await coordinator.async_shutdown() + return unload_ok async def async_migrate_entry( diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index d5097df962f..205691bc183 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -72,8 +72,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - zmanim = self.make_zmanim(dt.date.today()) - return self.entity_description.is_on(zmanim)(dt_util.now()) + return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now()) def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: """Return a list of times to update the sensor.""" diff --git a/homeassistant/components/jewish_calendar/coordinator.py b/homeassistant/components/jewish_calendar/coordinator.py new file mode 100644 index 00000000000..673395cbb1a --- /dev/null +++ b/homeassistant/components/jewish_calendar/coordinator.py @@ -0,0 +1,105 @@ +"""Data update coordinator for Jewish calendar.""" + +from dataclasses import dataclass +import datetime as dt +import logging + +from hdate import HDateInfo, Location, Zmanim +from hdate.translator import Language, set_language + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import event +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator] + + +@dataclass(frozen=True) +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: Language + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int + dateinfo: HDateInfo | None = None + zmanim: Zmanim | None = None + + +class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]): + """Data update coordinator class for Jewish calendar.""" + + config_entry: JewishCalendarConfigEntry + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config_entry: JewishCalendarConfigEntry, + data: JewishCalendarData, + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry) + self.data = data + set_language(data.language) + + async def _async_update_data(self) -> JewishCalendarData: + """Return HDate and Zmanim for today.""" + now = dt_util.now() + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + today = now.date() + + # Create new data object with today's information + new_data = JewishCalendarData( + language=self.data.language, + diaspora=self.data.diaspora, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + dateinfo=HDateInfo(today, self.data.diaspora), + zmanim=self.make_zmanim(today), + ) + + # Schedule next update at midnight + next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) + _LOGGER.debug("Scheduling next update at %s", next_midnight) + + # Schedule update at next midnight + self._unsub_refresh = event.async_track_point_in_time( + self.hass, self._handle_midnight_update, next_midnight + ) + + return new_data + + @callback + def _handle_midnight_update(self, _now: dt.datetime) -> None: + """Handle midnight update callback.""" + self.hass.async_create_task(self.async_request_refresh()) + + def make_zmanim(self, date: dt.date) -> Zmanim: + """Create a Zmanim object.""" + return Zmanim( + date=date, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + ) + + @property + def zmanim(self) -> Zmanim: + """Return the current Zmanim.""" + assert self.data.zmanim is not None, "Zmanim data not available" + return self.data.zmanim + + @property + def dateinfo(self) -> HDateInfo: + """Return the current HDateInfo.""" + assert self.data.dateinfo is not None, "HDateInfo data not available" + return self.data.dateinfo diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py index 27415282b6d..f2db0786b12 100644 --- a/homeassistant/components/jewish_calendar/diagnostics.py +++ b/homeassistant/components/jewish_calendar/diagnostics.py @@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics( return { "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT), + "data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT), } diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index d5e41129075..b9fff1d0f50 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,48 +1,22 @@ """Entity representing a Jewish Calendar sensor.""" from abc import abstractmethod -from dataclasses import dataclass import datetime as dt -import logging -from hdate import HDateInfo, Location, Zmanim -from hdate.translator import Language, set_language +from hdate import Zmanim -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] +from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator -@dataclass -class JewishCalendarDataResults: - """Jewish Calendar results dataclass.""" - - dateinfo: HDateInfo - zmanim: Zmanim - - -@dataclass -class JewishCalendarData: - """Jewish Calendar runtime dataclass.""" - - language: Language - diaspora: bool - location: Location - candle_lighting_offset: int - havdalah_offset: int - results: JewishCalendarDataResults | None = None - - -class JewishCalendarEntity(Entity): +class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True @@ -55,23 +29,13 @@ class JewishCalendarEntity(Entity): description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" + super().__init__(config_entry.runtime_data) self.entity_description = description self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, ) - self.data = config_entry.runtime_data - set_language(self.data.language) - - def make_zmanim(self, date: dt.date) -> Zmanim: - """Create a Zmanim object.""" - return Zmanim( - date=date, - location=self.data.location, - candle_lighting_offset=self.data.candle_lighting_offset, - havdalah_offset=self.data.havdalah_offset, - ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -85,6 +49,14 @@ class JewishCalendarEntity(Entity): self._update_unsub = None return await super().async_will_remove_from_hass() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # When coordinator updates (e.g., from tests forcing refresh or midnight update), + # reschedule our entity-specific updates + self._schedule_update() + super()._handle_coordinator_update() + @abstractmethod def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: """Return a list of times to update the sensor.""" @@ -92,10 +64,9 @@ class JewishCalendarEntity(Entity): def _schedule_update(self) -> None: """Schedule the next update of the sensor.""" now = dt_util.now() - zmanim = self.make_zmanim(now.date()) update = dt_util.start_of_local_day() + dt.timedelta(days=1) - for update_time in self._update_times(zmanim): + for update_time in self._update_times(self.coordinator.zmanim): if update_time is not None and now < update_time < update: update = update_time @@ -110,17 +81,4 @@ class JewishCalendarEntity(Entity): """Update the sensor data.""" self._update_unsub = None self._schedule_update() - self.create_results(now) self.async_write_ha_state() - - def create_results(self, now: dt.datetime | None = None) -> None: - """Create the results for the sensor.""" - if now is None: - now = dt_util.now() - - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - zmanim = self.make_zmanim(today) - dateinfo = HDateInfo(today, diaspora=self.data.diaspora) - self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 0c63ea21161..8670bae5371 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util +import homeassistant.util.dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @@ -238,25 +238,18 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): return [] return [self.entity_description.next_update_fn(zmanim)] - def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: + def get_dateinfo(self) -> HDateInfo: """Get the next date info.""" - if self.data.results is None: - self.create_results() - assert self.data.results is not None, "Results should be available" - - if now is None: - now = dt_util.now() - - today = now.date() - zmanim = self.make_zmanim(today) + now = dt_util.now() update = None - if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(zmanim) - _LOGGER.debug("Today: %s, update: %s", today, update) + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(self.coordinator.zmanim) + + _LOGGER.debug("Today: %s, update: %s", now.date(), update) if update is not None and now >= update: - return self.data.results.dateinfo.next_day - return self.data.results.dateinfo + return self.coordinator.dateinfo.next_day + return self.coordinator.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -273,7 +266,9 @@ class JewishCalendarSensor(JewishCalendarBaseSensor): super().__init__(config_entry, description) # Set the options for enumeration sensors if self.entity_description.options_fn is not None: - self._attr_options = self.entity_description.options_fn(self.data.diaspora) + self._attr_options = self.entity_description.options_fn( + self.coordinator.data.diaspora + ) @property def native_value(self) -> str | int | dt.datetime | None: @@ -297,9 +292,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @property def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" - if self.data.results is None: - self.create_results() - assert self.data.results is not None, "Results should be available" if self.entity_description.value_fn is None: - return self.data.results.zmanim.zmanim[self.entity_description.key].local - return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) + return self.coordinator.zmanim.zmanim[self.entity_description.key].local + return self.entity_description.value_fn( + self.get_dateinfo(), self.coordinator.make_zmanim + ) diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index d6928c189e8..f69d6c3657e 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -1 +1,19 @@ """Tests for the jewish_calendar component.""" + +from dataclasses import dataclass +import datetime as dt + + +@dataclass(frozen=True) +class TimeValue: + """Single test case.""" + + time: dt.datetime + expected: str | int | bool | list | dict | None + + +@dataclass(frozen=True) +class TimeValueSequence: + """Sequence of test cases.""" + + cases: list[TimeValue] diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 568affb9ab6..35094e67422 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -1,11 +1,14 @@ """Common fixtures for the jewish_calendar tests.""" +from __future__ import annotations + from collections.abc import AsyncGenerator, Generator, Iterable import datetime as dt -from typing import NamedTuple +from typing import Any, NamedTuple from unittest.mock import AsyncMock, patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from hdate.translator import set_language import pytest @@ -20,7 +23,9 @@ from homeassistant.const import CONF_LANGUAGE, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from . import TimeValue + +from tests.common import MockConfigEntry, async_fire_time_changed class _LocationData(NamedTuple): @@ -161,3 +166,48 @@ async def setup_at_time( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() yield + + +@pytest.fixture +async def test_sequence( + request: pytest.FixtureRequest, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + tz_info: dt.tzinfo, +) -> None: + """Set up time sequence testing fixture. + + This fixture: + 1. Sets up the integration at the first time point + 2. Yields the expected state value for each time point + 3. Then moves through each time point, yielding the expected state + + The test should compare the yielded expected state with the actual entity state. + """ + # We expect a sequence of TimeStatePoint objects + if not hasattr(request, "param") or not request.param: + raise ValueError("time_sequence fixture requires parameters") + + sequence: list[TimeValue] = request.param.cases + + async def _time_sequence() -> AsyncGenerator[Any]: + # Setup at the initial time + with freeze_time(sequence[0].time.replace(tzinfo=tz_info)): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Yield the expected state + yield sequence[0].expected + + # Move through subsequent time points + for data_point in sequence[1:]: + freezer.move_to(data_point.time.replace(tzinfo=tz_info)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Yield the expected state + yield data_point.expected + + return _time_sequence diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 381002b1f8b..c4da836368e 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -3,6 +3,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 40, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -17,33 +26,22 @@ 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), - 'results': dict({ - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'zmanim': dict({ - 'candle_lighting_offset': 40, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), }), @@ -59,6 +57,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': True, + 'nusach': 'sephardi', + }), 'diaspora': True, 'havdalah_offset': 0, 'language': 'en', @@ -73,33 +80,22 @@ 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), - 'results': dict({ - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': True, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), }), @@ -115,6 +111,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -129,33 +134,22 @@ 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), - 'results': dict({ - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), }), diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index a4c9fd02be3..47bc968fff5 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -1,139 +1,209 @@ """The tests for the Jewish calendar binary sensors.""" -from datetime import datetime as dt, timedelta +from collections.abc import AsyncGenerator +from datetime import datetime as dt from typing import Any -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from tests.common import async_fire_time_changed +from . import TimeValue, TimeValueSequence -MELACHA_PARAMS = [ +# Test sequences for issur melacha (forbidden work) binary sensor +MELACHA_TEST_SEQUENCES = [ + # New York scenarios pytest.param( "New York", - dt(2018, 9, 1, 16, 0), - {"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 1, 16, 0), STATE_ON), + TimeValue(dt(2018, 9, 1, 20, 14), STATE_OFF), + ] + ), id="currently_first_shabbat", ), pytest.param( "New York", - dt(2018, 9, 1, 20, 21), - {"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 1, 20, 21), STATE_OFF), + TimeValue(dt(2018, 9, 2, 6, 21), STATE_OFF), + ] + ), id="after_first_shabbat", ), pytest.param( "New York", - dt(2018, 9, 7, 13, 1), - {"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 7, 13, 1), STATE_OFF), + TimeValue(dt(2018, 9, 7, 19, 4), STATE_ON), + ] + ), id="friday_upcoming_shabbat", ), pytest.param( "New York", - dt(2018, 9, 8, 21, 25), - {"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 8, 21, 25), STATE_OFF), + TimeValue(dt(2018, 9, 9, 6, 27), STATE_OFF), + ] + ), id="upcoming_rosh_hashana", ), pytest.param( "New York", - dt(2018, 9, 9, 21, 25), - {"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 9, 21, 25), STATE_ON), + TimeValue(dt(2018, 9, 10, 6, 28), STATE_ON), + ] + ), id="currently_rosh_hashana", ), pytest.param( "New York", - dt(2018, 9, 10, 21, 25), - {"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 10, 21, 25), STATE_ON), + TimeValue(dt(2018, 9, 11, 6, 29), STATE_ON), + ] + ), id="second_day_rosh_hashana_night", ), pytest.param( "New York", - dt(2018, 9, 11, 11, 25), - {"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 11, 11, 25), STATE_ON), + TimeValue(dt(2018, 9, 11, 19, 57), STATE_OFF), + ] + ), id="second_day_rosh_hashana_day", ), pytest.param( "New York", - dt(2018, 9, 29, 16, 25), - {"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 29, 16, 25), STATE_ON), + TimeValue(dt(2018, 9, 29, 19, 25), STATE_OFF), + ] + ), id="currently_shabbat_chol_hamoed", ), pytest.param( "New York", - dt(2018, 9, 29, 21, 25), - {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 29, 21, 25), STATE_OFF), + TimeValue(dt(2018, 9, 30, 6, 48), STATE_OFF), + ] + ), id="upcoming_two_day_yomtov_in_diaspora", ), pytest.param( "New York", - dt(2018, 9, 30, 21, 25), - {"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 30, 21, 25), STATE_ON), + TimeValue(dt(2018, 10, 1, 6, 49), STATE_ON), + ] + ), id="currently_first_day_of_two_day_yomtov_in_diaspora", ), pytest.param( "New York", - dt(2018, 10, 1, 21, 25), - {"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON}, + TimeValueSequence( + [ + TimeValue(dt(2018, 10, 1, 21, 25), STATE_ON), + TimeValue(dt(2018, 10, 2, 6, 50), STATE_ON), + ] + ), id="currently_second_day_of_two_day_yomtov_in_diaspora", ), + # Jerusalem scenarios pytest.param( "Jerusalem", - dt(2018, 9, 29, 21, 25), - {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 9, 29, 21, 25), STATE_OFF), + TimeValue(dt(2018, 9, 30, 6, 29), STATE_OFF), + ] + ), id="upcoming_one_day_yom_tov_in_israel", ), pytest.param( "Jerusalem", - dt(2018, 10, 1, 11, 25), - {"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 10, 1, 11, 25), STATE_ON), + TimeValue(dt(2018, 10, 1, 19, 2), STATE_OFF), + ] + ), id="currently_one_day_yom_tov_in_israel", ), pytest.param( "Jerusalem", - dt(2018, 10, 1, 21, 25), - {"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF}, + TimeValueSequence( + [ + TimeValue(dt(2018, 10, 1, 21, 25), STATE_OFF), + TimeValue(dt(2018, 10, 2, 6, 31), STATE_OFF), + ] + ), id="after_one_day_yom_tov_in_israel", ), ] @pytest.mark.parametrize( - ("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True + ("location_data", "test_sequence"), MELACHA_TEST_SEQUENCES, indirect=True ) -@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] + hass: HomeAssistant, test_sequence: AsyncGenerator[Any] ) -> None: """Test Issur Melacha sensor output.""" sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" - assert hass.states.get(sensor_id).state == results["state"] - - freezer.move_to(results["update"]) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert hass.states.get(sensor_id).state == results["new_state"] + async for expected_state in test_sequence(): + current_state = hass.states.get(sensor_id).state + assert current_state == expected_state @pytest.mark.parametrize( - ("location_data", "test_time", "results"), + ("location_data", "test_sequence"), [ - ("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]), - ("New York", dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]), + pytest.param( + "New York", + TimeValueSequence( + [ + TimeValue(dt(2020, 10, 23, 17, 44, 59, 999999), STATE_OFF), + TimeValue(dt(2020, 10, 23, 17, 45, 0), STATE_ON), + TimeValue(dt(2020, 10, 24, 18, 42, 59), STATE_ON), + TimeValue(dt(2020, 10, 24, 18, 43, 0), STATE_OFF), + ] + ), + id="full_shabbat_cycle", + ), + pytest.param( + "New York", + TimeValueSequence( + [ + TimeValue(dt(2020, 10, 24, 18, 42, 59, 999999), STATE_ON), + TimeValue(dt(2020, 10, 24, 18, 43, 0), STATE_OFF), + ] + ), + id="havdalah_transition", + ), ], - ids=["before_candle_lighting", "before_havdalah"], indirect=True, ) -@pytest.mark.usefixtures("setup_at_time") -async def test_issur_melacha_sensor_update( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str] +async def test_issur_melacha_sensor_transitions( + hass: HomeAssistant, test_sequence: AsyncGenerator[Any] ) -> None: - """Test Issur Melacha sensor output.""" + """Test Issur Melacha sensor transitions at key times.""" sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" - assert hass.states.get(sensor_id).state == results[0] - - freezer.tick(timedelta(microseconds=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert hass.states.get(sensor_id).state == results[1] + async for expected_state in test_sequence(): + current_state = hass.states.get(sensor_id).state + assert current_state == expected_state diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 2d7ca34164f..bd045b25d58 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -1,9 +1,9 @@ """The tests for the Jewish calendar sensors.""" +from collections.abc import AsyncGenerator from datetime import datetime as dt from typing import Any -from freezegun.api import FrozenDateTimeFactory from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest @@ -11,7 +11,9 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from . import TimeValue, TimeValueSequence + +from tests.common import MockConfigEntry @pytest.mark.parametrize("language", ["en", "he"]) @@ -542,28 +544,23 @@ async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None: @pytest.mark.parametrize( - ("test_time", "results"), + "test_sequence", [ - ( - dt(2025, 6, 10, 17), - { - "initial_state": "14 Sivan 5785", - "move_to": dt(2025, 6, 10, 23, 0), - "new_state": "15 Sivan 5785", - }, - ), + TimeValueSequence( + [ + TimeValue(dt(2025, 6, 10, 17), "14 Sivan 5785"), # Initial time + TimeValue(dt(2025, 6, 10, 23, 0), "15 Sivan 5785"), # Later in the day + TimeValue(dt(2025, 6, 11, 9, 0), "15 Sivan 5785"), # Next morning + TimeValue(dt(2025, 6, 11, 22, 0), "16 Sivan 5785"), # Next evening + ] + ) ], indirect=True, ) -@pytest.mark.usefixtures("setup_at_time") -async def test_sensor_does_not_update_on_time_change( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] +async def test_sensor_date_changes_with_time( + hass: HomeAssistant, test_sequence: AsyncGenerator[Any] ) -> None: - """Test that the Jewish calendar sensor does not update after time advances (regression test for update bug).""" - sensor_id = "sensor.jewish_calendar_date" - assert hass.states.get(sensor_id).state == results["initial_state"] - - freezer.move_to(results["move_to"]) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert hass.states.get(sensor_id).state == results["new_state"] + """Test that the Jewish calendar date sensor updates when time crosses date boundaries.""" + async for expected_state in test_sequence(): + current_state = hass.states.get("sensor.jewish_calendar_date").state + assert current_state == expected_state