Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/zaptec/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ restart_charger:
description: Charger identifier
example: 00000000-1111-2222-3333-444444444444

update_firmware:
upgrade_firmware:
name: Update firmware
description: >-
Send update firmware request to the charger. Select charger
Expand Down
51 changes: 51 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,63 @@

import asyncio
import os
from typing import Any
from unittest.mock import MagicMock

import pytest

from custom_components.zaptec.zaptec.api import Zaptec


class FakeConfigEntry:
"""Minimal stand-in for HA's ConfigEntry.

Exposes only what coordinator.py and entity.py actually touch
(`pref_disable_polling`, `async_on_unload`,
`async_create_background_task`). A real ConfigEntry pulls in HA's full
test-harness machinery, which cannot run on native Windows in this dev
environment - see CLAUDE.md's environment notes.
"""

pref_disable_polling = False
title = "Mock Title"

def async_on_unload(self, func: Any) -> None:
"""No-op stand-in for HA's unload-callback registration. Never invoked by these tests."""

def async_create_background_task(
self, hass: Any, target: Any, name: str, eager_start: bool = True
) -> asyncio.Task:
"""Schedule target as a real asyncio Task.

This ensures trigger_poll()'s cancel-and-replace logic is genuinely
exercised by tests.
"""
return asyncio.ensure_future(target)


@pytest.fixture
def config_entry() -> FakeConfigEntry:
"""A fake config entry for coordinator/entity tests."""
return FakeConfigEntry()


@pytest.fixture
async def hass() -> MagicMock:
"""A minimal fake HomeAssistant object exposing a real running event loop.

DataUpdateCoordinator only reads `hass.loop` (to schedule refreshes via
`loop.call_at()`/`loop.time()`); coordinator.py and entity.py never touch
any other HomeAssistant functionality (config, states, services, etc.).
`is_stopping` is pinned False to match a real (non-shutting-down)
HomeAssistant instance, since a bare MagicMock would otherwise be truthy.
"""
fake_hass = MagicMock()
fake_hass.loop = asyncio.get_running_loop()
fake_hass.is_stopping = False
return fake_hass


@pytest.fixture(scope="session")
def skip_if_in_github_actions() -> None:
"""Check if we are running in Github actions and skip any dependant tests if true."""
Expand Down
68 changes: 68 additions & 0 deletions tests/test_binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for binary_sensor.py."""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

from homeassistant.helpers.entity import DeviceInfo
import pytest

from custom_components.zaptec.binary_sensor import (
ZapBinarySensorEntityDescription,
ZaptecBinarySensor,
ZaptecBinarySensorWithAttrs,
)
from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator, ZaptecUpdateOptions
from custom_components.zaptec.zaptec import Charger


@pytest.fixture
def coordinator(hass: MagicMock, config_entry: Any) -> ZaptecUpdateCoordinator:
"""Create a ZaptecUpdateCoordinator for testing."""
manager = MagicMock()
options = ZaptecUpdateOptions(
name="test",
update_interval=600,
charging_update_interval=None,
tracked_devices=set(),
poll_args={},
zaptec_object=None,
)
return ZaptecUpdateCoordinator(hass, entry=config_entry, manager=manager, options=options)


def make_charger(data: dict[str, Any]) -> MagicMock:
"""Create a MagicMock(spec=Charger) whose .get() reads from data."""
charger = MagicMock(spec=Charger)
charger.id = "charger1"
charger.qual_id = "Charger[charger1]"
charger.get.side_effect = data.get
return charger


def test_binary_sensor_update_from_zaptec_sets_is_on(
coordinator: ZaptecUpdateCoordinator,
) -> None:
"""ZaptecBinarySensor._update_from_zaptec reads the raw boolean value for its key."""
charger = make_charger({"is_online": True})
description = ZapBinarySensorEntityDescription(key="is_online", cls=ZaptecBinarySensor)
entity = ZaptecBinarySensor(coordinator, charger, description, DeviceInfo())

entity._update_from_zaptec() # noqa: SLF001

assert entity._attr_is_on is True # noqa: SLF001
assert entity._attr_available is True # noqa: SLF001


def test_binary_sensor_with_attrs_post_init_sets_attrs_and_unique_id(
coordinator: ZaptecUpdateCoordinator,
) -> None:
"""ZaptecBinarySensorWithAttrs._post_init copies all raw attrs and overrides unique_id."""
charger = make_charger({})
charger.asdict.return_value = {"Id": "charger1", "Active": True}
description = ZapBinarySensorEntityDescription(key="active", cls=ZaptecBinarySensorWithAttrs)
entity = ZaptecBinarySensorWithAttrs(coordinator, charger, description, DeviceInfo())

assert entity._attr_extra_state_attributes == {"Id": "charger1", "Active": True} # noqa: SLF001
assert entity._attr_unique_id == "charger1" # noqa: SLF001
91 changes: 91 additions & 0 deletions tests/test_button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for button.py."""

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, MagicMock

from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo
import pytest

from custom_components.zaptec.button import ZapButtonEntityDescription, ZaptecButton
from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator, ZaptecUpdateOptions
from custom_components.zaptec.zaptec import Charger


@pytest.fixture
def coordinator(hass: MagicMock, config_entry: Any) -> ZaptecUpdateCoordinator:
"""Create a ZaptecUpdateCoordinator for testing."""
manager = MagicMock()
options = ZaptecUpdateOptions(
name="test",
update_interval=600,
charging_update_interval=None,
tracked_devices=set(),
poll_args={},
zaptec_object=None,
)
return ZaptecUpdateCoordinator(hass, entry=config_entry, manager=manager, options=options)


def make_charger(data: dict[str, Any]) -> MagicMock:
"""Create a MagicMock(spec=Charger) whose .get() reads from data."""
charger = MagicMock(spec=Charger)
charger.id = "charger1"
charger.qual_id = "Charger[charger1]"
charger.get.side_effect = data.get
return charger


def test_button_available_delegates_to_is_command_valid(
coordinator: ZaptecUpdateCoordinator,
) -> None:
"""ZaptecButton.available checks is_command_valid using its own key as the command."""
charger = make_charger({})
charger.is_command_valid.return_value = True
description = ZapButtonEntityDescription(key="restart_charger", cls=ZaptecButton)
entity = ZaptecButton(coordinator, charger, description, DeviceInfo())

assert entity.available is True
charger.is_command_valid.assert_called_once_with("restart_charger")


def test_button_unavailable_when_command_invalid(coordinator: ZaptecUpdateCoordinator) -> None:
"""ZaptecButton.available is False when is_command_valid returns False."""
charger = make_charger({})
charger.is_command_valid.return_value = False
description = ZapButtonEntityDescription(key="resume_charging", cls=ZaptecButton)
entity = ZaptecButton(coordinator, charger, description, DeviceInfo())

assert entity.available is False


async def test_button_press_sends_command_and_polls(
coordinator: ZaptecUpdateCoordinator,
) -> None:
"""async_press sends the command named by the button's key and triggers a poll."""
charger = make_charger({})
charger.command = AsyncMock()
description = ZapButtonEntityDescription(key="restart_charger", cls=ZaptecButton)
entity = ZaptecButton(coordinator, charger, description, DeviceInfo())
entity.trigger_poll = AsyncMock()

await entity.async_press()

charger.command.assert_awaited_once_with("restart_charger")
entity.trigger_poll.assert_awaited_once()


async def test_button_press_wraps_command_failure(coordinator: ZaptecUpdateCoordinator) -> None:
"""async_press wraps a command failure in HomeAssistantError and skips the poll."""
charger = make_charger({})
charger.command = AsyncMock(side_effect=Exception("boom"))
description = ZapButtonEntityDescription(key="restart_charger", cls=ZaptecButton)
entity = ZaptecButton(coordinator, charger, description, DeviceInfo())
entity.trigger_poll = AsyncMock()

with pytest.raises(HomeAssistantError):
await entity.async_press()

entity.trigger_poll.assert_not_called()
Loading
Loading