-
Notifications
You must be signed in to change notification settings - Fork 33
test: add unit tests for ZaptecUpdateCoordinator and ZaptecBaseEntity #394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
rhammen
wants to merge
12
commits into
custom-components:master
from
rhammen:test/coordinator-entity-coverage
Closed
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
ebac029
test: add fake hass/config_entry fixtures for coordinator/entity tests
rhammen 957abde
test: add ZaptecUpdateCoordinator init/validation tests
rhammen 887fdcd
test: add ZaptecUpdateCoordinator.set_update_interval tests
rhammen 2922bab
test: add ZaptecUpdateCoordinator._async_update_data tests
rhammen 2219d93
test: add ZaptecUpdateCoordinator.trigger_poll tests
rhammen bea3338
test: add ZaptecBaseEntity init and _get_zaptec_value tests
rhammen 6559f54
test: add ZaptecBaseEntity update-handling and logging tests
rhammen 290af38
test: add ZaptecBaseEntity.trigger_poll delegation test
rhammen 8ab7b49
test: address final-review polish findings (is_stopping pin, exceptio…
rhammen 657c0e8
docs: add implementation plan for coordinator/entity test coverage
rhammen 5559e03
Remove implementation plan doc from tracked files
rhammen 2524315
@
rhammen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| """Tests for coordinator.py.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from datetime import timedelta | ||
| from typing import Any | ||
| from unittest.mock import AsyncMock, MagicMock, patch | ||
|
|
||
| from homeassistant.helpers.update_coordinator import UpdateFailed | ||
| import pytest | ||
|
|
||
| from custom_components.zaptec.const import ( | ||
| DOMAIN, | ||
| ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS, | ||
| ZAPTEC_POLL_INSTALLATION_TRIGGER_DELAYS, | ||
| ) | ||
| from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator, ZaptecUpdateOptions | ||
| from custom_components.zaptec.zaptec import Charger, Installation, Zaptec, ZaptecApiError | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def manager() -> MagicMock: | ||
| """A fake ZaptecManager exposing only what the coordinator touches.""" | ||
| mgr = MagicMock() | ||
| mgr.zaptec = MagicMock(spec=Zaptec) | ||
| mgr.device_coordinators = {} | ||
| mgr.tracked_devices = set() | ||
| return mgr | ||
|
|
||
|
|
||
| def make_options(**overrides: Any) -> ZaptecUpdateOptions: | ||
| """Build ZaptecUpdateOptions with sane defaults, overridable per test.""" | ||
| defaults: dict[str, Any] = { | ||
| "name": "test", | ||
| "update_interval": 600, | ||
| "charging_update_interval": None, | ||
| "tracked_devices": {"dev1"}, | ||
| "poll_args": {}, | ||
| "zaptec_object": None, | ||
| } | ||
| defaults.update(overrides) | ||
| return ZaptecUpdateOptions(**defaults) | ||
|
|
||
|
|
||
| async def test_init_sets_name_and_default_interval( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that coordinator init sets name and update interval correctly.""" | ||
| options = make_options(name="MyInstall", update_interval=300) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| assert coordinator.name == f"{DOMAIN}-myinstall" | ||
| assert coordinator.update_interval == timedelta(seconds=300) | ||
| assert coordinator.zaptec is manager.zaptec | ||
|
|
||
|
|
||
| async def test_init_raises_if_charging_interval_without_charger( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that charging interval requires a Charger object.""" | ||
| options = make_options( | ||
| charging_update_interval=60, | ||
| zaptec_object=MagicMock(spec=Installation), | ||
| ) | ||
|
|
||
| with pytest.raises(ValueError, match="Charging update interval requires a Charger object"): | ||
| ZaptecUpdateCoordinator(hass, entry=config_entry, manager=manager, options=options) | ||
|
|
||
|
|
||
| async def test_init_accepts_charging_interval_with_charger( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that charging interval is accepted when a Charger object is provided.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.is_charging.return_value = False | ||
| options = make_options(charging_update_interval=60, zaptec_object=charger) | ||
|
|
||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| assert coordinator._charging_update_interval == timedelta(seconds=60) # noqa: SLF001 | ||
|
|
||
|
|
||
| async def test_set_update_interval_switches_between_charging_and_default( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that set_update_interval switches between charging and default intervals.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.is_charging.return_value = False | ||
| charger.qual_id = "Charger[abc123]" | ||
| options = make_options( | ||
| update_interval=600, | ||
| charging_update_interval=60, | ||
| zaptec_object=charger, | ||
| ) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
| assert coordinator.update_interval == timedelta(seconds=600) | ||
|
|
||
| charger.is_charging.return_value = True | ||
| coordinator.set_update_interval() | ||
| assert coordinator.update_interval == timedelta(seconds=60) | ||
|
|
||
| charger.is_charging.return_value = False | ||
| coordinator.set_update_interval() | ||
| assert coordinator.update_interval == timedelta(seconds=600) | ||
|
|
||
|
|
||
| async def test_set_update_interval_is_noop_when_unchanged( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that set_update_interval doesn't reschedule when interval is unchanged.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.is_charging.return_value = False | ||
| options = make_options( | ||
| update_interval=600, | ||
| charging_update_interval=60, | ||
| zaptec_object=charger, | ||
| ) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| with patch.object(coordinator, "_schedule_refresh") as mock_schedule: | ||
| coordinator.set_update_interval() | ||
| mock_schedule.assert_not_called() | ||
|
|
||
|
|
||
| async def test_async_update_data_polls_zaptec_with_options( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that _async_update_data calls zaptec.poll with correct parameters.""" | ||
| manager.zaptec.poll = AsyncMock() | ||
| options = make_options( | ||
| tracked_devices={"dev1", "dev2"}, | ||
| poll_args={"poll_state": True}, | ||
| ) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| await coordinator._async_update_data() # noqa: SLF001 | ||
|
|
||
| manager.zaptec.poll.assert_awaited_once_with({"dev1", "dev2"}, poll_state=True) | ||
|
|
||
|
|
||
| async def test_async_update_data_raises_update_failed_on_api_error( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that _async_update_data raises UpdateFailed on ZaptecApiError.""" | ||
| api_error = ZaptecApiError("boom") | ||
| manager.zaptec.poll = AsyncMock(side_effect=api_error) | ||
| options = make_options() | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| with pytest.raises(UpdateFailed) as exc_info: | ||
| await coordinator._async_update_data() # noqa: SLF001 | ||
| assert exc_info.value.__cause__ is api_error | ||
|
|
||
|
|
||
| async def test_trigger_poll_charger_uses_charger_delays( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that _trigger_poll sleeps/refreshes once per charger delay.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.qual_id = "Charger[abc123]" | ||
| options = make_options(zaptec_object=charger) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| with ( | ||
| patch("custom_components.zaptec.coordinator.asyncio.sleep", AsyncMock()) as mock_sleep, | ||
| patch.object(coordinator, "async_refresh", AsyncMock()) as mock_refresh, | ||
| ): | ||
| await coordinator._trigger_poll(charger) # noqa: SLF001 | ||
|
|
||
| assert mock_sleep.await_count == len(ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS) | ||
| assert mock_refresh.await_count == len(ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS) | ||
|
|
||
|
|
||
| async def test_trigger_poll_installation_also_triggers_tracked_children( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that _trigger_poll on an Installation also polls tracked child chargers.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.id = "charger1" | ||
| installation = MagicMock(spec=Installation) | ||
| installation.qual_id = "Installation[abc123]" | ||
| installation.chargers = [charger] | ||
| manager.tracked_devices = {"charger1"} | ||
|
|
||
| child_coordinator = MagicMock() | ||
| child_coordinator.trigger_poll = AsyncMock() | ||
| manager.device_coordinators = {"charger1": child_coordinator} | ||
|
|
||
| options = make_options(zaptec_object=installation) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| with ( | ||
| patch("custom_components.zaptec.coordinator.asyncio.sleep", AsyncMock()) as mock_sleep, | ||
| patch.object(coordinator, "async_refresh", AsyncMock()) as mock_refresh, | ||
| ): | ||
| await coordinator._trigger_poll(installation) # noqa: SLF001 | ||
|
|
||
| assert mock_sleep.await_count == len(ZAPTEC_POLL_INSTALLATION_TRIGGER_DELAYS) | ||
| assert mock_refresh.await_count == len(ZAPTEC_POLL_INSTALLATION_TRIGGER_DELAYS) | ||
| child_coordinator.trigger_poll.assert_awaited_once() | ||
|
|
||
|
|
||
| async def test_trigger_poll_installation_skips_untracked_children( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that _trigger_poll skips children that aren't in tracked_devices.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.id = "charger1" | ||
| installation = MagicMock(spec=Installation) | ||
| installation.qual_id = "Installation[abc123]" | ||
| installation.chargers = [charger] | ||
| manager.tracked_devices = set() # charger1 is not tracked | ||
|
|
||
| options = make_options(zaptec_object=installation) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| with ( | ||
| patch("custom_components.zaptec.coordinator.asyncio.sleep", AsyncMock()), | ||
| patch.object(coordinator, "async_refresh", AsyncMock()), | ||
| ): | ||
| # Would raise KeyError from manager.device_coordinators[charger.id] if | ||
| # the untracked charger were not filtered out first. | ||
| await coordinator._trigger_poll(installation) # noqa: SLF001 | ||
|
|
||
|
|
||
| async def test_trigger_poll_noop_without_zaptec_object( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that trigger_poll is a no-op when there is no zaptec_object.""" | ||
| options = make_options(zaptec_object=None) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| await coordinator.trigger_poll() | ||
|
|
||
| assert coordinator._trigger_task is None # noqa: SLF001 | ||
|
|
||
|
|
||
| async def test_trigger_poll_cancels_inflight_task_before_starting_new_one( | ||
| hass: MagicMock, config_entry: Any, manager: MagicMock | ||
| ) -> None: | ||
| """Test that a second trigger_poll cancels the in-flight task and starts a new one.""" | ||
| charger = MagicMock(spec=Charger) | ||
| charger.qual_id = "Charger[abc123]" | ||
| options = make_options(zaptec_object=charger) | ||
| coordinator = ZaptecUpdateCoordinator( | ||
| hass, entry=config_entry, manager=manager, options=options | ||
| ) | ||
|
|
||
| call_count = 0 | ||
| first_started = asyncio.Event() | ||
|
|
||
| async def fake_trigger_poll(_zaptec_obj: Any) -> None: | ||
| nonlocal call_count | ||
| call_count += 1 | ||
| if call_count == 1: | ||
| first_started.set() | ||
| await asyncio.Event().wait() # blocks forever, until cancelled | ||
|
|
||
| with patch.object(coordinator, "_trigger_poll", fake_trigger_poll): | ||
| await coordinator.trigger_poll() | ||
| await first_started.wait() | ||
| first_task = coordinator._trigger_task # noqa: SLF001 | ||
| assert first_task is not None | ||
| assert not first_task.done() | ||
|
|
||
| await coordinator.trigger_poll() | ||
|
|
||
| assert first_task.cancelled() | ||
| # Two loop iterations are required here: the first lets the second | ||
| # task run to completion; the second lets its done-callback (which | ||
| # clears coordinator._trigger_task) actually fire, since | ||
| # Task.add_done_callback schedules callbacks via call_soon rather | ||
| # than invoking them synchronously on completion. | ||
| await asyncio.sleep(0) | ||
| await asyncio.sleep(0) # let the second task's done-callback run | ||
| assert coordinator._trigger_task is None # noqa: SLF001 | ||
| assert call_count == 2 # noqa: PLR2004 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about this comment. First about mentioning Windows and second about CLAUDE.md which isn't part of this project.