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 name: Check import order with isort
- id: mypy - id: mypy
name: Check typing with mypy name: Check typing with mypy
- id: pytest
name: Run tests with pytest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python - name: Set up Python
@ -52,6 +54,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/pylint.json" echo "::add-matcher::.github/workflows/matchers/pylint.json"
echo "::add-matcher::.github/workflows/matchers/isort.json" echo "::add-matcher::.github/workflows/matchers/isort.json"
echo "::add-matcher::.github/workflows/matchers/mypy.json" echo "::add-matcher::.github/workflows/matchers/mypy.json"
echo "::add-matcher::.github/workflows/matchers/pytest.json"
- run: flake8 aioesphomeapi - run: flake8 aioesphomeapi
if: ${{ matrix.id == 'flake8' }} if: ${{ matrix.id == 'flake8' }}
@ -63,3 +66,5 @@ jobs:
if: ${{ matrix.id == 'isort' }} if: ${{ matrix.id == 'isort' }}
- run: mypy aioesphomeapi - run: mypy aioesphomeapi
if: ${{ matrix.id == 'mypy' }} 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 .. code:: bash
# Setup virtualenv (optional)
$ python3 -m venv . $ python3 -m venv .
$ source bin/activate $ 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 License
------- -------

View File

@ -375,13 +375,15 @@ class APIClient:
if stop: if stop:
req.stop = stop req.stop = stop
else: else:
req.has_legacy_command = True
if stop: if stop:
req.legacy_command = LegacyCoverCommand.STOP req.legacy_command = LegacyCoverCommand.STOP
req.has_legacy_command = True
elif position == 1.0: elif position == 1.0:
req.legacy_command = LegacyCoverCommand.OPEN req.legacy_command = LegacyCoverCommand.OPEN
else: req.has_legacy_command = True
elif position == 0.0:
req.legacy_command = LegacyCoverCommand.CLOSE req.legacy_command = LegacyCoverCommand.CLOSE
req.has_legacy_command = True
assert self._connection is not None assert self._connection is not None
await self._connection.send_message(req) 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 from google.protobuf import message
import aioesphomeapi.host_resolver as hr
from .api_pb2 import ( # type: ignore from .api_pb2 import ( # type: ignore
ConnectRequest, ConnectRequest,
ConnectResponse, ConnectResponse,
@ -20,7 +22,6 @@ from .api_pb2 import ( # type: ignore
PingResponse, PingResponse,
) )
from .core import MESSAGE_TYPE_TO_PROTO, APIConnectionError from .core import MESSAGE_TYPE_TO_PROTO, APIConnectionError
from .host_resolver import ZeroconfInstanceType, async_resolve_host
from .model import APIVersion from .model import APIVersion
from .util import bytes_to_varuint, varuint_to_bytes from .util import bytes_to_varuint, varuint_to_bytes
@ -35,7 +36,7 @@ class ConnectionParams:
password: Optional[str] password: Optional[str]
client_info: str client_info: str
keepalive: float keepalive: float
zeroconf_instance: ZeroconfInstanceType zeroconf_instance: hr.ZeroconfInstanceType
class APIConnection: class APIConnection:
@ -112,7 +113,7 @@ class APIConnection:
raise APIConnectionError(f"Already connected for {self.log_name}!") raise APIConnectionError(f"Already connected for {self.log_name}!")
try: try:
coro = async_resolve_host( coro = hr.async_resolve_host(
self._params.eventloop, self._params.eventloop,
self._params.address, self._params.address,
self._params.port, self._params.port,

View File

@ -4,12 +4,11 @@ from dataclasses import dataclass
from typing import List, Tuple, Union, cast from typing import List, Tuple, Union, cast
import zeroconf import zeroconf
from zeroconf import Zeroconf import zeroconf.asyncio
from zeroconf.asyncio import AsyncZeroconf
from .core import APIConnectionError from .core import APIConnectionError
ZeroconfInstanceType = Union[Zeroconf, AsyncZeroconf, None] ZeroconfInstanceType = Union[zeroconf.Zeroconf, zeroconf.asyncio.AsyncZeroconf, None]
@dataclass(frozen=True) @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 # Use or create zeroconf instance, ensure it's an AsyncZeroconf
if zeroconf_instance is None: if zeroconf_instance is None:
try: try:
zc = AsyncZeroconf() zc = zeroconf.asyncio.AsyncZeroconf()
except Exception: except Exception:
raise APIConnectionError( raise APIConnectionError(
"Cannot start mDNS sockets, is this a docker container without " "Cannot start mDNS sockets, is this a docker container without "
"host network mode?" "host network mode?"
) )
do_close = True do_close = True
elif isinstance(zeroconf_instance, AsyncZeroconf): elif isinstance(zeroconf_instance, zeroconf.asyncio.AsyncZeroconf):
zc = zeroconf_instance zc = zeroconf_instance
do_close = False do_close = False
elif isinstance(zeroconf_instance, Zeroconf): elif isinstance(zeroconf_instance, zeroconf.Zeroconf):
zc = AsyncZeroconf(zc=zeroconf_instance) zc = zeroconf.asyncio.AsyncZeroconf(zc=zeroconf_instance)
do_close = False do_close = False
else: else:
raise ValueError( raise ValueError(

View File

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

View File

@ -4,3 +4,6 @@ flake8==3.9.2
isort==5.9.2 isort==5.9.2
mypy==0.910 mypy==0.910
types-protobuf==3.17.0 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")/.." cd "$(dirname "$0")/.."
set -euxo pipefail set -euxo pipefail
black --safe aioesphomeapi black --safe aioesphomeapi tests
pylint aioesphomeapi pylint aioesphomeapi
flake8 aioesphomeapi flake8 aioesphomeapi
isort aioesphomeapi isort aioesphomeapi tests
mypy aioesphomeapi mypy aioesphomeapi
pytest tests

View File

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