1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Jewish Calendar coordinator (#152434)

This commit is contained in:
Tsvi Mostovicz
2025-12-08 23:41:58 +02:00
committed by GitHub
parent c0365dfe99
commit c1227aaf1f
11 changed files with 437 additions and 243 deletions

View File

@@ -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(

View File

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

View File

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

View File

@@ -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),
}

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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]

View File

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

View File

@@ -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': "<class 'datetime.date'>",
'isoformat': '2025-05-19',
}),
'zmanim': dict({
'candle_lighting_offset': 40,
'date': dict({
'__type': "<class 'datetime.date'>",
'isoformat': '2025-05-19',
}),
'havdalah_offset': 0,
'location': dict({
'altitude': '**REDACTED**',
'diaspora': False,
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
'name': 'test home',
'timezone': dict({
'__type': "<class 'zoneinfo.ZoneInfo'>",
'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': "<class 'zoneinfo.ZoneInfo'>",
'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': "<class 'datetime.date'>",
'isoformat': '2025-05-19',
}),
'zmanim': dict({
'candle_lighting_offset': 18,
'date': dict({
'__type': "<class 'datetime.date'>",
'isoformat': '2025-05-19',
}),
'havdalah_offset': 0,
'location': dict({
'altitude': '**REDACTED**',
'diaspora': True,
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
'name': 'test home',
'timezone': dict({
'__type': "<class 'zoneinfo.ZoneInfo'>",
'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': "<class 'zoneinfo.ZoneInfo'>",
'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': "<class 'datetime.date'>",
'isoformat': '2025-05-19',
}),
'zmanim': dict({
'candle_lighting_offset': 18,
'date': dict({
'__type': "<class 'datetime.date'>",
'isoformat': '2025-05-19',
}),
'havdalah_offset': 0,
'location': dict({
'altitude': '**REDACTED**',
'diaspora': False,
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
'name': 'test home',
'timezone': dict({
'__type': "<class 'zoneinfo.ZoneInfo'>",
'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': "<class 'zoneinfo.ZoneInfo'>",
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
}),
}),
}),

View File

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

View File

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