diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 20dc7526..07b5dce2 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -104,7 +104,11 @@ jobs: -r requirements.txt \ -r requirements_test.txt - - name: Tests suite + - name: Tests suite (HA integration — harness) run: | - pytest --cov=./custom_components/zaptec --cov-branch + pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch + + - name: Tests suite (API client — plain pytest, no harness) + run: | + pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 882bd3e2..087460f1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,24 @@ To run tests and check test coverage: report, or enable the "Coverage Gutters" extension to view the coverage directly in VSCode. +The suite runs as **two pytest invocations**, and `./scripts/test` runs both: + +- **HA-integration tests** (`tests/test_*.py`) run under the + `pytest-homeassistant-custom-component` harness, which autoloads on Linux. + Run directly with: + `pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch` +- **API-client tests** (`tests/zaptec/*`) test the vendored `zaptec/` client, + which is destined to become a standalone PyPI library (issue #257) and has no + Home Assistant dependency. They run as plain pytest with the harness disabled + (the harness blocks non-localhost sockets, which would break their live + `api.zaptec.com/api/constants` call): + `pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append` + +Because the harness (and its socket block) is process-wide, a bare `pytest` +is not the entry point — use `./scripts/test` or the two commands above. The +HA-integration tests require Linux; run them in the Dev Container (native +Windows is not supported for that half). `tests/zaptec/*` run anywhere. + HA requires [95% coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage/) for all core integration modules, and while HACS doesn't have the same requirements, reaching this level is still a goal for this integration. diff --git a/requirements.txt b/requirements.txt index 1adb323f..be7d8a98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,5 @@ ruff==0.15.22 # Copy from manifest.json to get this into the dev container # without needing to start HA azure-servicebus==7.14.3 -pydantic==2.13.1 +pydantic>=2.11.7,<2.14 aiolimiter==1.2.1 diff --git a/requirements_test.txt b/requirements_test.txt index cf21f7bc..be62f2c4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,6 @@ pytest pytest-asyncio pytest-mock -pytest-cov \ No newline at end of file +pytest-cov +pytest-homeassistant-custom-component==0.13.324; python_version >= "3.14" +pytest-homeassistant-custom-component==0.13.316; python_version < "3.14" diff --git a/scripts/test b/scripts/test index b3d15862..58923e90 100755 --- a/scripts/test +++ b/scripts/test @@ -5,8 +5,14 @@ set -e if [ "$1" == "--skip-api" ]; then export SKIP_ZAPTEC_API_TEST="true" fi + +# HA-integration tests run under the pytest-hacc harness (autoloads on Linux). +# API-client tests (tests/zaptec/*) run as plain pytest with the harness +# disabled, so their live constants call is not socket-blocked. Coverage +# from both is combined via --cov-append. # run tests with -s to display printouts and --log-cli-level to get logger output -pytest --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s +pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s +pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append --log-cli-level=INFO -s # generate coverage report in html and xml coverage html diff --git a/tests/conftest.py b/tests/conftest.py index ba07a5c5..26d41b8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,20 @@ """Zaptec testing configuration file.""" -import asyncio +from collections.abc import Callable, Iterable import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.zaptec.const import DOMAIN +from custom_components.zaptec.manager import ZaptecManager +from custom_components.zaptec.zaptec import MISSING, Charger, Installation from custom_components.zaptec.zaptec.api import Zaptec +from custom_components.zaptec.zaptec.utils import to_under @pytest.fixture(scope="session") @@ -24,12 +33,7 @@ def skip_if_user_disabled_api_tests() -> None: @pytest.fixture(scope="session") def zaptec_username(skip_if_user_disabled_api_tests, skip_if_in_github_actions) -> str: # noqa: ANN001 (the inputs are purely to create dependencies to the env-flags above) - """ - Get the zaptec username stored in env. - - Any test relying on this fixture will be skipped if the test is running - in Gihub Actions, or the user has disabled tests requiring API login. - """ + """Get the zaptec username from env, skipping if API-login tests are disabled.""" username = os.environ.get("ZAPTEC_USERNAME") assert username, ( "Missing username, either set it with \"export ZAPTEC_USERNAME='username'\" " @@ -40,12 +44,7 @@ def zaptec_username(skip_if_user_disabled_api_tests, skip_if_in_github_actions) @pytest.fixture(scope="session") def zaptec_password(skip_if_user_disabled_api_tests, skip_if_in_github_actions) -> str: # noqa: ANN001 - """ - Get the zaptec password stored in env. - - Any test relying on this fixture will be skipped if the test is running - in Gihub Actions, or the user has disabled tests requiring API login. - """ + """Get the zaptec password from env, skipping if API-login tests are disabled.""" password = os.environ.get("ZAPTEC_PASSWORD") assert password, ( "Missing password, either set it with \"export ZAPTEC_PASSWORD='password'\" " @@ -54,14 +53,119 @@ def zaptec_password(skip_if_user_disabled_api_tests, skip_if_in_github_actions) return password -@pytest.fixture(scope="session") -def zaptec_constants() -> dict: - """Get latest constants from Zaptec API.""" +def _backed_get(data: dict[str, Any]) -> Callable[..., Any]: + """Return a `.get(key, default=MISSING)` implementation backed by `data`. + + Mirrors `ZaptecBase.__getitem__`'s key normalization (`to_under`); defaults + to `MISSING` rather than `Mapping.get`'s `None` since every real call site + (`entity.py`'s `_get_zaptec_value`) passes `default=MISSING` explicitly. + """ + + def _get(key: str, default: Any = MISSING) -> Any: + return data.get(to_under(key), default) - async def get_zaptec_constants() -> dict: - async with Zaptec("N/A", "N/A") as zaptec: - # the constants API endpoint does not require login - const: dict = await zaptec.request("constants") - return const + return _get - return asyncio.run(get_zaptec_constants()) + +def make_charger( + data: dict[str, Any], *, installation: MagicMock | None = None, charging: bool = False +) -> MagicMock: + """Build a spec'd Charger double backed by `data`. + + `model` is hardcoded rather than modeling `Charger.model`'s real + `ZCONST.serial_to_model` lookup — a deliberate simplification. + """ + charger = MagicMock(spec=Charger) + charger.id = data["id"] + charger.name = data.get("name", "Mock Charger") + charger.model = "Zaptec Charger" + charger.qual_id = f"Charger[{data['id'][-6:]}]" + charger.get.side_effect = _backed_get(data) + charger.is_charging.return_value = charging + charger.installation = installation + return charger + + +def make_installation(data: dict[str, Any], *, chargers: Iterable[MagicMock] = ()) -> MagicMock: + """Build a spec'd Installation double backed by `data`.""" + install = MagicMock(spec=Installation) + install.id = data["id"] + install.name = data.get("name", "Mock Installation") + install.model = "Zaptec Installation" + install.qual_id = f"Installation[{data['id'][-6:]}]" + install.get.side_effect = _backed_get(data) + install.chargers = list(chargers) + install.stream_main = AsyncMock(return_value=None) + install.stream_close = AsyncMock(return_value=None) + return install + + +@pytest.fixture +def mock_zaptec() -> MagicMock: + """A spec'd Zaptec client seeded with one installation and one charger. + + `__getitem__`/`__iter__`/`__contains__`/`__len__` are wired because `Zaptec` + is itself `Mapping[str, ZaptecBase]` in production, and real code (e.g. + `zaptec[deviceid]` in `__init__.py`/`coordinator.py`) indexes into it directly. + """ + installation = make_installation({"id": "inst-mock-1", "name": "Mock Home"}) + charger = make_charger( + { + "id": "chg-mock-1", + "name": "Mock Charger", + # Keys read by entities under test; extend as needed for coverage. + "operating_mode": "Connected", + "charger_operation_mode": "Connected", + }, + installation=installation, + charging=False, + ) + installation.chargers = [charger] + + objects = {installation.id: installation, charger.id: charger} + + zaptec = MagicMock(spec=Zaptec) + zaptec.__getitem__.side_effect = objects.__getitem__ + zaptec.__iter__.side_effect = lambda: iter(objects) + zaptec.__contains__.side_effect = objects.__contains__ + zaptec.__len__.side_effect = lambda: len(objects) + zaptec.objects.return_value = list(objects.values()) + zaptec.installations = [installation] + zaptec.chargers = [charger] + zaptec.login = AsyncMock(return_value=None) + zaptec.build = AsyncMock(return_value=None) + zaptec.poll = AsyncMock(return_value=None) + zaptec.show_all_updates = False + zaptec.redact = MagicMock() + # Load-bearing, not incidental: __init__.py's startup debug-dump path does + # `message += manager.zaptec.redact.dumps()`, which setup_integration actually + # exercises. An unconfigured MagicMock here would raise TypeError on the +=. + zaptec.redact.dumps.return_value = "" + return zaptec + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """A MockConfigEntry for the zaptec domain.""" + return MockConfigEntry( + domain=DOMAIN, + title="Mock Zaptec", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + entry_id="mock_entry_1", + ) + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_zaptec: MagicMock +) -> ZaptecManager: + """Set the integration up through the real async_setup, with a mocked client. + + Patches `custom_components.zaptec.Zaptec` — where `__init__.py` looks the name + up, per unittest.mock's patch-at-the-lookup rule — not the original definition + in `zaptec/api.py`, which `__init__.py`'s own import wouldn't see patched. + """ + mock_config_entry.add_to_hass(hass) + with patch("custom_components.zaptec.Zaptec", return_value=mock_zaptec): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry.runtime_data diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 00000000..fdb74b39 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,184 @@ +"""Behavior tests for ZaptecUpdateCoordinator, driven through the real harness.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.zaptec.const import ( + ZAPTEC_POLL_INTERVAL_CHARGING, + ZAPTEC_POLL_INTERVAL_IDLE, +) +from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator, ZaptecUpdateOptions +from custom_components.zaptec.zaptec import ZaptecApiError +from tests.conftest import setup_integration + + +async def test_successful_poll_marks_last_update_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A successful poll leaves every coordinator reporting success.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + for coordinator in manager.all_coordinators: + assert coordinator.last_update_success is True + mock_zaptec.poll.assert_awaited() + + +async def test_poll_failure_sets_update_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A ZaptecApiError during poll flips last_update_success to False.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + head = manager.head_coordinator + + mock_zaptec.poll.side_effect = ZaptecApiError("boom") + await head.async_refresh() + + assert head.last_update_success is False + + +async def test_device_coordinator_switches_interval_when_charging( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A charger's coordinator uses the shorter interval once it reports charging.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + charger_coord = manager.device_coordinators["chg-mock-1"] + idle_interval = charger_coord.update_interval + assert idle_interval == timedelta(seconds=ZAPTEC_POLL_INTERVAL_IDLE) + + # Flip the seeded charger to 'charging' and re-run the update-listener path. + mock_zaptec.chargers[0].is_charging.return_value = True + charger_coord.set_update_interval() + + assert charger_coord.update_interval == timedelta(seconds=ZAPTEC_POLL_INTERVAL_CHARGING) + # Also assert the relation directly, catching e.g. const.py setting + # CHARGING >= IDLE, which the equality asserts alone would miss. + assert charger_coord.update_interval < idle_interval + + +async def test_charging_update_interval_requires_charger_object( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Constructing a coordinator with a charging interval on a non-Charger object errors. + + Skips `setup_integration` to hit the constructor-time guard directly: a + bare `manager=MagicMock()` auto-vivifies `self.zaptec` with no error before + the `isinstance(zaptec_object, Charger)` check runs. + """ + mock_config_entry.add_to_hass(hass) + + with pytest.raises(ValueError, match="Charging update interval requires a Charger object"): + ZaptecUpdateCoordinator( + hass, + entry=mock_config_entry, + manager=MagicMock(), + options=ZaptecUpdateOptions( + name="bad", + update_interval=60, + charging_update_interval=30, + tracked_devices=set(), + poll_args={}, + zaptec_object=object(), # not a Charger instance + ), + ) + + +async def test_trigger_poll_is_noop_without_zaptec_object( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """trigger_poll() on a coordinator with no bound zaptec object does nothing. + + `head_coordinator` is the one coordinator built with `zaptec_object=None` + (device coordinators always get a real Charger/Installation), satisfying + trigger_poll()'s no-op guard. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + + await manager.head_coordinator.trigger_poll() + + assert manager.head_coordinator._trigger_task is None # noqa: SLF001 + + +async def test_trigger_poll_cancels_in_flight_task_and_reschedules( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A second trigger_poll() call cancels the running poll sequence and starts a new one.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + charger_coord = manager.device_coordinators["chg-mock-1"] + + # Collapse the real delays to zero, keeping real asyncio.sleep(0) checkpoints so + # the eagerly-started background task actually suspends and can be cancelled mid-flight. + monkeypatch.setattr( + "custom_components.zaptec.coordinator.ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS", [0, 0, 0] + ) + + # HA's eager task factory runs the task immediately; it suspends at the + # first sleep(0) checkpoint and is left pending. + await charger_coord.trigger_poll() + first_task = charger_coord._trigger_task # noqa: SLF001 + assert first_task is not None + + # Second call sees the still-pending first task and cancels it before rescheduling. + await charger_coord.trigger_poll() + assert first_task.cancelled() + + second_task = charger_coord._trigger_task # noqa: SLF001 + assert second_task is not None + assert second_task is not first_task + await second_task + await hass.async_block_till_done() + + assert charger_coord._trigger_task is None # noqa: SLF001 + assert charger_coord.last_update_success is True + + +async def test_trigger_poll_triggers_child_charger_coordinators( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Polling an installation also triggers the poll sequence of its tracked chargers. + + Patches `asyncio.sleep` globally, rather than the delays-list constant (as the + cancel/reschedule test does), just to reach the loop's first iteration fast — + installations use their own delay constant this test doesn't care about. + `charger_coord.trigger_poll` is mocked to isolate "parent calls child" from + the child's own trigger_poll logic (covered elsewhere). + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + install_coord = manager.device_coordinators["inst-mock-1"] + charger_coord = manager.device_coordinators["chg-mock-1"] + + monkeypatch.setattr( + "custom_components.zaptec.coordinator.asyncio.sleep", AsyncMock(return_value=None) + ) + charger_coord.trigger_poll = AsyncMock() + + await install_coord.trigger_poll() + task = install_coord._trigger_task # noqa: SLF001 + assert task is not None + await task + await hass.async_block_till_done() + + charger_coord.trigger_poll.assert_awaited_once() diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 00000000..cf7da4e9 --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,264 @@ +"""Behavior tests for ZaptecBaseEntity, driven through the real harness.""" + +import logging +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.zaptec.const import KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK +from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator +from custom_components.zaptec.entity import KeyUnavailableError, ZaptecBaseEntity +from custom_components.zaptec.zaptec import MISSING, ZaptecApiError +from tests.conftest import setup_integration + + +def _entity_from_coordinator( + coordinator: ZaptecUpdateCoordinator, *, key_not_in_skip_list: bool = False +) -> ZaptecBaseEntity: + """Return a real entity instance bound to `coordinator`. + + Reaches into the private `_listeners` dict since there's no public way to + list entities subscribed to a coordinator. `hasattr(candidate, "_log_value")` + filters out non-entity listeners (e.g. the coordinator's own + `set_update_interval`, also registered as a listener). + + `key_not_in_skip_list=True` skips entities whose `.key` is in + `KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK`, needed when asserting on the + "Getting value failed" log line those keys suppress. + """ + for cb, _context in coordinator._listeners.values(): # noqa: SLF001 + candidate = cb.__self__ + if not hasattr(candidate, "_log_value"): + continue + if key_not_in_skip_list and candidate.key in KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK: + continue + return candidate + raise AssertionError("no matching zaptec entity found") + + +async def _get_zaptec_entity(hass: HomeAssistant) -> str: + """Return one live zaptec entity_id whose value is backed by seeded data. + + Not every zaptec entity reads a key `mock_zaptec` seeds, and setup order + isn't guaranteed to surface a backed one first — skip unbacked entities. + """ + for state in hass.states.async_all(): + if state.entity_id.startswith( + ("sensor.", "binary_sensor.", "switch.", "number.") + ) and state.state not in ("unavailable", "unknown"): + return state.entity_id + raise AssertionError("no backed zaptec entity found") + + +async def test_entity_reports_value_from_zaptec( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A backed key surfaces as the entity's state (not 'unavailable'/'unknown').""" + await setup_integration(hass, mock_config_entry, mock_zaptec) + entity_id = await _get_zaptec_entity(hass) + state = hass.states.get(entity_id) + assert state.state not in ("unavailable", "unknown") + + +async def test_entity_stays_available_when_single_key_missing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A single missing backing key does NOT mark the entity unavailable. + + `CoordinatorEntity.available` is driven by `coordinator.last_update_success`, + not `_attr_available` — `_handle_coordinator_update` catches the + `KeyUnavailableError` from `_update_from_zaptec`, so the poll still succeeds + and the entity keeps its previous value/state. + """ + await setup_integration(hass, mock_config_entry, mock_zaptec) + entity_id = await _get_zaptec_entity(hass) + + state_before = hass.states.get(entity_id) + assert state_before.state not in ("unavailable", "unknown") + + # Make every key lookup miss, then re-run a refresh so entities re-read. + mock_zaptec.chargers[0].get.side_effect = lambda _key, default=MISSING: default + manager = mock_config_entry.runtime_data + for coordinator in manager.all_coordinators: + await coordinator.async_refresh() + await hass.async_block_till_done() + + state_after = hass.states.get(entity_id) + assert state_after.state != "unavailable" + assert state_after.state == state_before.state + + +async def test_entity_unavailable_when_coordinator_poll_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """The entity reports 'unavailable' when its coordinator's poll fails. + + `CoordinatorEntity.available` reflects `coordinator.last_update_success`, + not any per-key state. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + entity_id = await _get_zaptec_entity(hass) + + mock_zaptec.poll.side_effect = ZaptecApiError("boom") + coordinator = manager.device_coordinators["chg-mock-1"] + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unavailable" + + +async def test_log_value_logs_on_change_then_skips_when_unchanged( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: + """_log_value logs when the tracked value changes and stays quiet when it doesn't. + + `_log_value` reads an arbitrary attribute via `getattr` and dedups against + `_prev_value`, purely to feed a debug log line — no `hass.states` to assert on. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + entity.some_attr = "value1" + + with caplog.at_level(logging.DEBUG): + entity._log_value("some_attr") # noqa: SLF001 + assert "value1" in caplog.text + + caplog.clear() + with caplog.at_level(logging.DEBUG): + entity._log_value("some_attr") # noqa: SLF001 + assert caplog.text == "" + + +async def test_get_zaptec_value_returns_default_when_key_missing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """_get_zaptec_value() returns the caller's default when the key isn't backed. + + Covers the one production call site that opts out of the `MISSING`-triggers- + `KeyUnavailableError` default (`sensor.py`'s `default={}` for `completed_session`). + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + sentinel = object() + assert entity._get_zaptec_value(key="totally_missing_key", default=sentinel) is sentinel # noqa: SLF001 + + +async def test_get_zaptec_value_raises_when_intermediate_value_not_mapping( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A dotted key whose first segment resolves to a non-Mapping value raises. + + No shipped entity uses a dotted key today, so this exercises + `_get_zaptec_value`'s "obj isn't Mapping-like" branch directly, guarding it + for whenever one does. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + # "operating_mode" is seeded as a plain string, which has no `.get()`. + with pytest.raises(KeyUnavailableError): + entity._get_zaptec_value(key="operating_mode.sub") # noqa: SLF001 + + +async def test_log_zaptec_attribute_formats_none_str_iterable_and_scalar_keys( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """_log_zaptec_attribute formats None, a single key, an iterable, and a scalar. + + Pokes all four branches directly since the property only feeds a debug log + line. `str` (default `description.key`) and `Iterable` (sensor.py/update.py's + multi-key logging) are live; `None` and the scalar fallback are currently + unreachable in production but worth guarding. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + entity._log_zaptec_key = None # noqa: SLF001 + assert entity._log_zaptec_attribute == "" # noqa: SLF001 + + entity._log_zaptec_key = "foo" # noqa: SLF001 + assert entity._log_zaptec_attribute == ".foo" # noqa: SLF001 + + entity._log_zaptec_key = ["foo", "bar"] # noqa: SLF001 + assert entity._log_zaptec_attribute == ".foo and .bar" # noqa: SLF001 + + entity._log_zaptec_key = 42 # noqa: SLF001 + assert entity._log_zaptec_attribute == ".42" # noqa: SLF001 + + +async def test_log_unavailable_logs_error_and_recovery_transitions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: + """_log_unavailable logs the real exception on going unavailable, and logs recovery. + + Sets `_attr_available`/`_prev_available` directly since they're decoupled + from `CoordinatorEntity.available` (which reads `last_update_success`) — no + real coordinator refresh can drive both log transitions. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator, key_not_in_skip_list=True) + + entity._prev_available = True # noqa: SLF001 + entity._attr_available = False # noqa: SLF001 + with caplog.at_level(logging.DEBUG): + entity._log_unavailable(RuntimeError("boom")) # noqa: SLF001 + assert f"Entity {entity.entity_id} is unavailable" in caplog.text + assert "Getting value failed" in caplog.text + + caplog.clear() + entity._prev_available = False # noqa: SLF001 + entity._attr_available = True # noqa: SLF001 + with caplog.at_level(logging.DEBUG): + entity._log_unavailable() # noqa: SLF001 + assert f"Entity {entity.entity_id} is available" in caplog.text + + +async def test_entity_trigger_poll_delegates_to_coordinator( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """ZaptecBaseEntity.trigger_poll() awaits the bound coordinator's trigger_poll().""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + coordinator.trigger_poll = AsyncMock() + await entity.trigger_poll() + + coordinator.trigger_poll.assert_awaited_once() diff --git a/tests/test_init.py b/tests/test_init.py index 4082b177..6a00fb20 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,17 +1,22 @@ """Tests for custom_components.zaptec.__init__.""" from http import HTTPStatus +from unittest.mock import MagicMock +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.zaptec import _config_entry_error +from custom_components.zaptec.manager import ZaptecManager from custom_components.zaptec.zaptec.exceptions import ( AuthenticationError, RequestConnectionError, RequestError, RequestTimeoutError, ) +from tests.conftest import setup_integration @pytest.mark.parametrize( @@ -22,7 +27,7 @@ # Connection/timeout are recoverable -> HA retries setup. (RequestTimeoutError("slow"), ConfigEntryNotReady), (RequestConnectionError("down"), ConfigEntryNotReady), - # Transient server statuses are recoverable -> HA retries setup (issue #392). + # Transient server statuses are recoverable -> HA retries setup. (RequestError("unavailable", HTTPStatus.SERVICE_UNAVAILABLE), ConfigEntryNotReady), (RequestError("too many", HTTPStatus.TOO_MANY_REQUESTS), ConfigEntryNotReady), # Other HTTP errors stay permanent. @@ -33,3 +38,20 @@ def test_config_entry_error_mapping(err: Exception, expected: type[Exception]) -> None: """Setup login errors map to the right Home Assistant config-entry error.""" assert isinstance(_config_entry_error(err), expected) + + +async def test_setup_entry_creates_manager_and_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A full setup wires up the manager and registers at least one entity.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + + assert isinstance(manager, ZaptecManager) + assert mock_config_entry.runtime_data is manager + # Matches because mock_zaptec seeds "Mock Charger"/"Mock Home", which HA + # slugifies into "mock..." entity_ids. Update if that seed naming changes. + states = [s for s in hass.states.async_all() if s.entity_id.split(".")[1].startswith("mock")] + assert states, "expected at least one zaptec entity to be created" diff --git a/tests/zaptec/conftest.py b/tests/zaptec/conftest.py new file mode 100644 index 00000000..2b86570b --- /dev/null +++ b/tests/zaptec/conftest.py @@ -0,0 +1,37 @@ +"""Test configuration for the vendored Zaptec API client (tests/zaptec/*).""" + +import asyncio + +import pytest + +from custom_components.zaptec.zaptec.api import Zaptec + + +@pytest.fixture(scope="session") +def zaptec_constants() -> dict: + """Get latest constants from Zaptec API. + + Uses a self-contained event loop instead of `asyncio.run()`: under + pytest-hacc's `HassEventLoopPolicy`, `asyncio.run()` resets the thread's + event loop to `None` on exit, breaking later `asyncio_mode=auto` tests. + Saving/restoring the previous loop avoids clobbering that global state. + """ + + async def get_zaptec_constants() -> dict: + async with Zaptec("N/A", "N/A") as zaptec: + # the constants API endpoint does not require login + const: dict = await zaptec.request("constants") + return const + + try: + previous_loop = asyncio.get_event_loop() + except RuntimeError: + previous_loop = None + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(get_zaptec_constants()) + finally: + loop.close() + asyncio.set_event_loop(previous_loop)