Add pytest unit testing (#64)

This commit is contained in:
Otto Winter 2021-07-12 20:09:17 +02:00 committed by GitHub
parent 06a254c82e
commit 3a7a47f649
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1068 additions and 20 deletions

View File

@ -22,6 +22,8 @@ jobs:
name: Check import order with isort
- id: mypy
name: Check typing with mypy
- id: pytest
name: Run tests with pytest
steps:
- uses: actions/checkout@v2
- name: Set up Python
@ -52,6 +54,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/pylint.json"
echo "::add-matcher::.github/workflows/matchers/isort.json"
echo "::add-matcher::.github/workflows/matchers/mypy.json"
echo "::add-matcher::.github/workflows/matchers/pytest.json"
- run: flake8 aioesphomeapi
if: ${{ matrix.id == 'flake8' }}
@ -63,3 +66,5 @@ jobs:
if: ${{ matrix.id == 'isort' }}
- run: mypy aioesphomeapi
if: ${{ matrix.id == 'mypy' }}
- run: pytest -vv --tb=native tests
if: ${{ matrix.id == 'pytest' }}

20
.github/workflows/matchers/pytest.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"problemMatcher": [
{
"owner": "pytest",
"fileLocation": "absolute",
"pattern": [
{
"regexp": "^\\s+File \"(.*)\", line (\\d+), in (.*)$",
"file": 1,
"line": 2
},
{
"regexp": "^\\s+(.*)$",
"message": 1
}
]
}
]
}

View File

@ -104,9 +104,17 @@ For development is recommended to use a Python virtual environment (``venv``).
.. code:: bash
# Setup virtualenv (optional)
$ python3 -m venv .
$ source bin/activate
$ python3 setup.py develop
# Install aioesphomeapi and development depenencies
$ pip3 install -e .
$ pip3 install -r requirements_test.txt
# Run linters & test
$ script/lint
# Update protobuf _pb2.py definitions (requires a protobuf compiler installation)
$ script/gen-protoc
License
-------

View File

@ -375,13 +375,15 @@ class APIClient:
if stop:
req.stop = stop
else:
req.has_legacy_command = True
if stop:
req.legacy_command = LegacyCoverCommand.STOP
req.has_legacy_command = True
elif position == 1.0:
req.legacy_command = LegacyCoverCommand.OPEN
else:
req.has_legacy_command = True
elif position == 0.0:
req.legacy_command = LegacyCoverCommand.CLOSE
req.has_legacy_command = True
assert self._connection is not None
await self._connection.send_message(req)

View File

@ -7,6 +7,8 @@ from typing import Any, Awaitable, Callable, List, Optional, cast
from google.protobuf import message
import aioesphomeapi.host_resolver as hr
from .api_pb2 import ( # type: ignore
ConnectRequest,
ConnectResponse,
@ -20,7 +22,6 @@ from .api_pb2 import ( # type: ignore
PingResponse,
)
from .core import MESSAGE_TYPE_TO_PROTO, APIConnectionError
from .host_resolver import ZeroconfInstanceType, async_resolve_host
from .model import APIVersion
from .util import bytes_to_varuint, varuint_to_bytes
@ -35,7 +36,7 @@ class ConnectionParams:
password: Optional[str]
client_info: str
keepalive: float
zeroconf_instance: ZeroconfInstanceType
zeroconf_instance: hr.ZeroconfInstanceType
class APIConnection:
@ -112,7 +113,7 @@ class APIConnection:
raise APIConnectionError(f"Already connected for {self.log_name}!")
try:
coro = async_resolve_host(
coro = hr.async_resolve_host(
self._params.eventloop,
self._params.address,
self._params.port,

View File

@ -4,12 +4,11 @@ from dataclasses import dataclass
from typing import List, Tuple, Union, cast
import zeroconf
from zeroconf import Zeroconf
from zeroconf.asyncio import AsyncZeroconf
import zeroconf.asyncio
from .core import APIConnectionError
ZeroconfInstanceType = Union[Zeroconf, AsyncZeroconf, None]
ZeroconfInstanceType = Union[zeroconf.Zeroconf, zeroconf.asyncio.AsyncZeroconf, None]
@dataclass(frozen=True)
@ -49,18 +48,18 @@ async def _async_resolve_host_zeroconf( # pylint: disable=too-many-branches
# Use or create zeroconf instance, ensure it's an AsyncZeroconf
if zeroconf_instance is None:
try:
zc = AsyncZeroconf()
zc = zeroconf.asyncio.AsyncZeroconf()
except Exception:
raise APIConnectionError(
"Cannot start mDNS sockets, is this a docker container without "
"host network mode?"
)
do_close = True
elif isinstance(zeroconf_instance, AsyncZeroconf):
elif isinstance(zeroconf_instance, zeroconf.asyncio.AsyncZeroconf):
zc = zeroconf_instance
do_close = False
elif isinstance(zeroconf_instance, Zeroconf):
zc = AsyncZeroconf(zc=zeroconf_instance)
elif isinstance(zeroconf_instance, zeroconf.Zeroconf):
zc = zeroconf.asyncio.AsyncZeroconf(zc=zeroconf_instance)
do_close = False
else:
raise ValueError(

View File

@ -168,9 +168,9 @@ class CoverState(EntityState):
)
def is_closed(self, api_version: APIVersion) -> bool:
if api_version >= APIVersion(1, 1):
return self.position == 0.0
return self.legacy_state == LegacyCoverState.CLOSED
if api_version < APIVersion(1, 1):
return self.legacy_state == LegacyCoverState.CLOSED
return self.position == 0.0
# ==================== FAN ====================
@ -398,7 +398,7 @@ class ClimateState(EntityState):
)
custom_fan_mode: str = ""
preset: Optional[ClimatePreset] = converter_field(
default=ClimatePreset.HOME, converter=ClimatePreset.convert
default=ClimatePreset.NONE, converter=ClimatePreset.convert
)
custom_preset: str = ""

View File

@ -4,3 +4,6 @@ flake8==3.9.2
isort==5.9.2
mypy==0.910
types-protobuf==3.17.0
pytest>=6.2.4,<7
pytest-asyncio>=0.15.1,<1
mock>=4.0.3,<5

View File

@ -3,8 +3,9 @@
cd "$(dirname "$0")/.."
set -euxo pipefail
black --safe aioesphomeapi
black --safe aioesphomeapi tests
pylint aioesphomeapi
flake8 aioesphomeapi
isort aioesphomeapi
isort aioesphomeapi tests
mypy aioesphomeapi
pytest tests

View File

@ -42,9 +42,10 @@ setup(
description='Python API for interacting with ESPHome devices.',
long_description=long_description,
license=PROJECT_LICENSE,
packages=find_packages(),
packages=find_packages("aioesphomeapi"),
include_package_data=True,
zip_safe=False,
install_requires=REQUIRES,
python_requires='>=3.7',
test_suite="tests",
)

474
tests/test_client.py Normal file
View File

@ -0,0 +1,474 @@
from sys import version
from mock import MagicMock, AsyncMock, call, patch
import pytest
from aioesphomeapi.api_pb2 import (
BinarySensorStateResponse,
CameraImageRequest,
CameraImageResponse,
ClimateCommandRequest,
CoverCommandRequest,
ExecuteServiceArgument,
ExecuteServiceRequest,
FanCommandRequest,
LightCommandRequest,
ListEntitiesBinarySensorResponse,
ListEntitiesDoneResponse,
ListEntitiesServicesResponse,
NumberCommandRequest,
SwitchCommandRequest,
)
from aioesphomeapi.client import APIClient
from aioesphomeapi.model import (
APIVersion,
BinarySensorInfo,
BinarySensorState,
CameraState,
ClimateFanMode,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
FanDirection,
FanSpeed,
LegacyCoverCommand,
UserService,
UserServiceArg,
UserServiceArgType,
)
@pytest.fixture
def auth_client(event_loop):
client = APIClient(
eventloop=event_loop,
address="fake.address",
port=6052,
password=None,
)
with patch.object(client, "_connection") as conn:
conn.is_connected = True
conn.is_authenticated = True
yield client
def patch_response_complex(client: APIClient, messages):
async def patched(req, app, stop, timeout=5.0):
resp = []
for msg in messages:
if app(msg):
resp.append(msg)
if stop(msg):
break
else:
raise ValueError("Response never stopped")
return resp
client._connection.send_message_await_response_complex = patched
def patch_response_callback(client: APIClient):
on_message = None
async def patched(req, callback):
nonlocal on_message
on_message = callback
client._connection.send_message_callback_response = patched
async def ret(send):
on_message(send)
return ret
def patch_send(client: APIClient):
send = client._connection.send_message = AsyncMock()
return send
def patch_api_version(client: APIClient, version: APIVersion):
client._connection.api_version = version
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input, output",
[
(
[ListEntitiesBinarySensorResponse(), ListEntitiesDoneResponse()],
([BinarySensorInfo()], []),
),
(
[ListEntitiesServicesResponse(), ListEntitiesDoneResponse()],
([], [UserService()]),
),
],
)
async def test_list_entities(auth_client, input, output):
patch_response_complex(auth_client, input)
resp = await auth_client.list_entities_services()
assert resp == output
@pytest.mark.asyncio
async def test_subscribe_states(auth_client):
send = patch_response_callback(auth_client)
on_state = MagicMock()
await auth_client.subscribe_states(on_state)
on_state.assert_not_called()
await send(BinarySensorStateResponse())
on_state.assert_called_once_with(BinarySensorState())
@pytest.mark.asyncio
async def test_subscribe_states_camera(auth_client):
send = patch_response_callback(auth_client)
on_state = MagicMock()
await auth_client.subscribe_states(on_state)
await send(CameraImageResponse(key=1, data=b"asdf"))
on_state.assert_not_called()
await send(CameraImageResponse(key=1, data=b"qwer", done=True))
on_state.assert_called_once_with(CameraState(key=1, data=b"asdfqwer"))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(dict(key=1), dict(key=1)),
(
dict(key=1, position=1.0),
dict(
key=1, has_legacy_command=True, legacy_command=LegacyCoverCommand.OPEN
),
),
(
dict(key=1, position=0.0),
dict(
key=1, has_legacy_command=True, legacy_command=LegacyCoverCommand.CLOSE
),
),
(
dict(key=1, stop=True),
dict(
key=1, has_legacy_command=True, legacy_command=LegacyCoverCommand.STOP
),
),
],
)
async def test_cover_command_legacy(auth_client, cmd, req):
send = patch_send(auth_client)
patch_api_version(auth_client, APIVersion(1, 0))
await auth_client.cover_command(**cmd)
send.assert_called_once_with(CoverCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(dict(key=1), dict(key=1)),
(dict(key=1, position=0.5), dict(key=1, has_position=True, position=0.5)),
(dict(key=1, position=0.0), dict(key=1, has_position=True, position=0.0)),
(dict(key=1, stop=True), dict(key=1, stop=True)),
(
dict(key=1, position=1.0, tilt=0.8),
dict(key=1, has_position=True, position=1.0, has_tilt=True, tilt=0.8),
),
],
)
async def test_cover_command(auth_client, cmd, req):
send = patch_send(auth_client)
patch_api_version(auth_client, APIVersion(1, 1))
await auth_client.cover_command(**cmd)
send.assert_called_once_with(CoverCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(dict(key=1), dict(key=1)),
(dict(key=1, state=True), dict(key=1, has_state=True, state=True)),
(
dict(key=1, speed=FanSpeed.LOW),
dict(key=1, has_speed=True, speed=FanSpeed.LOW),
),
(
dict(key=1, speed_level=10),
dict(key=1, has_speed_level=True, speed_level=10),
),
(
dict(key=1, oscillating=False),
dict(key=1, has_oscillating=True, oscillating=False),
),
(
dict(key=1, direction=FanDirection.REVERSE),
dict(key=1, has_direction=True, direction=FanDirection.REVERSE),
),
],
)
async def test_fan_command(auth_client, cmd, req):
send = patch_send(auth_client)
await auth_client.fan_command(**cmd)
send.assert_called_once_with(FanCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(dict(key=1), dict(key=1)),
(dict(key=1, state=True), dict(key=1, has_state=True, state=True)),
(dict(key=1, brightness=0.8), dict(key=1, has_brightness=True, brightness=0.8)),
(
dict(key=1, rgb=(0.1, 0.5, 1.0)),
dict(key=1, has_rgb=True, red=0.1, green=0.5, blue=1.0),
),
(dict(key=1, white=0.0), dict(key=1, has_white=True, white=0.0)),
(
dict(key=1, color_temperature=0.0),
dict(key=1, has_color_temperature=True, color_temperature=0.0),
),
(
dict(key=1, transition_length=0.1),
dict(key=1, has_transition_length=True, transition_length=100),
),
(
dict(key=1, flash_length=0.1),
dict(key=1, has_flash_length=True, flash_length=100),
),
(dict(key=1, effect="special"), dict(key=1, has_effect=True, effect="special")),
],
)
async def test_light_command(auth_client, cmd, req):
send = patch_send(auth_client)
await auth_client.light_command(**cmd)
send.assert_called_once_with(LightCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(dict(key=1, state=False), dict(key=1, state=False)),
(dict(key=1, state=True), dict(key=1, state=True)),
],
)
async def test_switch_command(auth_client, cmd, req):
send = patch_send(auth_client)
await auth_client.switch_command(**cmd)
send.assert_called_once_with(SwitchCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(
dict(key=1, preset=ClimatePreset.HOME),
dict(key=1, has_legacy_away=True, legacy_away=False),
),
(
dict(key=1, preset=ClimatePreset.AWAY),
dict(key=1, has_legacy_away=True, legacy_away=True),
),
],
)
async def test_climate_command_legacy(auth_client, cmd, req):
send = patch_send(auth_client)
patch_api_version(auth_client, APIVersion(1, 4))
await auth_client.climate_command(**cmd)
send.assert_called_once_with(ClimateCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(
dict(key=1, mode=ClimateMode.HEAT),
dict(key=1, has_mode=True, mode=ClimateMode.HEAT),
),
(
dict(key=1, target_temperature=21.0),
dict(key=1, has_target_temperature=True, target_temperature=21.0),
),
(
dict(key=1, target_temperature_low=21.0),
dict(key=1, has_target_temperature_low=True, target_temperature_low=21.0),
),
(
dict(key=1, target_temperature_high=21.0),
dict(key=1, has_target_temperature_high=True, target_temperature_high=21.0),
),
(
dict(key=1, fan_mode=ClimateFanMode.LOW),
dict(key=1, has_fan_mode=True, fan_mode=ClimateFanMode.LOW),
),
(
dict(key=1, swing_mode=ClimateSwingMode.OFF),
dict(key=1, has_swing_mode=True, swing_mode=ClimateSwingMode.OFF),
),
(
dict(key=1, custom_fan_mode="asdf"),
dict(key=1, has_custom_fan_mode=True, custom_fan_mode="asdf"),
),
(
dict(key=1, preset=ClimatePreset.AWAY),
dict(key=1, has_preset=True, preset=ClimatePreset.AWAY),
),
(
dict(key=1, custom_preset="asdf"),
dict(key=1, has_custom_preset=True, custom_preset="asdf"),
),
],
)
async def test_climate_command(auth_client, cmd, req):
send = patch_send(auth_client)
patch_api_version(auth_client, APIVersion(1, 5))
await auth_client.climate_command(**cmd)
send.assert_called_once_with(ClimateCommandRequest(**req))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd, req",
[
(dict(key=1, state=0.0), dict(key=1, state=0.0)),
(dict(key=1, state=100.0), dict(key=1, state=100.0)),
],
)
async def test_number_command(auth_client, cmd, req):
send = patch_send(auth_client)
await auth_client.number_command(**cmd)
send.assert_called_once_with(NumberCommandRequest(**req))
@pytest.mark.asyncio
async def test_execute_service(auth_client):
send = patch_send(auth_client)
patch_api_version(auth_client, APIVersion(1, 3))
service = UserService(
name="my_service",
key=1,
args=[
UserServiceArg(name="arg1", type=UserServiceArgType.BOOL),
UserServiceArg(name="arg2", type=UserServiceArgType.INT),
UserServiceArg(name="arg3", type=UserServiceArgType.FLOAT),
UserServiceArg(name="arg4", type=UserServiceArgType.STRING),
UserServiceArg(name="arg5", type=UserServiceArgType.BOOL_ARRAY),
UserServiceArg(name="arg6", type=UserServiceArgType.INT_ARRAY),
UserServiceArg(name="arg7", type=UserServiceArgType.FLOAT_ARRAY),
UserServiceArg(name="arg8", type=UserServiceArgType.STRING_ARRAY),
],
)
with pytest.raises(KeyError):
await auth_client.execute_service(service, data={})
await auth_client.execute_service(
service,
data={
"arg1": False,
"arg2": 42,
"arg3": 99.0,
"arg4": "asdf",
"arg5": [False, True, False],
"arg6": [42, 10, 9],
"arg7": [0.0, -100.0],
"arg8": [],
},
)
send.assert_called_once_with(
ExecuteServiceRequest(
key=1,
args=[
ExecuteServiceArgument(bool_=False),
ExecuteServiceArgument(int_=42),
ExecuteServiceArgument(float_=99.0),
ExecuteServiceArgument(string_="asdf"),
ExecuteServiceArgument(bool_array=[False, True, False]),
ExecuteServiceArgument(int_array=[42, 10, 9]),
ExecuteServiceArgument(float_array=[0.0, -100.0]),
ExecuteServiceArgument(string_array=[]),
],
)
)
send.reset_mock()
patch_api_version(auth_client, APIVersion(1, 2))
service = UserService(
name="my_service",
key=2,
args=[
UserServiceArg(name="arg1", type=UserServiceArgType.BOOL),
UserServiceArg(name="arg2", type=UserServiceArgType.INT),
],
)
# Test legacy_int
await auth_client.execute_service(
service,
data={
"arg1": False,
"arg2": 42,
},
)
send.assert_called_once_with(
ExecuteServiceRequest(
key=2,
args=[
ExecuteServiceArgument(bool_=False),
ExecuteServiceArgument(legacy_int=42),
],
)
)
send.reset_mock()
# Test arg order
await auth_client.execute_service(
service,
data={
"arg2": 42,
"arg1": False,
},
)
send.assert_called_once_with(
ExecuteServiceRequest(
key=2,
args=[
ExecuteServiceArgument(bool_=False),
ExecuteServiceArgument(legacy_int=42),
],
)
)
send.reset_mock()
@pytest.mark.asyncio
async def test_request_single_image(auth_client):
send = patch_send(auth_client)
await auth_client.request_single_image()
send.assert_called_once_with(CameraImageRequest(single=True, stream=False))
@pytest.mark.asyncio
async def test_request_image_stream(auth_client):
send = patch_send(auth_client)
await auth_client.request_image_stream()
send.assert_called_once_with(CameraImageRequest(single=False, stream=True))

63
tests/test_connection.py Normal file
View File

@ -0,0 +1,63 @@
import asyncio
import socket
from mock import AsyncMock, MagicMock, Mock, patch
import pytest
from aioesphomeapi.api_pb2 import ConnectResponse, HelloResponse
from aioesphomeapi.connection import APIConnection, ConnectionParams
from aioesphomeapi.core import APIConnectionError
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr
@pytest.fixture
def connection_params() -> ConnectionParams:
return ConnectionParams(
eventloop=asyncio.get_event_loop(),
address="fake.address",
port=6052,
password=None,
client_info="Tests client",
keepalive=15.0,
zeroconf_instance=None,
)
@pytest.fixture
def conn(connection_params) -> APIConnection:
async def on_stop():
pass
return APIConnection(connection_params, on_stop)
@pytest.fixture
def resolve_host():
with patch("aioesphomeapi.host_resolver.async_resolve_host") as func:
func.return_value = AddrInfo(
family=socket.AF_INET,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP,
sockaddr=IPv4Sockaddr("10.0.0.512", 6052),
)
yield func
@pytest.fixture
def socket_socket():
with patch("socket.socket") as func:
yield func
@pytest.mark.asyncio
async def test_connect(conn, resolve_host, socket_socket, event_loop):
with patch.object(event_loop, "sock_connect"), patch(
"asyncio.open_connection", return_value=(None, None)
), patch.object(conn, "run_forever"), patch.object(
conn, "_start_ping"
), patch.object(
conn, "send_message_await_response", return_value=HelloResponse()
):
await conn.connect()
assert conn.is_connected

147
tests/test_host_resolver.py Normal file
View File

@ -0,0 +1,147 @@
import socket
from mock import AsyncMock, MagicMock, patch
import pytest
import aioesphomeapi.host_resolver as hr
from aioesphomeapi.core import APIConnectionError
@pytest.fixture
def async_zeroconf():
with patch("zeroconf.asyncio.AsyncZeroconf") as klass:
yield klass.return_value
@pytest.fixture
def addr_infos():
return [
hr.AddrInfo(
family=socket.AF_INET,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP,
sockaddr=hr.IPv4Sockaddr(address="10.0.0.42", port=6052),
),
hr.AddrInfo(
family=socket.AF_INET6,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP,
sockaddr=hr.IPv6Sockaddr(
address="2001:db8:85a3::8a2e:370:7334",
port=6052,
flowinfo=0,
scope_id=0,
),
),
]
@pytest.mark.asyncio
async def test_resolve_host_zeroconf(async_zeroconf, addr_infos):
info = MagicMock()
info.addresses_by_version.return_value = [
b"\n\x00\x00*",
b" \x01\r\xb8\x85\xa3\x00\x00\x00\x00\x8a.\x03ps4",
]
async_zeroconf.async_get_service_info = AsyncMock(return_value=info)
async_zeroconf.async_close = AsyncMock()
ret = await hr._async_resolve_host_zeroconf("asdf", 6052)
async_zeroconf.async_get_service_info.assert_called_once_with(
"_esphomelib._tcp.local.", "asdf._esphomelib._tcp.local.", 3000
)
async_zeroconf.async_close.assert_called_once_with()
assert ret == addr_infos
@pytest.mark.asyncio
async def test_resolve_host_zeroconf_empty(async_zeroconf):
async_zeroconf.async_get_service_info = AsyncMock(return_value=None)
async_zeroconf.async_close = AsyncMock()
ret = await hr._async_resolve_host_zeroconf("asdf.local", 6052)
assert ret == []
@pytest.mark.asyncio
async def test_resolve_host_getaddrinfo(addr_infos):
eventloop = AsyncMock()
eventloop.getaddrinfo.return_value = [
(
socket.AF_INET,
socket.SOCK_STREAM,
socket.IPPROTO_TCP,
"canon1",
("10.0.0.42", 6052),
),
(
socket.AF_INET6,
socket.SOCK_STREAM,
socket.IPPROTO_TCP,
"canon2",
("2001:db8:85a3::8a2e:370:7334", 6052, 0, 0),
),
(-1, socket.SOCK_STREAM, socket.IPPROTO_TCP, "canon3", ("10.0.0.42", 6052)),
]
ret = await hr._async_resolve_host_getaddrinfo(eventloop, "example.com", 6052)
assert ret == addr_infos
@pytest.mark.asyncio
async def test_resolve_host_getaddrinfo_oserror():
eventloop = AsyncMock()
eventloop.getaddrinfo.side_effect = OSError()
with pytest.raises(APIConnectionError):
await hr._async_resolve_host_getaddrinfo(eventloop, "example.com", 6052)
@pytest.mark.asyncio
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
async def test_resolve_host_mdns(resolve_addr, resolve_zc, addr_infos):
resolve_zc.return_value = addr_infos
ret = await hr.async_resolve_host(None, "example.local", 6052)
resolve_zc.assert_called_once_with("example", 6052, zeroconf_instance=None)
resolve_addr.assert_not_called()
assert ret == addr_infos[0]
@pytest.mark.asyncio
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
async def test_resolve_host_mdns_empty(resolve_addr, resolve_zc, addr_infos):
resolve_zc.return_value = []
resolve_addr.return_value = addr_infos
ret = await hr.async_resolve_host(None, "example.local", 6052)
resolve_zc.assert_called_once_with("example", 6052, zeroconf_instance=None)
resolve_addr.assert_called_once_with(None, "example.local", 6052)
assert ret == addr_infos[0]
@pytest.mark.asyncio
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
async def test_resolve_host_addrinfo(resolve_addr, resolve_zc, addr_infos):
resolve_addr.return_value = addr_infos
ret = await hr.async_resolve_host(None, "example.com", 6052)
resolve_zc.assert_not_called()
resolve_addr.assert_called_once_with(None, "example.com", 6052)
assert ret == addr_infos[0]
@pytest.mark.asyncio
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
async def test_resolve_host_addrinfo_empty(resolve_addr, resolve_zc, addr_infos):
resolve_addr.return_value = []
with pytest.raises(APIConnectionError):
await hr.async_resolve_host(None, "example.com", 6052)
resolve_zc.assert_not_called()
resolve_addr.assert_called_once_with(None, "example.com", 6052)

302
tests/test_model.py Normal file
View File

@ -0,0 +1,302 @@
from dataclasses import dataclass, field
from typing import List, Optional
import pytest
from aioesphomeapi.api_pb2 import (
BinarySensorStateResponse,
ClimateStateResponse,
CoverStateResponse,
DeviceInfoResponse,
FanStateResponse,
HomeassistantServiceMap,
HomeassistantServiceResponse,
LightStateResponse,
ListEntitiesBinarySensorResponse,
ListEntitiesClimateResponse,
ListEntitiesCoverResponse,
ListEntitiesFanResponse,
ListEntitiesLightResponse,
ListEntitiesNumberResponse,
ListEntitiesSensorResponse,
ListEntitiesServicesArgument,
ListEntitiesServicesResponse,
ListEntitiesSwitchResponse,
ListEntitiesTextSensorResponse,
NumberStateResponse,
SensorStateResponse,
ServiceArgType,
SwitchStateResponse,
TextSensorStateResponse,
)
from aioesphomeapi.model import (
APIIntEnum,
APIModelBase,
APIVersion,
BinarySensorInfo,
BinarySensorState,
ClimateInfo,
ClimatePreset,
ClimateState,
CoverInfo,
CoverState,
DeviceInfo,
FanInfo,
FanState,
HomeassistantServiceCall,
LegacyCoverState,
LightInfo,
LightState,
NumberInfo,
NumberState,
SensorInfo,
SensorState,
SwitchInfo,
SwitchState,
TextSensorInfo,
TextSensorState,
UserService,
UserServiceArg,
UserServiceArgType,
converter_field,
)
class DummyIntEnum(APIIntEnum):
DEFAULT = 0
MY_VAL = 1
@pytest.mark.parametrize(
"input, output",
[
(0, DummyIntEnum.DEFAULT),
(1, DummyIntEnum.MY_VAL),
(2, None),
(-1, None),
(DummyIntEnum.DEFAULT, DummyIntEnum.DEFAULT),
(DummyIntEnum.MY_VAL, DummyIntEnum.MY_VAL),
],
)
def test_api_int_enum_convert(input, output):
v = DummyIntEnum.convert(input)
assert v == output
assert v is None or isinstance(v, DummyIntEnum)
@pytest.mark.parametrize(
"input, output",
[
([], []),
([1], [DummyIntEnum.MY_VAL]),
([0, 1], [DummyIntEnum.DEFAULT, DummyIntEnum.MY_VAL]),
([-1], []),
([0, -1], [DummyIntEnum.DEFAULT]),
([DummyIntEnum.DEFAULT], [DummyIntEnum.DEFAULT]),
],
)
def test_api_int_enum_convert_list(input, output):
v = DummyIntEnum.convert_list(input)
assert v == output
assert all(isinstance(x, DummyIntEnum) for x in v)
@dataclass(frozen=True)
class DummyAPIModel(APIModelBase):
val1: int = 0
val2: Optional[DummyIntEnum] = converter_field(
default=DummyIntEnum.DEFAULT, converter=DummyIntEnum.convert
)
@dataclass(frozen=True)
class ListAPIModel(APIModelBase):
val: List[DummyAPIModel] = field(default_factory=list)
def test_api_model_base_converter():
assert DummyAPIModel().val2 == DummyIntEnum.DEFAULT
assert isinstance(DummyAPIModel().val2, DummyIntEnum)
assert DummyAPIModel(val2=0).val2 == DummyIntEnum.DEFAULT
assert isinstance(DummyAPIModel().val2, DummyIntEnum)
assert DummyAPIModel(val2=-1).val2 is None
def test_api_model_base_to_dict():
assert DummyAPIModel().to_dict() == {
"val1": 0,
"val2": 0,
}
assert DummyAPIModel(val1=-1, val2=1).to_dict() == {
"val1": -1,
"val2": 1,
}
assert ListAPIModel(val=[DummyAPIModel()]).to_dict() == {
"val": [
{
"val1": 0,
"val2": 0,
}
]
}
def test_api_model_base_from_dict():
assert DummyAPIModel.from_dict({}) == DummyAPIModel()
assert (
DummyAPIModel.from_dict(
{
"val1": -1,
"val2": -1,
}
)
== DummyAPIModel(val1=-1, val2=None)
)
assert (
DummyAPIModel.from_dict(
{
"val1": -1,
"unknown": 100,
}
)
== DummyAPIModel(val1=-1)
)
assert ListAPIModel.from_dict({}) == ListAPIModel()
assert ListAPIModel.from_dict({"val": []}) == ListAPIModel()
def test_api_model_base_from_pb():
class DummyPB:
def __init__(self, val1=0, val2=0):
self.val1 = val1
self.val2 = val2
assert DummyAPIModel.from_pb(DummyPB()) == DummyAPIModel()
assert DummyAPIModel.from_pb(DummyPB(val1=-1, val2=-1)) == DummyAPIModel(
val1=-1, val2=None
)
def test_api_version_ord():
assert APIVersion(1, 0) == APIVersion(1, 0)
assert APIVersion(1, 0) < APIVersion(1, 1)
assert APIVersion(1, 1) <= APIVersion(1, 1)
assert APIVersion(1, 0) < APIVersion(2, 0)
assert not (APIVersion(2, 1) <= APIVersion(2, 0))
assert APIVersion(2, 1) > APIVersion(2, 0)
@pytest.mark.parametrize(
"model, pb",
[
(DeviceInfo, DeviceInfoResponse),
(BinarySensorInfo, ListEntitiesBinarySensorResponse),
(BinarySensorState, BinarySensorStateResponse),
(CoverInfo, ListEntitiesCoverResponse),
(CoverState, CoverStateResponse),
(FanInfo, ListEntitiesFanResponse),
(FanState, FanStateResponse),
(LightInfo, ListEntitiesLightResponse),
(LightState, LightStateResponse),
(SensorInfo, ListEntitiesSensorResponse),
(SensorState, SensorStateResponse),
(SwitchInfo, ListEntitiesSwitchResponse),
(SwitchState, SwitchStateResponse),
(TextSensorInfo, ListEntitiesTextSensorResponse),
(TextSensorState, TextSensorStateResponse),
(ClimateInfo, ListEntitiesClimateResponse),
(ClimateState, ClimateStateResponse),
(NumberInfo, ListEntitiesNumberResponse),
(NumberState, NumberStateResponse),
(HomeassistantServiceCall, HomeassistantServiceResponse),
(UserServiceArg, ListEntitiesServicesArgument),
(UserService, ListEntitiesServicesResponse),
],
)
def test_basic_pb_conversions(model, pb):
assert model.from_pb(pb()) == model()
@pytest.mark.parametrize(
"state, version, out",
[
(CoverState(legacy_state=LegacyCoverState.OPEN), (1, 0), False),
(CoverState(legacy_state=LegacyCoverState.CLOSED), (1, 0), True),
(CoverState(position=1.0), (1, 1), False),
(CoverState(position=0.5), (1, 1), False),
(CoverState(position=0.0), (1, 1), True),
],
)
def test_cover_state_legacy_state(state, version, out):
assert state.is_closed(APIVersion(*version)) is out
@pytest.mark.parametrize(
"state, version, out",
[
(ClimateInfo(legacy_supports_away=False), (1, 4), []),
(
ClimateInfo(legacy_supports_away=True),
(1, 4),
[ClimatePreset.HOME, ClimatePreset.AWAY],
),
(ClimateInfo(supported_presets=[ClimatePreset.HOME]), (1, 4), []),
(ClimateInfo(supported_presets=[], legacy_supports_away=True), (1, 5), []),
(
ClimateInfo(supported_presets=[ClimatePreset.HOME]),
(1, 5),
[ClimatePreset.HOME],
),
],
)
def test_climate_info_supported_presets_compat(state, version, out):
assert state.supported_presets_compat(APIVersion(*version)) == out
@pytest.mark.parametrize(
"state, version, out",
[
(ClimateState(legacy_away=False), (1, 4), ClimatePreset.HOME),
(ClimateState(legacy_away=True), (1, 4), ClimatePreset.AWAY),
(
ClimateState(legacy_away=True, preset=ClimatePreset.HOME),
(1, 4),
ClimatePreset.AWAY,
),
(ClimateState(preset=ClimatePreset.HOME), (1, 5), ClimatePreset.HOME),
(ClimateState(preset=ClimatePreset.BOOST), (1, 5), ClimatePreset.BOOST),
(
ClimateState(legacy_away=True, preset=ClimatePreset.BOOST),
(1, 5),
ClimatePreset.BOOST,
),
],
)
def test_climate_state_preset_compat(state, version, out):
assert state.preset_compat(APIVersion(*version)) == out
def test_homeassistant_service_map_conversion():
assert HomeassistantServiceCall.from_pb(
HomeassistantServiceResponse(
data=[HomeassistantServiceMap(key="key", value="value")]
)
) == HomeassistantServiceCall(data={"key": "value"})
assert HomeassistantServiceCall.from_dict(
{"data": {"key": "value"}}
) == HomeassistantServiceCall(data={"key": "value"})
def test_user_service_conversion():
assert UserService.from_pb(
ListEntitiesServicesResponse(
args=[
ListEntitiesServicesArgument(
name="arg", type=ServiceArgType.SERVICE_ARG_TYPE_INT
)
]
)
) == UserService(args=[UserServiceArg(name="arg", type=UserServiceArgType.INT)])
assert UserService.from_dict({"args": [{"name": "arg", "type": 1}]}) == UserService(
args=[UserServiceArg(name="arg", type=UserServiceArgType.INT)]
)

22
tests/test_util.py Normal file
View File

@ -0,0 +1,22 @@
import pytest
from aioesphomeapi import util
VARUINT_TESTCASES = [
(0, b"\x00"),
(42, b"\x2a"),
(127, b"\x7f"),
(128, b"\x80\x01"),
(300, b"\xac\x02"),
(65536, b"\x80\x80\x04"),
]
@pytest.mark.parametrize("val, encoded", VARUINT_TESTCASES)
def test_varuint_to_bytes(val, encoded):
assert util.varuint_to_bytes(val) == encoded
@pytest.mark.parametrize("val, encoded", VARUINT_TESTCASES)
def test_bytes_to_varuint(val, encoded):
assert util.bytes_to_varuint(encoded) == val