Add async read-only proxy client

This commit is contained in:
2026-07-18 17:05:26 +02:00
parent 1b6af397e5
commit c85637e26d
2 changed files with 811 additions and 0 deletions
+523
View File
@@ -0,0 +1,523 @@
"""Tests sans réseau réel du client TCP asynchrone Arkteos."""
import asyncio
from collections import deque
from pathlib import Path
from typing import Deque
import pytest
from custom_components.arkteos.client import ArkteosClient, DEFAULT_PROXY_PORT
FIXTURES_PATH = Path(__file__).with_name("fixtures")
class FakeWriter:
def __init__(self) -> None:
self.closed = False
self.wait_closed_calls = 0
def close(self) -> None:
self.closed = True
async def wait_closed(self) -> None:
self.wait_closed_calls += 1
class ControlledReader:
def __init__(self, chunks: list[bytes] | None = None) -> None:
self._chunks: Deque[bytes] = deque(chunks or [])
self._data_ready = asyncio.Event()
if self._chunks:
self._data_ready.set()
def push(self, data: bytes) -> None:
self._chunks.append(data)
self._data_ready.set()
async def read(self, _size: int) -> bytes:
while not self._chunks:
await self._data_ready.wait()
self._data_ready.clear()
return self._chunks.popleft()
def _load_frames() -> dict[str, bytes]:
return {
"metadata": (FIXTURES_PATH / "metadata_95.bin").read_bytes(),
"frigo": (FIXTURES_PATH / "frigo_163.bin").read_bytes(),
"regulation": (FIXTURES_PATH / "regulation_227.bin").read_bytes(),
}
async def _wait_for(predicate: object, attempts: int = 200) -> None:
for _ in range(attempts):
if callable(predicate) and predicate():
return
await asyncio.sleep(0.001)
raise AssertionError("condition asynchrone non atteinte")
def _connection_factory(
monkeypatch: pytest.MonkeyPatch,
outcomes: list[tuple[ControlledReader, FakeWriter] | Exception],
) -> None:
pending = deque(outcomes)
async def open_connection(_host: str, _port: int) -> tuple[ControlledReader, FakeWriter]:
if not pending:
raise ConnectionRefusedError("aucune connexion factice disponible")
outcome = pending.popleft()
if isinstance(outcome, Exception):
raise outcome
return outcome
monkeypatch.setattr(asyncio, "open_connection", open_connection)
def test_default_port() -> None:
assert ArkteosClient("proxy.example").port == DEFAULT_PROXY_PORT
def test_successful_connection(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
reader = ControlledReader()
writer = FakeWriter()
_connection_factory(monkeypatch, [(reader, writer)])
client = ArkteosClient("proxy.example")
await client.start()
assert await client.wait_until_connected(0.1)
await client.stop()
assert writer.closed
asyncio.run(scenario())
def test_connection_refused(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
_connection_factory(monkeypatch, [ConnectionRefusedError("refus")])
async def pause(_delay: float) -> None:
await asyncio.sleep(0)
client = ArkteosClient("proxy.example", reconnect_delays=(1,), sleep=pause)
await client.start()
await _wait_for(lambda: client.connection_attempts >= 1)
await client.stop()
assert not client.connected
assert isinstance(client.last_error, ConnectionRefusedError)
asyncio.run(scenario())
def test_progressive_reconnection(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
_connection_factory(
monkeypatch,
[ConnectionRefusedError("1"), ConnectionRefusedError("2"), ConnectionRefusedError("3")],
)
delays: list[float] = []
async def pause(delay: float) -> None:
delays.append(delay)
await asyncio.sleep(0)
client = ArkteosClient(
"proxy.example", reconnect_delays=(1, 2, 5), sleep=pause
)
await client.start()
await _wait_for(lambda: len(delays) >= 3)
await client.stop()
assert delays[:3] == [1, 2, 5]
assert client.reconnect_count >= 3
asyncio.run(scenario())
def test_complete_frame_read(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
metadata = _load_frames()["metadata"]
reader = ControlledReader([metadata])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 1)
assert client.last_frame_type == "metadata"
await client.stop()
asyncio.run(scenario())
def test_fragmented_read(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
frigo = _load_frames()["frigo"]
reader = ControlledReader([frigo[:70], frigo[70:]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 1)
assert client.latest_frigo_data is not None
await client.stop()
asyncio.run(scenario())
def test_multiple_frames_in_one_read(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
frames = _load_frames()
reader = ControlledReader([frames["metadata"] + frames["frigo"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 2)
await client.stop()
asyncio.run(scenario())
def test_metadata_data(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
reader = ControlledReader([_load_frames()["metadata"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.latest_metadata is not None)
assert client.latest_metadata == {"frame_type": "metadata"}
await client.stop()
asyncio.run(scenario())
def test_frigo_data(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
reader = ControlledReader([_load_frames()["frigo"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.latest_frigo_data is not None)
assert client.latest_frigo_data["frame_type"] == "frigo"
await client.stop()
asyncio.run(scenario())
def test_regulation_data(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
reader = ControlledReader([_load_frames()["regulation"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.latest_regulation_data is not None)
assert client.latest_regulation_data["frame_type"] == "regulation"
await client.stop()
asyncio.run(scenario())
def test_invalid_frame_is_rejected_without_stopping(monkeypatch: pytest.MonkeyPatch) -> None:
class InvalidExtractor:
def feed(self, _data: bytes) -> list[bytes]:
return [b"invalid"]
async def scenario() -> None:
reader = ControlledReader([b"source"])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
client._extractor = InvalidExtractor() # type: ignore[assignment]
await client.start()
await _wait_for(lambda: client.frames_rejected == 1)
assert client.running
await client.stop()
asyncio.run(scenario())
def test_bytes_received_counter(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
metadata = _load_frames()["metadata"]
reader = ControlledReader([metadata])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.bytes_received == len(metadata))
await client.stop()
asyncio.run(scenario())
def test_frames_received_counter(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
frames = _load_frames()
reader = ControlledReader([frames["frigo"] + frames["regulation"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 2)
await client.stop()
asyncio.run(scenario())
def test_frames_rejected_counter(monkeypatch: pytest.MonkeyPatch) -> None:
class RaisingExtractor:
def feed(self, _data: bytes) -> list[bytes]:
raise ValueError("rejet")
async def scenario() -> None:
reader = ControlledReader([b"source"])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
client._extractor = RaisingExtractor() # type: ignore[assignment]
await client.start()
await _wait_for(lambda: client.frames_rejected == 1)
await client.stop()
asyncio.run(scenario())
def test_latest_data_updates(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
frames = _load_frames()
reader = ControlledReader(
[frames["metadata"] + frames["frigo"] + frames["regulation"]]
)
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 3)
assert client.last_frame_type == "regulation"
assert client.latest_metadata is not None
assert client.latest_frigo_data is not None
assert client.latest_regulation_data is not None
await client.stop()
asyncio.run(scenario())
def test_synchronous_data_callback(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
received: list[str] = []
reader = ControlledReader([_load_frames()["frigo"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
client.add_data_callback(lambda frame_type, _accepted, _rejected: received.append(frame_type))
await client.start()
await _wait_for(lambda: received == ["frigo"])
await client.stop()
asyncio.run(scenario())
def test_asynchronous_data_callback(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
received: list[str] = []
async def callback(frame_type: str, _accepted: dict[str, object], _rejected: dict[str, object]) -> None:
received.append(frame_type)
reader = ControlledReader([_load_frames()["regulation"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
client.add_data_callback(callback)
await client.start()
await _wait_for(lambda: received == ["regulation"])
await client.stop()
asyncio.run(scenario())
def test_callback_exception_does_not_stop_client(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
def callback(_frame_type: str, _accepted: object, _rejected: object) -> None:
raise RuntimeError("callback")
reader = ControlledReader([_load_frames()["metadata"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
client.add_data_callback(callback)
await client.start()
await _wait_for(lambda: client.frames_received == 1)
assert client.running
assert isinstance(client.last_error, RuntimeError)
await client.stop()
asyncio.run(scenario())
def test_availability_true_after_valid_frame(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
states: list[bool] = []
reader = ControlledReader([_load_frames()["metadata"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
client.add_availability_callback(states.append)
await client.start()
await _wait_for(lambda: client.available)
assert states == [True]
await client.stop()
asyncio.run(scenario())
def test_availability_false_after_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
states: list[bool] = []
reader = ControlledReader([_load_frames()["metadata"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example", frame_timeout=0.01)
client.add_availability_callback(states.append)
await client.start()
await _wait_for(lambda: states == [True, False])
assert not client.available
await client.stop()
asyncio.run(scenario())
def test_availability_restored_after_new_frame(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
states: list[bool] = []
frames = _load_frames()
reader = ControlledReader([frames["metadata"]])
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example", frame_timeout=0.01)
client.add_availability_callback(states.append)
await client.start()
await _wait_for(lambda: states == [True, False])
reader.push(frames["frigo"])
await _wait_for(lambda: states == [True, False, True])
await client.stop()
asyncio.run(scenario())
def test_remote_disconnection(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
writer = FakeWriter()
reader = ControlledReader([b""])
_connection_factory(monkeypatch, [(reader, writer), ConnectionRefusedError("fin")])
async def pause(_delay: float) -> None:
await asyncio.sleep(0)
client = ArkteosClient("proxy.example", reconnect_delays=(1,), sleep=pause)
await client.start()
await _wait_for(lambda: writer.closed)
assert not client.connected
await client.stop()
asyncio.run(scenario())
def test_writer_is_closed(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
writer = FakeWriter()
_connection_factory(monkeypatch, [(ControlledReader(), writer)])
client = ArkteosClient("proxy.example")
await client.start()
await client.wait_until_connected(0.1)
await client.stop()
assert writer.closed
assert writer.wait_closed_calls == 1
asyncio.run(scenario())
def test_stop_during_reconnect_wait(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
_connection_factory(monkeypatch, [ConnectionRefusedError("refus")])
sleeping = asyncio.Event()
async def pause(_delay: float) -> None:
sleeping.set()
await asyncio.Event().wait()
client = ArkteosClient("proxy.example", sleep=pause)
await client.start()
await _wait_for(sleeping.is_set)
await client.stop()
assert not client.running
asyncio.run(scenario())
def test_stop_during_read(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
writer = FakeWriter()
_connection_factory(monkeypatch, [(ControlledReader(), writer)])
client = ArkteosClient("proxy.example")
await client.start()
await client.wait_until_connected(0.1)
await client.stop()
assert writer.closed
assert not client.connected
asyncio.run(scenario())
def test_double_start_does_not_create_two_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
_connection_factory(monkeypatch, [(ControlledReader(), FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
task = client._task
await client.start()
assert client._task is task
await client.stop()
asyncio.run(scenario())
def test_double_stop_is_safe(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
_connection_factory(monkeypatch, [(ControlledReader(), FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await client.stop()
await client.stop()
assert not client.running
asyncio.run(scenario())
def test_client_has_no_socket_write_operations(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
writer = FakeWriter()
_connection_factory(monkeypatch, [(ControlledReader([_load_frames()["metadata"]]), writer)])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 1)
await client.stop()
assert not hasattr(writer, "write")
asyncio.run(scenario())
def test_no_client_task_remains_after_stop(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
_connection_factory(monkeypatch, [(ControlledReader(), FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
task = client._task
await client.stop()
assert client._task is None
assert task is not None and task.done()
asyncio.run(scenario())
def test_all_real_fixtures(monkeypatch: pytest.MonkeyPatch) -> None:
async def scenario() -> None:
frames = _load_frames()
reader = ControlledReader(
[frames["metadata"] + frames["frigo"] + frames["regulation"]]
)
_connection_factory(monkeypatch, [(reader, FakeWriter())])
client = ArkteosClient("proxy.example")
await client.start()
await _wait_for(lambda: client.frames_received == 3)
assert client.last_frame_type == "regulation"
await client.stop()
asyncio.run(scenario())