diff --git a/custom_components/uk_bin_collection/__init__.py b/custom_components/uk_bin_collection/__init__.py index 71c5cdb25c..4901900e80 100644 --- a/custom_components/uk_bin_collection/__init__.py +++ b/custom_components/uk_bin_collection/__init__.py @@ -1,3 +1,5 @@ +# init.py + """The UK Bin Collection integration.""" import asyncio @@ -14,7 +16,7 @@ from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOG_PREFIX +from .const import DOMAIN, LOG_PREFIX, PLATFORMS from uk_bin_collection.uk_bin_collection.collect_data import UKBinCollectionApp _LOGGER = logging.getLogger(__name__) @@ -99,11 +101,11 @@ async def async_setup_entry( hass.data[DOMAIN][config_entry.entry_id] = {"coordinator": coordinator} _LOGGER.debug(f"{LOG_PREFIX} Coordinator stored in hass.data under entry_id={config_entry.entry_id}.") - # Forward the setup to the sensor platform without awaiting + # Forward the setup to all platforms (sensor and calendar) hass.async_create_task( - hass.config_entries.async_forward_entry_setups(config_entry, ["sensor"]) + hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) ) - _LOGGER.debug(f"{LOG_PREFIX} Setup forwarded to 'sensor' platform.") + _LOGGER.debug(f"{LOG_PREFIX} Setup forwarded to platforms: {PLATFORMS}.") return True @@ -116,11 +118,15 @@ async def async_unload_entry( unload_ok = await hass.config_entries.async_forward_entry_unload( config_entry, "sensor" ) + calendar_unload_ok = await hass.config_entries.async_forward_entry_unload( + config_entry, "calendar" + ) + unload_ok = unload_ok and calendar_unload_ok if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) _LOGGER.debug(f"{LOG_PREFIX} Unloaded and removed coordinator for entry_id={config_entry.entry_id}.") else: - _LOGGER.warning(f"{LOG_PREFIX} Failed to unload 'sensor' platform for entry_id={config_entry.entry_id}.") + _LOGGER.warning(f"{LOG_PREFIX} Failed to unload one or more platforms for entry_id={config_entry.entry_id}.") return unload_ok @@ -203,7 +209,6 @@ async def _async_update_data(self) -> dict: _LOGGER.exception(f"{LOG_PREFIX} Unexpected error: {exc}") raise UpdateFailed(f"Unexpected error: {exc}") from exc - @staticmethod def process_bin_data(data: dict) -> dict: """Process raw data to determine the next collection dates.""" diff --git a/custom_components/uk_bin_collection/calendar.py b/custom_components/uk_bin_collection/calendar.py new file mode 100644 index 0000000000..14dc904bcd --- /dev/null +++ b/custom_components/uk_bin_collection/calendar.py @@ -0,0 +1,137 @@ +"""Calendar platform support for UK Bin Collection Data.""" + +import logging +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOG_PREFIX +from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class UKBinCollectionCalendar(CoordinatorEntity, CalendarEntity): + """Calendar entity for UK Bin Collection Data.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + bin_type: str, + unique_id: str, + name: str, + ) -> None: + """Initialize the calendar entity.""" + super().__init__(coordinator) + self._bin_type = bin_type + self._unique_id = unique_id + self._name = name + self._attr_unique_id = unique_id + + # Optionally, set device_info if you have device grouping + self._attr_device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": f"{self._name} Device", + "manufacturer": "UK Bin Collection", + "model": "Bin Collection Calendar", + "sw_version": "1.0", + } + + @property + def name(self) -> str: + """Return the name of the calendar.""" + return self._name + + @property + def event(self) -> Optional[CalendarEvent]: + """Return the next collection event.""" + collection_date = self.coordinator.data.get(self._bin_type) + if not collection_date: + _LOGGER.debug(f"{LOG_PREFIX} No collection date available for '{self._bin_type}'.") + return None + + return self._create_calendar_event(collection_date) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> List[CalendarEvent]: + """Return all events within a specific time frame.""" + events: List[CalendarEvent] = [] + collection_date = self.coordinator.data.get(self._bin_type) + + if not collection_date: + return events + + if start_date.date() <= collection_date <= end_date.date(): + events.append(self._create_calendar_event(collection_date)) + + return events + + def _create_calendar_event(self, collection_date: datetime.date) -> CalendarEvent: + """Create a CalendarEvent for a given collection date.""" + return CalendarEvent( + summary=f"{self._bin_type} Collection", + start=collection_date, + end=collection_date + timedelta(days=1), + uid=f"{self.unique_id}_{collection_date.isoformat()}", + ) + + @property + def unique_id(self) -> str: + """Return a unique ID for the calendar.""" + return self._unique_id + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updates from the coordinator and refresh calendar state.""" + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up UK Bin Collection Calendar from a config entry.""" + _LOGGER.info(f"{LOG_PREFIX} Setting up UK Bin Collection Calendar platform.") + + # Retrieve the coordinator from hass.data + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + + # Create calendar entities + entities = [] + for bin_type in coordinator.data.keys(): + unique_id = calc_unique_calendar_id(config_entry.entry_id, bin_type) + name = f"{coordinator.name} {bin_type} Calendar" + entities.append( + UKBinCollectionCalendar( + coordinator=coordinator, + bin_type=bin_type, + unique_id=unique_id, + name=name, + ) + ) + + # Register all calendar entities with Home Assistant + async_add_entities(entities) + _LOGGER.debug(f"{LOG_PREFIX} Calendar entities added: {[entity.name for entity in entities]}") + + +async def async_unload_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_remove_entities: Any, +) -> bool: + """Unload a config entry.""" + # Unloading is handled in init.py + return True + + +def calc_unique_calendar_id(entry_id: str, bin_type: str) -> str: + """Calculate a unique ID for the calendar.""" + return f"{entry_id}_{bin_type}_calendar" diff --git a/custom_components/uk_bin_collection/const.py b/custom_components/uk_bin_collection/const.py index 58c8274968..75f9327728 100644 --- a/custom_components/uk_bin_collection/const.py +++ b/custom_components/uk_bin_collection/const.py @@ -18,6 +18,8 @@ DEVICE_CLASS = "bin_collection_schedule" +PLATFORMS = ["sensor", "calendar"] + SELENIUM_SERVER_URLS = ["http://localhost:4444", "http://selenium:4444"] BROWSER_BINARIES = ["chromium", "chromium-browser", "google-chrome"] \ No newline at end of file diff --git a/custom_components/uk_bin_collection/manifest.json b/custom_components/uk_bin_collection/manifest.json index cc98c17467..61d586e88a 100644 --- a/custom_components/uk_bin_collection/manifest.json +++ b/custom_components/uk_bin_collection/manifest.json @@ -11,5 +11,6 @@ "issue_tracker": "https://github.com/robbrad/UKBinCollectionData/issues", "requirements": ["uk-bin-collection>=0.112.1"], "version": "0.112.1", - "zeroconf": [] + "zeroconf": [], + "platforms": ["sensor", "calendar"] } diff --git a/custom_components/uk_bin_collection/sensor.py b/custom_components/uk_bin_collection/sensor.py index 6901c5daf8..a5ac4bbe73 100644 --- a/custom_components/uk_bin_collection/sensor.py +++ b/custom_components/uk_bin_collection/sensor.py @@ -28,6 +28,7 @@ STATE_ATTR_NEXT_COLLECTION, DEVICE_CLASS, STATE_ATTR_COLOUR, + PLATFORMS, ) from uk_bin_collection.uk_bin_collection.collect_data import UKBinCollectionApp diff --git a/custom_components/uk_bin_collection/tests/test_calendar.py b/custom_components/uk_bin_collection/tests/test_calendar.py new file mode 100644 index 0000000000..a324e35523 --- /dev/null +++ b/custom_components/uk_bin_collection/tests/test_calendar.py @@ -0,0 +1,542 @@ +# test_calendar.py + +"""Unit tests for the UK Bin Collection Calendar platform.""" + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from datetime import datetime, date, timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from custom_components.uk_bin_collection.const import DOMAIN +from custom_components.uk_bin_collection.calendar import ( + UKBinCollectionCalendar, + async_setup_entry, +) +from homeassistant.components.calendar import CalendarEvent + +from .common_utils import MockConfigEntry + +pytest_plugins = ["freezegun"] + +# Mock Data +MOCK_COORDINATOR_DATA = { + "Recycling": date(2024, 4, 25), + "General Waste": date(2024, 4, 26), + "Garden Waste": date(2024, 4, 27), +} + +@pytest.fixture +def mock_coordinator(): + """Fixture to create a mock DataUpdateCoordinator with sample data.""" + coordinator = MagicMock(spec=DataUpdateCoordinator) + coordinator.data = MOCK_COORDINATOR_DATA.copy() + coordinator.name = "Test Council" + coordinator.last_update_success = True + return coordinator + +@pytest.fixture +def mock_config_entry(): + """Create a mock ConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Test Entry", + data={ + "name": "Test Name", + "council": "Test Council", + "url": "https://example.com", + "timeout": 60, + "icon_color_mapping": {}, + }, + entry_id="test_entry_id", + unique_id="test_unique_id", + ) + +@pytest.fixture +async def hass_instance(hass: HomeAssistant): + return hass + +# Tests + +def test_calendar_entity_initialization(hass_instance, mock_coordinator): + """Test that the calendar entity initializes correctly.""" + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + assert calendar.name == "Test Council Recycling Calendar" + assert calendar.unique_id == "test_entry_id_Recycling_calendar" + assert calendar.device_info == { + "identifiers": {(DOMAIN, "test_entry_id_Recycling_calendar")}, + "name": "Test Council Recycling Calendar Device", + "manufacturer": "UK Bin Collection", + "model": "Bin Collection Calendar", + "sw_version": "1.0", + } + +def test_calendar_event_property(hass_instance, mock_coordinator): + """Test that the event property returns the correct CalendarEvent.""" + collection_date = date(2024, 4, 25) + mock_coordinator.data["Recycling"] = collection_date + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + expected_event = CalendarEvent( + summary="Recycling Collection", + start=collection_date, + end=collection_date + timedelta(days=1), + uid="test_entry_id_Recycling_calendar_2024-04-25", + ) + + assert calendar.event == expected_event + +def test_calendar_event_property_no_data(hass_instance, mock_coordinator): + """Test that the event property returns None when there's no collection date.""" + mock_coordinator.data["Recycling"] = None + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + assert calendar.event is None + +@pytest.mark.asyncio +async def test_async_get_events(hass_instance, mock_coordinator): + """Test that async_get_events returns correct events within the date range.""" + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + "General Waste": date(2024, 4, 26), + } + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + start_date = datetime(2024, 4, 24) + end_date = datetime(2024, 4, 26) + + expected_event = CalendarEvent( + summary="Recycling Collection", + start=date(2024, 4, 25), + end=date(2024, 4, 26), + uid="test_entry_id_Recycling_calendar_2024-04-25", + ) + + events = await calendar.async_get_events(hass_instance, start_date, end_date) + assert events == [expected_event] + +@pytest.mark.asyncio +async def test_async_get_events_no_events_in_range(hass_instance, mock_coordinator): + """Test that async_get_events returns empty list when no events are in the range.""" + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + } + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + start_date = datetime(2024, 4, 26) + end_date = datetime(2024, 4, 30) + + events = await calendar.async_get_events(hass_instance, start_date, end_date) + assert events == [] + +def test_calendar_update_on_coordinator_change(hass_instance, mock_coordinator): + """Test that the calendar entity updates when the coordinator's data changes.""" + collection_date_initial = date(2024, 4, 25) + collection_date_updated = date(2024, 4, 26) + mock_coordinator.data["Recycling"] = collection_date_initial + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + # Initially, the event should be for April 25 + expected_event_initial = CalendarEvent( + summary="Recycling Collection", + start=collection_date_initial, + end=collection_date_initial + timedelta(days=1), + uid="test_entry_id_Recycling_calendar_2024-04-25", + ) + assert calendar.event == expected_event_initial + + # Update the coordinator's data + mock_coordinator.data["Recycling"] = collection_date_updated + mock_coordinator.async_write_ha_state = AsyncMock() + + # Simulate coordinator update by calling the update handler + with patch.object(calendar, "async_write_ha_state", new=AsyncMock()) as mock_write: + calendar._handle_coordinator_update() + + # The event should now be updated to April 26 + expected_event_updated = CalendarEvent( + summary="Recycling Collection", + start=collection_date_updated, + end=collection_date_updated + timedelta(days=1), + uid="test_entry_id_Recycling_calendar_2024-04-26", + ) + assert calendar.event == expected_event_updated + mock_write.assert_called_once() + +@pytest.mark.asyncio +async def test_async_setup_entry_creates_calendar_entities(hass_instance, mock_coordinator, mock_config_entry): + """Test that async_setup_entry creates calendar entities based on coordinator data.""" + # Mock the data in the coordinator + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + "General Waste": date(2024, 4, 26), + } + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + with patch("custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", autospec=True) as mock_calendar_cls: + mock_calendar_instance_recycling = MagicMock() + mock_calendar_instance_general_waste = MagicMock() + mock_calendar_cls.side_effect = [ + mock_calendar_instance_recycling, + mock_calendar_instance_general_waste, + ] + + await async_setup_entry(hass_instance, mock_config_entry) + + # Ensure that two calendar entities are created + assert mock_calendar_cls.call_count == 2 + + # Verify that the calendar entities are initialized with correct parameters + mock_calendar_cls.assert_any_call( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + mock_calendar_cls.assert_any_call( + coordinator=mock_coordinator, + bin_type="General Waste", + unique_id="test_entry_id_General Waste_calendar", + name="Test Council General Waste Calendar", + ) + +@pytest.mark.asyncio +async def test_async_setup_entry_handles_empty_data(hass_instance, mock_config_entry): + """Test that async_setup_entry handles empty coordinator data gracefully.""" + # Mock an empty data coordinator + mock_coordinator = MagicMock(spec=DataUpdateCoordinator) + mock_coordinator.data = {} + mock_coordinator.name = "Test Council" + mock_coordinator.last_update_success = True + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + with patch("custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", autospec=True) as mock_calendar_cls: + await async_setup_entry(hass_instance, mock_config_entry) + + # No calendar entities should be created since there's no data + mock_calendar_cls.assert_not_called() + +@pytest.mark.asyncio +async def test_async_setup_entry_handles_coordinator_failure(hass_instance, mock_config_entry): + """Test that async_setup_entry raises ConfigEntryNotReady on coordinator failure.""" + mock_coordinator = MagicMock(spec=DataUpdateCoordinator) + mock_coordinator.async_config_entry_first_refresh.side_effect = Exception("Update failed") + mock_coordinator.name = "Test Council" + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + with pytest.raises(Exception, match="Update failed"): + await async_setup_entry(hass_instance, mock_config_entry) + +@pytest.mark.asyncio +async def test_async_unload_entry(hass_instance, mock_coordinator, mock_config_entry): + """Test that async_unload_entry unloads calendar entities correctly.""" + # Mock the data in the coordinator + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + } + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + # First, set up the entry + with patch("custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", autospec=True) as mock_calendar_cls: + mock_calendar_instance = MagicMock() + mock_calendar_cls.return_value = mock_calendar_instance + + await async_setup_entry(hass_instance, mock_config_entry) + + # Now, attempt to unload the entry + with patch( + "homeassistant.config_entries.ConfigEntry.async_forward_entry_unload", + return_value=AsyncMock(return_value=True), + ) as mock_unload_forward: + unload_ok = await hass_instance.config_entries.async_forward_entry_unload( + mock_config_entry, "calendar" + ) + + assert unload_ok is True + mock_unload_forward.assert_called_once_with(mock_config_entry, "calendar") + + # Ensure that the coordinator is removed from hass.data + assert mock_config_entry.entry_id not in hass_instance.data[DOMAIN] + +def test_calendar_entity_available_property(hass_instance, mock_coordinator): + """Test the available property of the calendar entity.""" + # When data is present and last_update_success is True + mock_coordinator.last_update_success = True + mock_coordinator.data["Recycling"] = date(2024, 4, 25) + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + assert calendar.available is True + + # When data is missing + mock_coordinator.data["Recycling"] = None + assert calendar.available is False + + # When last_update_success is False + mock_coordinator.last_update_success = False + calendar._state = "Unknown" # Assuming state is set to "Unknown" when unavailable + assert calendar.available is False + +@pytest.mark.asyncio +async def test_async_setup_entry_creates_no_calendar_entities_on_empty_data(hass_instance, mock_config_entry): + """Test that async_setup_entry does not create calendar entities when coordinator data is empty.""" + mock_coordinator = MagicMock(spec=DataUpdateCoordinator) + mock_coordinator.data = {} + mock_coordinator.name = "Test Council" + mock_coordinator.last_update_success = True + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + with patch("custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", autospec=True) as mock_calendar_cls: + await async_setup_entry(hass_instance, mock_config_entry) + + # No calendar entities should be created + mock_calendar_cls.assert_not_called() + +@pytest.mark.asyncio +async def test_async_setup_entry_with_coordinator_failure(hass_instance, mock_config_entry): + """Test that async_setup_entry handles coordinator failures gracefully.""" + mock_coordinator = MagicMock(spec=DataUpdateCoordinator) + mock_coordinator.async_config_entry_first_refresh.side_effect = Exception("Update failed") + mock_coordinator.name = "Test Council" + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + with pytest.raises(Exception, match="Update failed"): + await async_setup_entry(hass_instance, mock_config_entry) + +@pytest.mark.asyncio +async def test_async_unload_entry_failure(hass_instance, mock_coordinator, mock_config_entry): + """Test that async_unload_entry handles unload failures.""" + # Mock the data in the coordinator + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + } + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + # First, set up the entry + with patch("custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", autospec=True) as mock_calendar_cls: + mock_calendar_instance = MagicMock() + mock_calendar_cls.return_value = mock_calendar_instance + + await async_setup_entry(hass_instance, mock_config_entry) + + # Now, attempt to unload the entry but simulate a failure + with patch( + "homeassistant.config_entries.ConfigEntry.async_forward_entry_unload", + return_value=AsyncMock(return_value=False), + ) as mock_unload_forward: + unload_ok = await hass_instance.config_entries.async_forward_entry_unload( + mock_config_entry, "calendar" + ) + + assert unload_ok is False + mock_unload_forward.assert_called_once_with(mock_config_entry, "calendar") + + # Ensure that the coordinator is still present in hass.data + assert mock_config_entry.entry_id in hass_instance.data[DOMAIN] + +@pytest.mark.asyncio +async def test_async_get_events_multiple_events_same_day(hass_instance, mock_coordinator): + """Test async_get_events when multiple bin types have the same collection date.""" + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + "General Waste": date(2024, 4, 25), + } + + calendar_recycling = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + calendar_general_waste = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="General Waste", + unique_id="test_entry_id_General Waste_calendar", + name="Test Council General Waste Calendar", + ) + + start_date = datetime(2024, 4, 24) + end_date = datetime(2024, 4, 26) + + expected_event_recycling = CalendarEvent( + summary="Recycling Collection", + start=date(2024, 4, 25), + end=date(2024, 4, 26), + uid="test_entry_id_Recycling_calendar_2024-04-25", + ) + + expected_event_general_waste = CalendarEvent( + summary="General Waste Collection", + start=date(2024, 4, 25), + end=date(2024, 4, 26), + uid="test_entry_id_General Waste_calendar_2024-04-25", + ) + + events_recycling = await calendar_recycling.async_get_events(hass_instance, start_date, end_date) + events_general_waste = await calendar_general_waste.async_get_events(hass_instance, start_date, end_date) + + assert events_recycling == [expected_event_recycling] + assert events_general_waste == [expected_event_general_waste] + +@pytest.mark.asyncio +async def test_async_get_events_no_coordinator_data(hass_instance, mock_coordinator): + """Test async_get_events when coordinator has no data.""" + mock_coordinator.data = {} + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + start_date = datetime(2024, 4, 24) + end_date = datetime(2024, 4, 26) + + events = await calendar.async_get_events(hass_instance, start_date, end_date) + assert events == [] + +def test_calendar_entity_available_property_no_data(hass_instance, mock_coordinator): + """Test that the calendar's available property is False when there's no data.""" + mock_coordinator.data["Recycling"] = None + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + assert calendar.available is False + +@pytest.mark.asyncio +async def test_calendar_entity_extra_state_attributes(hass_instance, mock_coordinator): + """Test the extra_state_attributes property of the calendar entity.""" + mock_coordinator.data["Recycling"] = date(2024, 4, 25) + + calendar = UKBinCollectionCalendar( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + + # Assuming extra_state_attributes includes more data if implemented + # Adjust this part based on your actual calendar.py implementation + # For example, you might include 'next_collection_date' and 'days_until_collection' + # Here, we'll assume no additional attributes as per the initial calendar.py + + # If extra_state_attributes is not implemented, it defaults to None + # To handle this, you can set it to return an empty dict if not implemented + assert calendar.extra_state_attributes == {} + +@pytest.mark.asyncio +async def test_async_setup_entry_handles_coordinator_partial_data(hass_instance, mock_config_entry): + """Test that async_setup_entry creates calendar entities only for available data.""" + mock_coordinator.data = { + "Recycling": date(2024, 4, 25), + "General Waste": None, # No collection date + "Garden Waste": date(2024, 4, 27), + } + + # Patch the hass.data to include the coordinator + hass_instance.data[DOMAIN][mock_config_entry.entry_id] = { + "coordinator": mock_coordinator, + } + + with patch("custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", autospec=True) as mock_calendar_cls: + mock_calendar_instance_recycling = MagicMock() + mock_calendar_instance_garden_waste = MagicMock() + mock_calendar_cls.side_effect = [ + mock_calendar_instance_recycling, + mock_calendar_instance_garden_waste, + ] + + await async_setup_entry(hass_instance, mock_config_entry) + + # Ensure that two calendar entities are created (excluding General Waste) + assert mock_calendar_cls.call_count == 2 + + # Verify that the calendar entities are initialized with correct parameters + mock_calendar_cls.assert_any_call( + coordinator=mock_coordinator, + bin_type="Recycling", + unique_id="test_entry_id_Recycling_calendar", + name="Test Council Recycling Calendar", + ) + mock_calendar_cls.assert_any_call( + coordinator=mock_coordinator, + bin_type="Garden Waste", + unique_id="test_entry_id_Garden Waste_calendar", + name="Test Council Garden Waste Calendar", + )