generated from mononoki/pyproj
198 lines
6.2 KiB
Python
198 lines
6.2 KiB
Python
import asyncio
|
||
from copy import deepcopy
|
||
import importlib
|
||
import importlib.util
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def client_module():
|
||
"""Загружает общий тестовый клиент без запуска его командного интерфейса."""
|
||
path = Path(__file__).parents[1] / "examples" / "client.py"
|
||
spec = importlib.util.spec_from_file_location("artex_test_client", path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
class Clock:
|
||
"""Монотонные часы, время которых продвигает сам тест."""
|
||
|
||
def __init__(self):
|
||
self.value = 1000.0
|
||
|
||
def __call__(self):
|
||
return self.value
|
||
|
||
def advance(self, seconds):
|
||
assert seconds >= 0
|
||
self.value += seconds
|
||
|
||
|
||
class SleepRequest:
|
||
def __init__(self, delay):
|
||
self.delay = delay
|
||
self.task = asyncio.current_task()
|
||
self.future = asyncio.get_running_loop().create_future()
|
||
|
||
def release(self):
|
||
if not self.future.done():
|
||
self.future.set_result(None)
|
||
|
||
|
||
class AsyncioProxy:
|
||
"""Подменяет только ожидание времени в выбранном серверном модуле."""
|
||
|
||
def __init__(self, timing, label):
|
||
self.timing = timing
|
||
self.label = label
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(asyncio, name)
|
||
|
||
def create_task(self, coroutine, **kwargs):
|
||
return self.timing.spawn(coroutine, **kwargs)
|
||
|
||
async def sleep(self, delay, result=None):
|
||
request = SleepRequest(delay)
|
||
self.timing.queues[self.label].put_nowait(request)
|
||
await request.future
|
||
return result
|
||
|
||
|
||
class Timing:
|
||
def __init__(self):
|
||
self.clock = Clock()
|
||
self.tasks = []
|
||
self.expected_errors = []
|
||
self.queues = {}
|
||
self.waiting = {}
|
||
|
||
def install(self, module, monkeypatch, label):
|
||
self.queues[label] = asyncio.Queue()
|
||
self.waiting[label] = []
|
||
monkeypatch.setattr(module, "asyncio", AsyncioProxy(self, label))
|
||
# Do not invent a missing clock binding: that must remain a real error.
|
||
for name in ("now", "monotonic"):
|
||
if hasattr(module, name):
|
||
monkeypatch.setattr(module, name, self.clock)
|
||
|
||
def spawn(self, coroutine, **kwargs):
|
||
task = asyncio.create_task(coroutine, **kwargs)
|
||
self.tasks.append(task)
|
||
return task
|
||
|
||
def accept_task_error(self, task, error):
|
||
assert task in self.tasks and task.done() and not task.cancelled()
|
||
assert task.exception() is error
|
||
self.expected_errors.append(error)
|
||
|
||
async def sleeping(self, label, task=None):
|
||
async with asyncio.timeout(1):
|
||
while True:
|
||
for request in list(self.waiting[label]):
|
||
if request.future.done():
|
||
self.waiting[label].remove(request)
|
||
elif task is None or request.task is task:
|
||
self.waiting[label].remove(request)
|
||
return request
|
||
self.waiting[label].append(await self.queues[label].get())
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, traceback):
|
||
for task in self.tasks:
|
||
if not task.done():
|
||
task.cancel()
|
||
results = await asyncio.gather(*self.tasks, return_exceptions=True)
|
||
if exc_type is None:
|
||
for result in results:
|
||
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
|
||
if not any(result is expected for expected in self.expected_errors):
|
||
raise result
|
||
|
||
|
||
class ConnectionRecorder:
|
||
def __init__(self):
|
||
self.sent = []
|
||
self.close_count = 0
|
||
|
||
async def send(self, packet):
|
||
self.sent.append(deepcopy(packet))
|
||
|
||
async def close(self):
|
||
self.close_count += 1
|
||
|
||
|
||
class RouterRecorder:
|
||
"""Наблюдатель вызовов менеджера; сам Router проверяется отдельно."""
|
||
|
||
def __init__(self):
|
||
self.routes = {}
|
||
self.moves = []
|
||
self.removed = []
|
||
self.data = []
|
||
|
||
def add(self, uid):
|
||
self.routes.setdefault(uid, set())
|
||
|
||
def move(self, old_uid, new_uid):
|
||
self.moves.append((old_uid, new_uid))
|
||
if old_uid in self.routes:
|
||
self.routes[new_uid] = self.routes.pop(old_uid)
|
||
|
||
def remove(self, uid):
|
||
self.removed.append(uid)
|
||
self.routes.pop(uid, None)
|
||
|
||
async def process(self, uid, command):
|
||
self.data.append((uid, command))
|
||
|
||
|
||
@pytest.fixture
|
||
def timing():
|
||
"""Даёт управляемые часы и ожидания без реальных пауз в 5/60 секунд."""
|
||
return Timing()
|
||
|
||
|
||
@pytest.fixture
|
||
def session_code(timing, monkeypatch):
|
||
"""Загружает настоящую Session, подменяя только часы и ожидания."""
|
||
module = importlib.import_module("session.session")
|
||
timing.install(module, monkeypatch, "session")
|
||
return module
|
||
|
||
|
||
@pytest.fixture
|
||
def make_session(session_code):
|
||
"""Создаёт настоящую сессию с записью отправок и timeout-событий."""
|
||
def create(uid=11):
|
||
connection = ConnectionRecorder()
|
||
events = []
|
||
|
||
async def on_timeout(source, event):
|
||
events.append((source, deepcopy(event)))
|
||
|
||
session = session_code.Session(uid, connection, on_timeout)
|
||
return SimpleNamespace(session=session, connection=connection, events=events)
|
||
|
||
return create
|
||
|
||
|
||
@pytest.fixture
|
||
def make_manager(session_code, timing, monkeypatch):
|
||
"""Создаёт настоящий менеджер с наблюдателем маршрутов и управляемой очисткой."""
|
||
module = importlib.import_module("session.manager")
|
||
timing.install(module, monkeypatch, "manager")
|
||
|
||
def create():
|
||
router = RouterRecorder()
|
||
manager = module.SessionManager(router)
|
||
return SimpleNamespace(manager=manager, router=router, connection=ConnectionRecorder)
|
||
|
||
return create
|