Skip to content

Commit

Permalink
tests: simplify and document the emulator server.
Browse files Browse the repository at this point in the history
Signed-off-by: Romain Bezut <morian@xdec.net>
  • Loading branch information
morian committed Oct 13, 2024
1 parent 108aac9 commit 52f0794
Show file tree
Hide file tree
Showing 8 changed files with 113 additions and 62 deletions.
4 changes: 2 additions & 2 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
furo==2024.8.6
sphinx==8.0.2
sphinx_autodoc_typehints==2.4.4
sphinx==8.1.2
sphinx_autodoc_typehints==2.5.0
sphinx_copybutton==0.5.2
sphinx_inline_tabs==2023.4.21
sphinxext_opengraph==0.9.1
Expand Down
57 changes: 8 additions & 49 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
from __future__ import annotations

import asyncio
import os
from asyncio import create_task, start_unix_server
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING

import pytest
from anyio.from_thread import start_blocking_portal

from .emulator import EmulatedDevice
from .emulator import EmulatorServer

if TYPE_CHECKING:
from asyncio import StreamReader, StreamWriter
from collections.abc import Iterator


Expand All @@ -21,55 +18,17 @@ def anyio_backend() -> str:
return 'asyncio'


class FakeServer:
"""Fake server used to spawn fake LD2410 devices."""

def __init__(self, socket_path: str) -> None:
"""Create a fake unix socket server used to emulate a real device."""
self.shutdown = asyncio.Event()
self.socket_path = socket_path
self.started = asyncio.Event()
self.stopped = asyncio.Event()
self._tasks = []

async def handle_connection(self, reader: StreamReader, writer: StreamWriter) -> None:
"""Handle a new connection, which means a brand new device."""
self._tasks.append(asyncio.current_task())
async with EmulatedDevice(reader, writer) as device:
await device.wait_for_closing()

async def serve(self) -> None:
"""Serve requests until we are told to exit."""
server = await start_unix_server(self.handle_connection, self.socket_path)
try:
async with server:
self.started.set()
task_wait = create_task(self.shutdown.wait())
task_serve = create_task(server.serve_forever())
done, pending = await asyncio.wait(
(task_wait, task_serve),
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in self._tasks:
task.cancel()
finally:
self.stopped.set()


@pytest.fixture(scope='class')
def fake_device_socket() -> Iterator[str]:
"""Run a real server in a separate thread."""
@pytest.fixture(scope='session')
def emulation_server() -> Iterator[str]:
"""Run an emulation server in a separate thread."""

with TemporaryDirectory() as tmp:
tmp_socket = os.path.join(tmp, 'server.sock')

with start_blocking_portal(backend='asyncio') as portal:
server = FakeServer(tmp_socket)
portal.start_task_soon(server.serve)
server = EmulatorServer(tmp_socket)
future, _ = portal.start_task(server.run)
try:
portal.call(server.started.wait)
yield server.socket_path
finally:
portal.call(server.shutdown.set)
portal.call(server.stopped.wait)
future.cancel()
2 changes: 2 additions & 0 deletions tests/emulator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from .device import EmulatedDevice
from .models import EmulatorCode, EmulatorCommand
from .server import EmulatorServer

__all__ = [
'EmulatedDevice',
'EmulatorCode',
'EmulatorCommand',
'EmulatorServer',
]
8 changes: 4 additions & 4 deletions tests/emulator/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import asyncio
import json
import logging
from asyncio import Event
from asyncio import Event, Lock
from contextlib import AsyncExitStack, suppress
from dataclasses import asdict, is_dataclass
from enum import IntEnum
Expand Down Expand Up @@ -62,7 +62,7 @@ async def _check_configuration_mode(self, command: Container[Any]) -> bytes:


class EmulatedDevice:
"""Emulate a fake device for test purpose."""
"""Emulate a fake LD2410 device for test purpose."""

def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
"""Create a new emulated LD2410 device from a generic reader/writer."""
Expand Down Expand Up @@ -103,7 +103,7 @@ def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
self._status = DeviceStatus()
self._reader = reader
self._writer = writer
self._write_lock = asyncio.Lock()
self._write_lock = Lock()

def _build_reply(
self,
Expand Down Expand Up @@ -358,7 +358,7 @@ async def _command_task(self) -> None:
self._closing.set()

async def _report_task(self) -> None:
"""Report tasks regularly."""
"""Generate sensor reports regularly."""
while True:
await asyncio.sleep(0.1)
try:
Expand Down
33 changes: 30 additions & 3 deletions tests/emulator/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,54 @@


class EmulatorCode(IntEnum):
"""All kind of emulator-specific commands."""
"""
Emulator-specific commands codes.
These are used to control the behavior of the emulator through
a REPORT frame sent from the client to the fake device.
"""

#: Tell the emulator to disconnect immediately.
DISCONNECT_NOW = auto()

#: Tell the emulator to disconnect after the next command is received.
DISCONNECT_AFTER_COMMAND = auto()

#: Generate and push a corrupted frame immediately.
GENERATE_CORRUPTED_FRAME = auto()

#: Generate and push a corrupted reply frame.
GENERATE_CORRUPTED_COMMAND = auto()

#: Generate and push an unsolicited reply frame immediately.
GENERATE_SPURIOUS_REPLY = auto()

#: The next DISTANCE_RESOLUTION_GET command will receive an invalid resolution index.
RETURN_INVALID_RESOLUTION = auto()


@dataclass
class EmulatorCommand:
"""A command sent to the emulator."""
"""
Emulator-specific commands.
These are used to control the behavior of the emulator through a REPORT
frame sent from the client to the fake device. ``data`` is currently
unused because no emulator require additional data.
"""

#: Command core sent to the emulator.
code: EmulatorCode

#: Additional parameters for the command code (unused for now).
data: Mapping[str, Any] | None = None


@dataclass
class DeviceStatus:
"""Contains the internal state of a device."""
"""Internal state of the emulated device."""

baud_rate = BaudRateIndex.RATE_256000
configuring: bool = False
Expand Down
63 changes: 63 additions & 0 deletions tests/emulator/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

from asyncio import start_unix_server
from typing import TYPE_CHECKING

from anyio import TASK_STATUS_IGNORED

from .device import EmulatedDevice

if TYPE_CHECKING:
from asyncio import StreamReader, StreamWriter

from anyio.abc import TaskStatus


class EmulatorServer:
"""Server used to emulate LD2410 devices on top of an unix socket."""

def __init__(self, socket_path: str) -> None:
"""
Create an emulation server for LD2410 devices on top of an unix socket.
Args:
socket_path: path to the unix socket
"""
self._socket_path = socket_path

@property
def socket_path(self) -> str:
"""Get the server's unix socket path."""
return self._socket_path

async def _handle_connection(self, reader: StreamReader, writer: StreamWriter) -> None:
"""
Handle a new connection for a new emulated device.
This coroutine runs in a new :class:`asyncio.Task` as stated in the documentation
from :meth:`start_unix_server`, which means that we don't have to handle anything
else than the emulated device here.
Args:
reader: the read part of the connection stream
writer: the write part of the connection stream
"""
async with EmulatedDevice(reader, writer) as device:
await device.wait_for_closing()

async def run(self, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None:
"""
Serve incoming connection requests forever.
Connection server is closed when the underlying task is cancelled.
Keyword Args:
task_status: anyio specific used to tell when we are ready to serve
"""
server = await start_unix_server(self._handle_connection, self._socket_path)
async with server:
task_status.started()
await server.serve_forever()
2 changes: 1 addition & 1 deletion tests/requirements-testing.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
anyio==4.6.0
coverage==7.6.1
coverage==7.6.2
pytest-cov==5.0.0
pytest-timeout==2.3.1
pytest==8.3.3
6 changes: 3 additions & 3 deletions tests/test_ld2410.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from .emulator import EmulatorCode, EmulatorCommand

# All test coroutines will be treated as marked.
# All test coroutines will be treated as marked for anyio.
pytestmark = pytest.mark.anyio


Expand All @@ -47,9 +47,9 @@ async def send_emulator_command(self, command: EmulatorCommand) -> None:


@pytest.fixture
def raw_device(fake_device_socket):
def raw_device(emulation_server):
"""A raw non-entered device."""
return FakeLD2410(fake_device_socket)
return FakeLD2410(emulation_server)


@pytest.fixture
Expand Down

0 comments on commit 52f0794

Please sign in to comment.