generated from mononoki/pyproj
[+] mvp1
basic working mvp
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,7 +43,7 @@ ENV/
|
|||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
nogit
|
nogit
|
||||||
*.test.*
|
*.test*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = src
|
||||||
32
src/main.py
32
src/main.py
@@ -0,0 +1,32 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Created on Mon Oct 5 11:47:32 2026
|
||||||
|
|
||||||
|
@author: fox
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from transport import TCP
|
||||||
|
from session import Sessions
|
||||||
|
|
||||||
|
from router import Router
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
sessions = Sessions(router)
|
||||||
|
router.sessions = sessions
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
tcp = TCP(
|
||||||
|
"0.0.0.0",
|
||||||
|
9000,
|
||||||
|
session=sessions,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
43
src/router/__init__.py
Normal file
43
src/router/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
class Router:
|
||||||
|
def __init__(self):
|
||||||
|
self.sessions = None
|
||||||
|
self.session_routes = {}
|
||||||
|
|
||||||
|
def add(self, uid):
|
||||||
|
if self.session_routes.get(uid) is None:
|
||||||
|
self.session_routes[uid] = set()
|
||||||
|
print(f"[ROUTER] connected: {uid}")
|
||||||
|
|
||||||
|
def move(self, old_uid, new_uid):
|
||||||
|
#TODO redo this hardcode
|
||||||
|
if old_uid in self.session_routes.keys():
|
||||||
|
self.session_routes[new_uid] = self.session_routes.pop(old_uid)
|
||||||
|
|
||||||
|
for target in self.session_routes.values():
|
||||||
|
if old_uid in target:
|
||||||
|
target.remove(old_uid)
|
||||||
|
target.add(new_uid)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def remove(self, uid):
|
||||||
|
if uid in self.session_routes.keys():
|
||||||
|
self.session_routes.pop(uid)
|
||||||
|
|
||||||
|
for target in self.session_routes.values():
|
||||||
|
if uid in target : target.remove(uid)
|
||||||
|
|
||||||
|
async def process(self, source, command):
|
||||||
|
print(f"[ROUTER] {source} -> others: {command}")
|
||||||
|
|
||||||
|
for uid in self.session_routes[source]:
|
||||||
|
self.sessions[uid].send(
|
||||||
|
{
|
||||||
|
"command" : command,
|
||||||
|
"payload" : self.sessions[source].last_data[command]
|
||||||
|
})
|
||||||
1
src/session/__init__.py
Normal file
1
src/session/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .manager import SessionManager as Sessions
|
||||||
74
src/session/manager.py
Normal file
74
src/session/manager.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from .session import Session
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
|
||||||
|
def __init__(self, router):
|
||||||
|
self.router = router
|
||||||
|
self.sessions = {}
|
||||||
|
|
||||||
|
async def new(self, connection):
|
||||||
|
uid = id(connection)
|
||||||
|
|
||||||
|
self.sessions[uid] = Session(
|
||||||
|
uid,
|
||||||
|
connection,
|
||||||
|
on_timeout = self.process
|
||||||
|
)
|
||||||
|
|
||||||
|
return uid
|
||||||
|
|
||||||
|
async def feed(self, uid, package):
|
||||||
|
|
||||||
|
session = self.sessions[uid]
|
||||||
|
event = await session.feed(package)
|
||||||
|
if event is None : return
|
||||||
|
|
||||||
|
await self.process(uid, event)
|
||||||
|
|
||||||
|
|
||||||
|
async def process(self, uid, event):
|
||||||
|
'''
|
||||||
|
event = {type : , data : }
|
||||||
|
event can be only:
|
||||||
|
- connect - ping the router (or skip if exist)
|
||||||
|
- reconnect - renamr the key
|
||||||
|
- data - ping the router
|
||||||
|
- disconnect - update our state
|
||||||
|
'''
|
||||||
|
match event["type"]:
|
||||||
|
case "connect":
|
||||||
|
self.router.add(uid)
|
||||||
|
case "reconnect":
|
||||||
|
if event["uid"] in self.sessions.keys() and self.sessions[event["uid"]].upf == event["upf"]:
|
||||||
|
if self.sessions[event["uid"]].state == "stop":
|
||||||
|
self.sessions[event["uid"]].state = "migrated"
|
||||||
|
|
||||||
|
self.router.move(event["uid"], uid)
|
||||||
|
|
||||||
|
self.sessions[uid].dynamic_meta = self.sessions[event["uid"]].dynamic_meta
|
||||||
|
self.sessions[uid].last_data = self.sessions[event["uid"]].last_data
|
||||||
|
|
||||||
|
|
||||||
|
case "data":
|
||||||
|
await self.router.process(uid, event["data"])
|
||||||
|
case "disconnect" :
|
||||||
|
...#TODO
|
||||||
|
await self.close(uid)
|
||||||
|
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if key not in self.sessions.keys():
|
||||||
|
raise KeyError
|
||||||
|
return self.sessions[key]
|
||||||
|
|
||||||
|
|
||||||
|
async def close(self, uid):
|
||||||
|
#if not already stopping
|
||||||
|
if self.sessions[uid].state in ["stop", "migrated"] : return
|
||||||
|
await self.sessions[uid].stop()
|
||||||
|
#wait if there ll be reconect
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
self.sessions.pop(uid, None)
|
||||||
|
self.router.remove(uid)
|
||||||
144
src/session/session.py
Normal file
144
src/session/session.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from time import monotonic as now
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from .versions import VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
|
||||||
|
def __init__(self, uid, connection, on_timeout):
|
||||||
|
self.uid = uid
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
self.timeout = now() + 60 #TODO move timeout to env
|
||||||
|
self.watcher_task = asyncio.create_task(self.watch(on_timeout))
|
||||||
|
|
||||||
|
|
||||||
|
self.state = "new"
|
||||||
|
self.err_count = 0
|
||||||
|
self.timeout_attempt = 0
|
||||||
|
self.upf = hash(now())
|
||||||
|
|
||||||
|
self.pv = "v1" #default protocol version
|
||||||
|
self.static_meta = {
|
||||||
|
# "name" : "unnamed",
|
||||||
|
# "type" : "unknown",
|
||||||
|
# "in" : set(),
|
||||||
|
# "out" : set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dynamic_meta = {
|
||||||
|
# "active_in" : set()
|
||||||
|
# "active_out" : set()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_data = {}
|
||||||
|
|
||||||
|
async def feed (self, package):
|
||||||
|
'''
|
||||||
|
package here is hust a dict with dialect
|
||||||
|
we meet here to solve the dialect
|
||||||
|
'''
|
||||||
|
com = package.get(VERSION[self.pv]["COMMAND"])
|
||||||
|
data = package.get(VERSION[self.pv]["PAYLOAD"])
|
||||||
|
|
||||||
|
if com is None : return {"type" : "disconnect"} #BAN
|
||||||
|
|
||||||
|
if self.state == "new" and com == VERSION[self.pv]["CONNECT"]:
|
||||||
|
...
|
||||||
|
#TODO
|
||||||
|
#process new connection? auth? get and fill some data
|
||||||
|
|
||||||
|
|
||||||
|
self.static_meta["name"] = data.get("name", "unnamed")
|
||||||
|
self.static_meta["type"] = data.get("type", "unknown")
|
||||||
|
|
||||||
|
self.static_meta["in"] = set(data.get("in", []))
|
||||||
|
self.dynamic_meta["active_in"] = set()
|
||||||
|
|
||||||
|
self.static_meta["out"] = set(data.get("out", []))
|
||||||
|
self.dynamic_meta["active_out"] = set()
|
||||||
|
|
||||||
|
|
||||||
|
self.static_meta["com"] = set(data.get("com", []))
|
||||||
|
self.static_meta["auth"] = set(data.get("token", []))
|
||||||
|
|
||||||
|
self.state = "token"
|
||||||
|
|
||||||
|
self.send({VERSION[self.pv]["COMMAND"] : VERSION[self.pv]["TOKEN_ASK"],
|
||||||
|
VERSION[self.pv]["PAYLOAD"] : self.uid,
|
||||||
|
VERSION[self.pv]["UPF"] : self.upf})
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.state == "token" and com == VERSION[self.pv]["TOKEN_ANS"]:
|
||||||
|
self.state = "ready"
|
||||||
|
self.send({VERSION[self.pv]["COMMAND"] : VERSION[self.pv]["HANDSHAKE_ASK"]})
|
||||||
|
|
||||||
|
|
||||||
|
old_token = data.get(VERSION[self.pv]["TOKEN"])
|
||||||
|
old_upf = data.get(VERSION[self.pv]["UPF"])
|
||||||
|
if old_token and old_upf:
|
||||||
|
return {"type" : "reconnect",
|
||||||
|
"uid" : old_token,
|
||||||
|
"upf" : old_upf}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.state == "ready" and com == VERSION[self.pv]["HANDSHAKE_ANS"]:
|
||||||
|
|
||||||
|
if not data.get(VERSION[self.pv]["STATUS"]):
|
||||||
|
if self.timeout_attempt == 5 : return {"type" : "disconnect"}
|
||||||
|
self.timeout_attempt += 1
|
||||||
|
await asyncio.sleep(self.timeout_attempt)
|
||||||
|
self.send({VERSION[self.pv]["COMMAND"] : VERSION[self.pv]["HANDSHAKE_ASK"]})
|
||||||
|
return {"type" : "await handshake", "state" : data.get(VERSION[self.pv]["STATE"])}
|
||||||
|
|
||||||
|
self.state = "air"
|
||||||
|
return {"type" : "connect", "uid" : self.uid}
|
||||||
|
|
||||||
|
if self.state == "air":
|
||||||
|
data = package[VERSION[self.pv]["PAYLOAD"]]
|
||||||
|
|
||||||
|
if com == VERSION[self.pv]["HEARTBEAT_ASK"]:
|
||||||
|
#TODO update deadline
|
||||||
|
self.timeout = now() + 5 #TODO move to env
|
||||||
|
self.send({VERSION[self.pv]["COMMAND"] : VERSION[self.pv]["HEARTBEAT_ANS"]})
|
||||||
|
return
|
||||||
|
|
||||||
|
self.update(com, data)
|
||||||
|
return {"type" : "data", "data" : com}
|
||||||
|
|
||||||
|
self.err_count += 1
|
||||||
|
if self.err_count == 5 : return {"type" : "disconnect"} #BAN
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def watch(self, on_timeout):
|
||||||
|
while self.state not in {"stop", "migrated"}:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if self.state in {"stop", "migrated"}:
|
||||||
|
return
|
||||||
|
|
||||||
|
if now() >= self.timeout:
|
||||||
|
await on_timeout(
|
||||||
|
self.uid,
|
||||||
|
{"type": "disconnect"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def update(self, command, data):
|
||||||
|
self.last_data[command] = data
|
||||||
|
|
||||||
|
def send(self, package):
|
||||||
|
#TODO
|
||||||
|
#decide if we want dialect pack here or not
|
||||||
|
if self.state != "stop":
|
||||||
|
asyncio.create_task( self.connection.send(package) )
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self.state = "stop"
|
||||||
|
await self.connection.close()
|
||||||
|
|
||||||
|
|
||||||
28
src/session/versions.py
Normal file
28
src/session/versions.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
VERSION = {
|
||||||
|
"v1": {
|
||||||
|
"COMMAND": "command",
|
||||||
|
"PAYLOAD": "payload",
|
||||||
|
|
||||||
|
"CONNECT": "connection",
|
||||||
|
"TOKEN_ASK": "token_ask",
|
||||||
|
"TOKEN_ANS": "token_answer",
|
||||||
|
"TOKEN": "token",
|
||||||
|
"UPF": "upf",
|
||||||
|
"HANDSHAKE_ASK": "handshake_ask",
|
||||||
|
"HANDSHAKE_ANS": "handshake_answer",
|
||||||
|
"STATUS": "status",
|
||||||
|
"STATE": "state",
|
||||||
|
"HEARTBEAT_ASK": "heartbeat_ask",
|
||||||
|
"HEARTBEAT_ANS": "heartbeat_answer",
|
||||||
|
},
|
||||||
|
|
||||||
|
"v2": {
|
||||||
|
"COMMAND": "com",
|
||||||
|
"PAYLOAD": "arg",
|
||||||
|
},
|
||||||
|
|
||||||
|
"short": {
|
||||||
|
"COMMAND": "c",
|
||||||
|
"PAYLOAD": "p",
|
||||||
|
},
|
||||||
|
}
|
||||||
15
src/start_server.bat
Normal file
15
src/start_server.bat
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
set "PYTHON=D:\_Programs\anaconda\envs\sites\python.exe"
|
||||||
|
if not exist "%PYTHON%" (
|
||||||
|
echo Python not found: %PYTHON%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
"%PYTHON%" -u "%~dp0main.py" %*
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
pause
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
1
src/transport/__init__.py
Normal file
1
src/transport/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .tcp import TCP
|
||||||
19
src/transport/tcp/__init__.py
Normal file
19
src/transport/tcp/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import asyncio
|
||||||
|
from .handler import handle
|
||||||
|
|
||||||
|
|
||||||
|
class TCP:
|
||||||
|
def __init__(self, addr, port, session):
|
||||||
|
self.addr = addr
|
||||||
|
self.port = port
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
lambda r, w: handle(r, w, self.session),
|
||||||
|
self.addr,
|
||||||
|
self.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
15
src/transport/tcp/connection.py
Normal file
15
src/transport/tcp/connection.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def __init__(self, reader, writer):
|
||||||
|
self.reader = reader
|
||||||
|
self.writer = writer
|
||||||
|
|
||||||
|
async def send(self, packet):
|
||||||
|
data = (json.dumps(packet) + "\n").encode()
|
||||||
|
self.writer.write(data)
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
19
src/transport/tcp/handler.py
Normal file
19
src/transport/tcp/handler.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from .connection import Connection
|
||||||
|
import json
|
||||||
|
|
||||||
|
async def handle(reader, writer, session):
|
||||||
|
connection = Connection(reader, writer)
|
||||||
|
|
||||||
|
uid = await session.new(connection)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await reader.readline()
|
||||||
|
if not raw : break
|
||||||
|
|
||||||
|
packet = json.loads(raw)
|
||||||
|
await session.feed(uid, packet)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await session.close(uid)
|
||||||
|
|
||||||
70
tests/README.md
Normal file
70
tests/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Клиент и тесты сервера
|
||||||
|
|
||||||
|
`src/.test/client_test.py` используется и для ручного запуска, и в интеграционных
|
||||||
|
тестах. Он ожидает `token_ask`, отвечает `token_answer`, ожидает `handshake_ask`,
|
||||||
|
отвечает `handshake_answer` и подтверждает состояние `air` ответом на heartbeat.
|
||||||
|
Затем ручной запуск отправляет десять пакетов телеметрии с heartbeat между ними.
|
||||||
|
Каждое ожидание ответа имеет таймаут; импорт файла не запускает клиент.
|
||||||
|
|
||||||
|
После запуска сервера:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
src\.test\start_client.bat rover --count 10 --interval 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Для запроса восстановления укажите старые значения из вывода клиента:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
src\.test\start_client.bat rover --old-token 123 --old-upf 456
|
||||||
|
```
|
||||||
|
|
||||||
|
Сейчас сервер не сообщает результат восстановления отдельным ответом.
|
||||||
|
Клиент сообщает только факт отправки запроса; heartbeat подтверждает новый
|
||||||
|
handshake, но сам по себе не доказывает успешное восстановление.
|
||||||
|
|
||||||
|
Запуск всех тестов из корня `server`:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
D:\_Programs\anaconda\envs\sites\python.exe -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Также можно запустить `tests/start_tests.bat`. Только проверки самого клиента:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
tests\start_tests.bat tests/test_client_protocol.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Интеграционные тесты проверяют подключение, метаданные портов, телеметрию,
|
||||||
|
heartbeat, немедленное закрытие транспорта и сохранение отключённой сессии,
|
||||||
|
отключение после пяти команд в неправильном состоянии протокола,
|
||||||
|
перенос данных и маршрутов в обе стороны, отказ для активной/отсутствующей
|
||||||
|
сессии и неверного `upf`, а также однократность восстановления.
|
||||||
|
|
||||||
|
Дополнительные проверки:
|
||||||
|
|
||||||
|
- `test_session.py`: все этапы подключения, истечение и продление дедлайна,
|
||||||
|
остановка воркера, ошибки последовательности и повторный handshake.
|
||||||
|
- `test_session_manager.py`: обработка событий, истечение окна восстановления,
|
||||||
|
однократность переноса и безопасность старой отложенной очистки.
|
||||||
|
- `test_router.py`: перенос ссылок в обе стороны, несвязанные маршруты,
|
||||||
|
окончательное удаление связей и данные отправителя при пересылке.
|
||||||
|
- `test_source_validation.py`: синтаксис исходников и docstring каждого теста.
|
||||||
|
|
||||||
|
В `test_client_run.py` также проверяются настоящие TCP-таймауты в состояниях
|
||||||
|
`new/token/ready/air` и сохранение соединения после продления heartbeat.
|
||||||
|
Краткое описание каждой проверки находится в docstring её функции.
|
||||||
|
|
||||||
|
Интеграционные тесты запускают настоящий TCP-обработчик, сессии и роутер на
|
||||||
|
свободном локальном порту. Маршруты задаются явно внутри теста: правила API/GUI
|
||||||
|
не предполагаются. Изолированные проверки менеджера используют наблюдатель
|
||||||
|
вызовов роутера; настоящий роутер дополнительно проверяется отдельно.
|
||||||
|
|
||||||
|
В сценариях с таймерами подменяются только часы и ожидания времени в памяти.
|
||||||
|
Тест явно продвигает время и освобождает нужное ожидание, поэтому реальные
|
||||||
|
паузы в 5/60 секунд не нужны. Методы сессии, её воркер, обработчики менеджера
|
||||||
|
и маршрутизация не подменяются. Неопределённые имена часов не добавляются
|
||||||
|
тестами: такие ошибки остаются видимыми. Фоновые задачи завершаются или
|
||||||
|
отменяются при выходе из каждого сценария; ошибки воркеров не скрываются.
|
||||||
|
|
||||||
|
`fixtures/client_run_expected.json` оставлен как исторический эталон старого
|
||||||
|
сценария. Новые тесты проверяют ответы протокола и состояние сервера, а не логи.
|
||||||
190
tests/conftest.py
Normal file
190
tests/conftest.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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] / "src" / ".test" / "client_test.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.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
|
||||||
|
|
||||||
|
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):
|
||||||
|
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
|
||||||
36
tests/fixtures/client_run_expected.json
vendored
Normal file
36
tests/fixtures/client_run_expected.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
{"command": "connection", "state": "token"},
|
||||||
|
{"command": "token_answer", "state": "ready"},
|
||||||
|
{"command": "handshake_answer", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"},
|
||||||
|
{"command": "telemetry", "state": "air"}
|
||||||
|
],
|
||||||
|
"last_data": {
|
||||||
|
"telemetry": {"temperature": 42.0, "battery": 87}
|
||||||
|
},
|
||||||
|
"server_responses": [],
|
||||||
|
"sessions_after_disconnect": 0,
|
||||||
|
"routes_after_disconnect": 0,
|
||||||
|
"server_output": [
|
||||||
|
"[ROUTER] connected: <uid>",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry",
|
||||||
|
"[ROUTER] <uid> -> others: telemetry"
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tests/start_tests.bat
Normal file
19
tests/start_tests.bat
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
|
||||||
|
set "PYTHON=D:\_Programs\anaconda\envs\sites\python.exe"
|
||||||
|
if not exist "%PYTHON%" (
|
||||||
|
echo Python not found: %PYTHON%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%~1"=="" (
|
||||||
|
"%PYTHON%" -m pytest "%~dp0."
|
||||||
|
) else (
|
||||||
|
"%PYTHON%" -m pytest %*
|
||||||
|
)
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
pause
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
114
tests/test_client_protocol.py
Normal file
114
tests/test_client_protocol.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Check the real test client independently of the server implementation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
async def send_packet(writer, packet):
|
||||||
|
writer.write((json.dumps(packet) + "\n").encode())
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_waits_for_handshake_and_checks_heartbeat(client_module):
|
||||||
|
"""Проверяет порядок handshake и сохранение данных, пришедших до heartbeat-ответа."""
|
||||||
|
async def scenario():
|
||||||
|
finished = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
async def peer(reader, writer):
|
||||||
|
try:
|
||||||
|
packet = json.loads(await reader.readline())
|
||||||
|
assert packet == {
|
||||||
|
"command": "connection",
|
||||||
|
"payload": {"name": "rover", "type": "robot", "in": ["control"], "out": ["telemetry"]},
|
||||||
|
}
|
||||||
|
await send_packet(writer, {"command": "token_ask", "payload": 123, "upf": 456})
|
||||||
|
assert json.loads(await reader.readline()) == {
|
||||||
|
"command": "token_answer", "payload": {"token": None, "upf": None},
|
||||||
|
}
|
||||||
|
await send_packet(writer, {"command": "handshake_ask"})
|
||||||
|
assert json.loads(await reader.readline()) == {
|
||||||
|
"command": "handshake_answer", "payload": {"status": True, "state": None},
|
||||||
|
}
|
||||||
|
assert json.loads(await reader.readline()) == {"command": "heartbeat_ask", "payload": {}}
|
||||||
|
await send_packet(writer, {"command": "control", "payload": {"turn": 1}})
|
||||||
|
await send_packet(writer, {"command": "heartbeat_answer"})
|
||||||
|
assert json.loads(await reader.readline()) == {
|
||||||
|
"command": "telemetry", "payload": {"temperature": 42.0, "battery": 87},
|
||||||
|
}
|
||||||
|
assert await reader.read() == b""
|
||||||
|
except BaseException as error:
|
||||||
|
finished.set_exception(error)
|
||||||
|
else:
|
||||||
|
finished.set_result(None)
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
server = await asyncio.start_server(peer, "127.0.0.1", 0)
|
||||||
|
async with server:
|
||||||
|
client = client_module.Client(
|
||||||
|
"rover", port=server.sockets[0].getsockname()[1],
|
||||||
|
node_type="robot", inputs=["control"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
assert (client.uid, client.upf) == (123, 456)
|
||||||
|
assert await client.recv() == {"command": "control", "payload": {"turn": 1}}
|
||||||
|
await client.telemetry()
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
await finished
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_times_out_when_server_does_not_answer(client_module):
|
||||||
|
"""Проверяет таймаут молчащего сервера и освобождение клиентского соединения."""
|
||||||
|
async def scenario():
|
||||||
|
finished = asyncio.Event()
|
||||||
|
|
||||||
|
async def peer(reader, writer):
|
||||||
|
try:
|
||||||
|
await reader.read()
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
finished.set()
|
||||||
|
|
||||||
|
server = await asyncio.start_server(peer, "127.0.0.1", 0)
|
||||||
|
async with server:
|
||||||
|
client = client_module.Client(port=server.sockets[0].getsockname()[1], timeout=0.1)
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
await client.connect()
|
||||||
|
assert client.writer is None
|
||||||
|
await finished.wait()
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_reports_wrong_handshake_packet(client_module):
|
||||||
|
"""Проверяет понятную ошибку и закрытие клиента при неверном ответе handshake."""
|
||||||
|
async def scenario():
|
||||||
|
finished = asyncio.Event()
|
||||||
|
|
||||||
|
async def peer(reader, writer):
|
||||||
|
try:
|
||||||
|
await reader.readline()
|
||||||
|
await send_packet(writer, {"command": "handshake_ask"})
|
||||||
|
await reader.read()
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
finished.set()
|
||||||
|
|
||||||
|
server = await asyncio.start_server(peer, "127.0.0.1", 0)
|
||||||
|
async with server:
|
||||||
|
client = client_module.Client(port=server.sockets[0].getsockname()[1])
|
||||||
|
with pytest.raises(client_module.ProtocolError, match="Expected token_ask"):
|
||||||
|
await client.connect()
|
||||||
|
assert client.writer is None
|
||||||
|
await finished.wait()
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||||
321
tests/test_client_run.py
Normal file
321
tests/test_client_run.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Integration checks using the same client as src/.test/start_client.bat."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class RunningServer:
|
||||||
|
def __init__(self, client_module):
|
||||||
|
self.client_module = client_module
|
||||||
|
self.clients = []
|
||||||
|
self.handlers = []
|
||||||
|
self.watchers = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
# Lazy imports keep client-only tests runnable if the server has an error.
|
||||||
|
from router import Router
|
||||||
|
from session import Sessions
|
||||||
|
from transport.tcp.handler import handle
|
||||||
|
|
||||||
|
errors = self.errors
|
||||||
|
watchers = self.watchers
|
||||||
|
|
||||||
|
class ObservedSessions(Sessions):
|
||||||
|
async def new(self, connection):
|
||||||
|
uid = await super().new(connection)
|
||||||
|
watchers.append(self[uid].watcher_task)
|
||||||
|
return uid
|
||||||
|
|
||||||
|
async def feed(self, uid, packet):
|
||||||
|
try:
|
||||||
|
await super().feed(uid, packet)
|
||||||
|
except Exception as error:
|
||||||
|
# Preserve the original error before the handler enters
|
||||||
|
# its delayed cleanup and closes the client's transport.
|
||||||
|
errors.append(error)
|
||||||
|
raise
|
||||||
|
|
||||||
|
self.router = Router()
|
||||||
|
self.sessions = ObservedSessions(self.router)
|
||||||
|
self.router.sessions = self.sessions
|
||||||
|
|
||||||
|
def connected(reader, writer):
|
||||||
|
task = asyncio.create_task(handle(reader, writer, self.sessions))
|
||||||
|
self.handlers.append(task)
|
||||||
|
task.add_done_callback(self.handler_finished)
|
||||||
|
|
||||||
|
self.server = await asyncio.start_server(connected, "127.0.0.1", 0)
|
||||||
|
self.port = self.server.sockets[0].getsockname()[1]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def handler_finished(self, task):
|
||||||
|
if not task.cancelled() and task.exception() is not None:
|
||||||
|
self.errors.append(task.exception())
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback):
|
||||||
|
self.server.close()
|
||||||
|
await self.server.wait_closed()
|
||||||
|
close_results = await asyncio.gather(
|
||||||
|
*(client.close() for client in self.clients), return_exceptions=True,
|
||||||
|
)
|
||||||
|
# Stop pending recovery-window waits after assertions, without modifying
|
||||||
|
# the production timer or waiting 60 seconds for each test connection.
|
||||||
|
tasks = self.handlers + self.watchers
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
|
||||||
|
self.errors.append(result)
|
||||||
|
if exc_type is None and self.errors:
|
||||||
|
raise self.errors[0]
|
||||||
|
if exc_type is None:
|
||||||
|
for result in close_results:
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
raise result
|
||||||
|
|
||||||
|
async def client(
|
||||||
|
self, name, inputs=("control",), outputs=("telemetry",),
|
||||||
|
node_type="test", **recovery,
|
||||||
|
):
|
||||||
|
client = self.client_module.Client(
|
||||||
|
name, port=self.port, timeout=1,
|
||||||
|
inputs=inputs, outputs=outputs, node_type=node_type,
|
||||||
|
)
|
||||||
|
self.clients.append(client)
|
||||||
|
try:
|
||||||
|
await client.connect(**recovery)
|
||||||
|
except (TimeoutError, self.client_module.ProtocolError) as error:
|
||||||
|
if self.errors:
|
||||||
|
raise self.errors[0] from error
|
||||||
|
raise
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def disconnect(self, client):
|
||||||
|
# A half-close proves that the SERVER closes the transport immediately.
|
||||||
|
client.writer.write_eof()
|
||||||
|
assert await asyncio.wait_for(client.reader.read(), 1) == b""
|
||||||
|
await client.close()
|
||||||
|
assert self.sessions[client.uid].state == "stop"
|
||||||
|
assert client.uid in self.sessions.sessions
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_connection_telemetry_heartbeat_and_disconnect(client_module):
|
||||||
|
"""Проверяет handshake, метаданные, 10 пакетов, heartbeat и сохранение после EOF."""
|
||||||
|
async def scenario():
|
||||||
|
async with RunningServer(client_module) as server:
|
||||||
|
client = await server.client("rover")
|
||||||
|
session = server.sessions[client.uid]
|
||||||
|
assert session.state == "air"
|
||||||
|
assert session.upf == client.upf
|
||||||
|
assert session.static_meta["name"] == "rover"
|
||||||
|
assert session.static_meta["type"] == "test"
|
||||||
|
assert session.static_meta["in"] == {"control"}
|
||||||
|
assert session.static_meta["out"] == {"telemetry"}
|
||||||
|
assert session.dynamic_meta["active_in"] == set()
|
||||||
|
assert session.dynamic_meta["active_out"] == set()
|
||||||
|
assert client.uid in server.router.session_routes
|
||||||
|
|
||||||
|
deadline = session.timeout
|
||||||
|
for index in range(10):
|
||||||
|
await client.telemetry(temperature=42.0 + index, battery=87 - index)
|
||||||
|
assert await client.heartbeat() == {"command": "heartbeat_answer"}
|
||||||
|
assert session.last_data == {
|
||||||
|
"telemetry": {"temperature": 42.0 + index, "battery": 87 - index},
|
||||||
|
}
|
||||||
|
assert session.timeout >= deadline
|
||||||
|
deadline = session.timeout
|
||||||
|
# Rapid heartbeats may share one monotonic clock tick. Exact renewal
|
||||||
|
# is checked separately with a controlled clock.
|
||||||
|
|
||||||
|
await server.disconnect(client)
|
||||||
|
assert session.last_data == {"telemetry": {"temperature": 51.0, "battery": 78}}
|
||||||
|
assert client.uid in server.router.session_routes
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 10))
|
||||||
|
|
||||||
|
|
||||||
|
def test_logical_errors_disconnect_before_handshake(client_module):
|
||||||
|
"""Проверяет закрытие TCP после пяти команд в неправильном состоянии протокола."""
|
||||||
|
async def scenario():
|
||||||
|
async with RunningServer(client_module) as server:
|
||||||
|
client = client_module.Client(port=server.port, timeout=1)
|
||||||
|
server.clients.append(client)
|
||||||
|
client.reader, client.writer = await asyncio.open_connection("127.0.0.1", server.port)
|
||||||
|
# Deliberately answer a handshake that the server has not requested.
|
||||||
|
for _ in range(5):
|
||||||
|
await client.send("handshake_answer", {"status": True, "state": None})
|
||||||
|
assert await asyncio.wait_for(client.reader.read(), 1) == b""
|
||||||
|
assert len(server.sessions.sessions) == 1
|
||||||
|
session = next(iter(server.sessions.sessions.values()))
|
||||||
|
assert session.state == "stop"
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_preserves_data_and_moves_both_route_directions(client_module):
|
||||||
|
"""Проверяет восстановление данных и фактическую передачу по маршрутам в обе стороны."""
|
||||||
|
async def scenario():
|
||||||
|
async with RunningServer(client_module) as server:
|
||||||
|
old = await server.client("old-rover")
|
||||||
|
peer = await server.client(
|
||||||
|
"peer", inputs=["telemetry"], outputs=["control"], node_type="operator",
|
||||||
|
)
|
||||||
|
# Route creation has no API yet; explicitly seed both directions.
|
||||||
|
server.router.session_routes[old.uid].add(peer.uid)
|
||||||
|
server.router.session_routes[peer.uid].add(old.uid)
|
||||||
|
await old.telemetry()
|
||||||
|
await old.heartbeat()
|
||||||
|
assert await peer.recv() == {
|
||||||
|
"command": "telemetry", "payload": {"temperature": 42.0, "battery": 87},
|
||||||
|
}
|
||||||
|
server.sessions[old.uid].dynamic_meta = {
|
||||||
|
"active_in": {"control"}, "active_out": {"telemetry"},
|
||||||
|
}
|
||||||
|
last_data = deepcopy(server.sessions[old.uid].last_data)
|
||||||
|
dynamic_meta = deepcopy(server.sessions[old.uid].dynamic_meta)
|
||||||
|
await server.disconnect(old)
|
||||||
|
|
||||||
|
restored = await server.client("restored-rover", old_token=old.uid, old_upf=old.upf)
|
||||||
|
assert restored.uid != old.uid
|
||||||
|
assert server.sessions[restored.uid].state == "air"
|
||||||
|
assert server.sessions[restored.uid].last_data == last_data
|
||||||
|
assert server.sessions[restored.uid].dynamic_meta == dynamic_meta
|
||||||
|
assert old.uid not in server.router.session_routes
|
||||||
|
assert set(server.router.session_routes[restored.uid]) == {peer.uid}
|
||||||
|
assert set(server.router.session_routes[peer.uid]) == {restored.uid}
|
||||||
|
|
||||||
|
await peer.send("control", {"turn": 1})
|
||||||
|
await peer.heartbeat()
|
||||||
|
assert await restored.recv() == {"command": "control", "payload": {"turn": 1}}
|
||||||
|
await restored.telemetry(43.0, 86)
|
||||||
|
await restored.heartbeat()
|
||||||
|
assert await peer.recv() == {
|
||||||
|
"command": "telemetry", "payload": {"temperature": 43.0, "battery": 86},
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 10))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["active", "wrong_upf", "missing"])
|
||||||
|
def test_recovery_does_not_take_an_ineligible_session(client_module, reason):
|
||||||
|
"""Проверяет отказ переноса для активной/отсутствующей сессии и неверного upf."""
|
||||||
|
async def scenario():
|
||||||
|
async with RunningServer(client_module) as server:
|
||||||
|
old = await server.client("old-rover")
|
||||||
|
await old.telemetry()
|
||||||
|
await old.heartbeat()
|
||||||
|
if reason != "active":
|
||||||
|
await server.disconnect(old)
|
||||||
|
uid = -1 if reason == "missing" else old.uid
|
||||||
|
upf = old.upf + 1 if reason == "wrong_upf" else old.upf
|
||||||
|
fresh = await server.client("fresh-rover", old_token=uid, old_upf=upf)
|
||||||
|
assert server.sessions[fresh.uid].last_data == {}
|
||||||
|
assert old.uid in server.router.session_routes
|
||||||
|
assert server.sessions[old.uid].state == ("air" if reason == "active" else "stop")
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 10))
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_recovery_proof_can_only_be_used_once(client_module):
|
||||||
|
"""Проверяет, что повторный старый UID/upf не забирает уже восстановленные данные."""
|
||||||
|
async def scenario():
|
||||||
|
async with RunningServer(client_module) as server:
|
||||||
|
old = await server.client("old-rover")
|
||||||
|
await old.telemetry()
|
||||||
|
await old.heartbeat()
|
||||||
|
await server.disconnect(old)
|
||||||
|
restored = await server.client("restored-rover", old_token=old.uid, old_upf=old.upf)
|
||||||
|
expected = {"telemetry": {"temperature": 42.0, "battery": 87}}
|
||||||
|
assert server.sessions[restored.uid].last_data == expected
|
||||||
|
repeated = await server.client("repeated-rover", old_token=old.uid, old_upf=old.upf)
|
||||||
|
assert server.sessions[repeated.uid].last_data == {}
|
||||||
|
assert server.sessions[restored.uid].last_data == expected
|
||||||
|
assert restored.uid in server.router.session_routes
|
||||||
|
assert old.uid not in server.router.session_routes
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 10))
|
||||||
|
|
||||||
|
|
||||||
|
async def expect_timeout_close(client, session):
|
||||||
|
read = asyncio.create_task(client.reader.read())
|
||||||
|
try:
|
||||||
|
done, _ = await asyncio.wait(
|
||||||
|
{read, session.watcher_task}, timeout=1,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
assert done, "Воркер не обработал истечение дедлайна"
|
||||||
|
if session.watcher_task in done:
|
||||||
|
# Expose a crashed watcher rather than just a later socket timeout.
|
||||||
|
await session.watcher_task
|
||||||
|
assert await asyncio.wait_for(read, 1) == b""
|
||||||
|
assert session.state == "stop"
|
||||||
|
finally:
|
||||||
|
if not read.done():
|
||||||
|
read.cancel()
|
||||||
|
await asyncio.gather(read, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["new", "token", "ready", "air"])
|
||||||
|
@pytest.mark.usefixtures("make_manager")
|
||||||
|
def test_server_timeout_closes_transport_in_each_phase(client_module, timing, state):
|
||||||
|
"""Проверяет закрытие настоящего TCP по таймауту на каждом этапе подключения."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing, RunningServer(client_module) as server:
|
||||||
|
client = client_module.Client(port=server.port, timeout=1)
|
||||||
|
server.clients.append(client)
|
||||||
|
client.reader, client.writer = await asyncio.open_connection("127.0.0.1", server.port)
|
||||||
|
async with asyncio.timeout(1):
|
||||||
|
while not server.sessions.sessions:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
session = next(iter(server.sessions.sessions.values()))
|
||||||
|
if state in {"token", "ready", "air"}:
|
||||||
|
await client.send("connection", {"name": "idle-rover", "type": "test", "in": [], "out": ["telemetry"]})
|
||||||
|
token = await client.recv()
|
||||||
|
assert token["command"] == "token_ask"
|
||||||
|
if state in {"ready", "air"}:
|
||||||
|
await client.send("token_answer", {"token": None, "upf": None})
|
||||||
|
assert await client.recv() == {"command": "handshake_ask"}
|
||||||
|
if state == "air":
|
||||||
|
await client.send("handshake_answer", {"status": True, "state": None})
|
||||||
|
await client.heartbeat()
|
||||||
|
assert session.state == state
|
||||||
|
request = await timing.sleeping("session", session.watcher_task)
|
||||||
|
timing.clock.advance(session.timeout - timing.clock())
|
||||||
|
request.release()
|
||||||
|
await expect_timeout_close(client, session)
|
||||||
|
assert server.sessions[session.uid] is session
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("make_manager")
|
||||||
|
def test_server_heartbeat_prevents_stale_deadline_disconnect(client_module, timing):
|
||||||
|
"""Проверяет по TCP продление heartbeat и последующее отключение при тишине."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing, RunningServer(client_module) as server:
|
||||||
|
client = await server.client("rover")
|
||||||
|
session = server.sessions[client.uid]
|
||||||
|
request = await timing.sleeping("session", session.watcher_task)
|
||||||
|
old_deadline = session.timeout
|
||||||
|
timing.clock.advance(4)
|
||||||
|
await client.heartbeat()
|
||||||
|
assert session.timeout > old_deadline
|
||||||
|
timing.clock.advance(old_deadline - timing.clock())
|
||||||
|
request.release()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if session.watcher_task.done():
|
||||||
|
await session.watcher_task
|
||||||
|
assert session.state == "air"
|
||||||
|
await client.heartbeat()
|
||||||
|
request = await timing.sleeping("session", session.watcher_task)
|
||||||
|
timing.clock.advance(session.timeout - timing.clock())
|
||||||
|
request.release()
|
||||||
|
await expect_timeout_close(client, session)
|
||||||
|
assert client.uid in server.sessions.sessions
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||||
92
tests/test_router.py
Normal file
92
tests/test_router.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Инварианты графа маршрутов и выбор полезной нагрузки при пересылке."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def router():
|
||||||
|
"""Создаёт настоящий Router независимо от менеджера и сессий."""
|
||||||
|
from router import Router
|
||||||
|
return Router()
|
||||||
|
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
def __init__(self, last_data=None):
|
||||||
|
self.last_data = last_data or {}
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send(self, packet):
|
||||||
|
self.sent.append(deepcopy(packet))
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_creates_empty_routes_and_preserves_existing_edges(router):
|
||||||
|
"""Проверяет создание множества маршрутов и сохранение связей при повторном add."""
|
||||||
|
router.add(1)
|
||||||
|
assert router.session_routes[1] == set()
|
||||||
|
router.session_routes[1].add(2)
|
||||||
|
router.add(1)
|
||||||
|
assert router.session_routes[1] == {2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_rewrites_only_edges_pointing_to_old_uid(router):
|
||||||
|
"""Проверяет перенос исходящих/входящих связей без изменения несвязанных маршрутов."""
|
||||||
|
router.session_routes = {1: {2}, 2: {1, 4}, 3: set(), 4: {2}, 5: set()}
|
||||||
|
router.move(1, 5)
|
||||||
|
assert router.session_routes == {2: {5, 4}, 3: set(), 4: {2}, 5: {2}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_handles_self_route(router):
|
||||||
|
"""Проверяет замену UID в маршруте, который указывал на собственную сессию."""
|
||||||
|
router.session_routes = {1: {1, 2}, 2: {1}}
|
||||||
|
router.move(1, 3)
|
||||||
|
assert router.session_routes == {2: {3}, 3: {3, 2}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_missing_uid_does_not_change_graph(router):
|
||||||
|
"""Проверяет безопасный перенос отсутствующего UID без добавления новых связей."""
|
||||||
|
router.session_routes = {1: {2}, 2: set()}
|
||||||
|
before = deepcopy(router.session_routes)
|
||||||
|
router.move(99, 100)
|
||||||
|
assert router.session_routes == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_clears_outgoing_and_incoming_edges(router):
|
||||||
|
"""Проверяет удаление узла и всех ссылок на него после окончательной очистки."""
|
||||||
|
router.session_routes = {1: {2}, 2: {1, 3}, 3: {1}, 4: set()}
|
||||||
|
router.remove(1)
|
||||||
|
assert router.session_routes == {2: {3}, 3: set(), 4: set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_missing_uid_is_safe(router):
|
||||||
|
"""Проверяет повторное удаление уже отсутствующего UID."""
|
||||||
|
router.session_routes = {1: set()}
|
||||||
|
router.remove(99)
|
||||||
|
assert router.session_routes == {1: set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_delivers_source_payload_to_each_target(router):
|
||||||
|
"""Проверяет fan-out, данные отправителя и отсутствие доставки несвязанным узлам."""
|
||||||
|
nodes = {
|
||||||
|
1: Node({"telemetry": {"battery": 87}}),
|
||||||
|
2: Node({"telemetry": {"battery": 1}}),
|
||||||
|
3: Node(),
|
||||||
|
4: Node(),
|
||||||
|
}
|
||||||
|
router.sessions = nodes
|
||||||
|
router.session_routes = {1: {2, 3}, 2: set(), 3: set(), 4: set()}
|
||||||
|
asyncio.run(router.process(1, "telemetry"))
|
||||||
|
expected = [{"command": "telemetry", "payload": {"battery": 87}}]
|
||||||
|
assert nodes[2].sent == expected
|
||||||
|
assert nodes[3].sent == expected
|
||||||
|
assert nodes[1].sent == nodes[4].sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_with_no_routes_sends_nothing(router):
|
||||||
|
"""Проверяет отсутствие доставки при пустом множестве получателей."""
|
||||||
|
router.sessions = {1: Node({"telemetry": {"battery": 87}}), 2: Node()}
|
||||||
|
router.session_routes = {1: set(), 2: set()}
|
||||||
|
asyncio.run(router.process(1, "telemetry"))
|
||||||
|
assert router.sessions[1].sent == router.sessions[2].sent == []
|
||||||
239
tests/test_session.py
Normal file
239
tests/test_session.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""Переходы протокола и жизненный цикл таймера отдельной сессии."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def run(scenario):
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 3))
|
||||||
|
|
||||||
|
|
||||||
|
async def tick(request, session):
|
||||||
|
request.release()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if session.watcher_task.done() and not session.watcher_task.cancelled():
|
||||||
|
error = session.watcher_task.exception()
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_deadline_and_watcher(make_session, timing):
|
||||||
|
"""Проверяет начальное окно в 60 секунд и запуск собственного воркера сессии."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
assert case.session.state == "new"
|
||||||
|
assert case.session.timeout == timing.clock() + 60
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
assert request.delay > 0
|
||||||
|
assert case.events == []
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["new", "token", "ready", "air"])
|
||||||
|
def test_expired_deadline_emits_one_disconnect(make_session, timing, state):
|
||||||
|
"""Проверяет однократное timeout-событие во всех незавершённых и активном состояниях."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = state
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
timing.clock.advance(60)
|
||||||
|
request.release()
|
||||||
|
await asyncio.wait_for(case.session.watcher_task, 1)
|
||||||
|
assert case.events == [(case.session.uid, {"type": "disconnect"})]
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watcher_does_not_disconnect_before_deadline(make_session, timing):
|
||||||
|
"""Проверяет, что воркер не отключает сессию раньше её дедлайна."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
timing.clock.advance(59)
|
||||||
|
await tick(request, case.session)
|
||||||
|
assert case.events == []
|
||||||
|
assert not case.session.watcher_task.done()
|
||||||
|
await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_extends_current_deadline(make_session, timing):
|
||||||
|
"""Проверяет перенос дедлайна heartbeat и отсутствие отключения по старому сроку."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = "air"
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
timing.clock.advance(10)
|
||||||
|
await case.session.feed({"command": "heartbeat_ask", "payload": {}})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert case.session.timeout == timing.clock() + 5
|
||||||
|
assert case.connection.sent == [{"command": "heartbeat_answer"}]
|
||||||
|
assert case.session.last_data == {}
|
||||||
|
|
||||||
|
timing.clock.advance(4)
|
||||||
|
await case.session.feed({"command": "heartbeat_ask", "payload": {}})
|
||||||
|
new_deadline = case.session.timeout
|
||||||
|
timing.clock.advance(1)
|
||||||
|
await tick(request, case.session)
|
||||||
|
assert case.events == []
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
timing.clock.advance(new_deadline - timing.clock())
|
||||||
|
request.release()
|
||||||
|
await asyncio.wait_for(case.session.watcher_task, 1)
|
||||||
|
assert case.events == [(case.session.uid, {"type": "disconnect"})]
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["stop", "migrated"])
|
||||||
|
def test_terminal_session_does_not_emit_timeout(make_session, timing, state):
|
||||||
|
"""Проверяет завершение воркера без timeout для отключённой или перенесённой сессии."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
case.session.state = state
|
||||||
|
timing.clock.advance(100)
|
||||||
|
request.release()
|
||||||
|
await asyncio.wait_for(case.session.watcher_task, 1)
|
||||||
|
assert case.events == []
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_closes_transport_and_finishes_watcher(make_session, timing):
|
||||||
|
"""Проверяет закрытие соединения, состояние stop и завершение таймерного воркера."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
request = await timing.sleeping("session", case.session.watcher_task)
|
||||||
|
await case.session.stop()
|
||||||
|
assert case.session.state == "stop"
|
||||||
|
assert case.connection.close_count == 1
|
||||||
|
request.release()
|
||||||
|
await asyncio.wait_for(case.session.watcher_task, 1)
|
||||||
|
assert case.events == []
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_and_successful_handshake(make_session, timing):
|
||||||
|
"""Проверяет new→token→ready→air, ответы сервера и метаданные портов."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
session = case.session
|
||||||
|
assert await session.feed({"command": "connection", "payload": {
|
||||||
|
"name": "rover", "type": "robot", "in": ["control"], "out": ["telemetry"],
|
||||||
|
}}) is None
|
||||||
|
assert session.state == "token"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert case.connection.sent == [{"command": "token_ask", "payload": session.uid, "upf": session.upf}]
|
||||||
|
assert session.static_meta["name"] == "rover"
|
||||||
|
assert session.static_meta["type"] == "robot"
|
||||||
|
assert session.static_meta["in"] == {"control"}
|
||||||
|
assert session.static_meta["out"] == {"telemetry"}
|
||||||
|
assert session.dynamic_meta == {"active_in": set(), "active_out": set()}
|
||||||
|
|
||||||
|
assert await session.feed({"command": "token_answer", "payload": {"token": None, "upf": None}}) is None
|
||||||
|
assert session.state == "ready"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert case.connection.sent[-1] == {"command": "handshake_ask"}
|
||||||
|
assert await session.feed({"command": "handshake_answer", "payload": {"status": True, "state": None}}) == {
|
||||||
|
"type": "connect", "uid": session.uid,
|
||||||
|
}
|
||||||
|
assert session.state == "air"
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_request_still_requires_own_handshake(make_session, timing):
|
||||||
|
"""Проверяет нормализацию старого UID/upf и ожидание отдельного нового handshake."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = "token"
|
||||||
|
event = await case.session.feed({"command": "token_answer", "payload": {"token": 123, "upf": 456}})
|
||||||
|
assert event == {"type": "reconnect", "uid": 123, "upf": 456}
|
||||||
|
assert case.session.state == "ready"
|
||||||
|
event = await case.session.feed({"command": "handshake_answer", "payload": {"status": True}})
|
||||||
|
assert event == {"type": "connect", "uid": case.session.uid}
|
||||||
|
assert case.session.state == "air"
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_updates_latest_value_for_each_command(make_session, timing):
|
||||||
|
"""Проверяет нормализованные data-события и замену последнего значения по команде."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = "air"
|
||||||
|
for command, payload in [("telemetry", {"battery": 87}), ("status", {"ok": True}), ("telemetry", {"battery": 86})]:
|
||||||
|
assert await case.session.feed({"command": command, "payload": payload}) == {"type": "data", "data": command}
|
||||||
|
assert case.session.last_data == {"telemetry": {"battery": 86}, "status": {"ok": True}}
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["new", "token", "ready"])
|
||||||
|
def test_wrong_sequence_disconnects_at_error_limit(make_session, timing, state):
|
||||||
|
"""Проверяет четыре допустимых логических ошибки и disconnect на пятой."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = state
|
||||||
|
for _ in range(4):
|
||||||
|
assert await case.session.feed({"command": "telemetry", "payload": {}}) is None
|
||||||
|
assert await case.session.feed({"command": "telemetry", "payload": {}}) == {"type": "disconnect"}
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_command_requests_disconnect(make_session, timing):
|
||||||
|
"""Проверяет немедленное disconnect-событие у пакета без команды."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
assert await case.session.feed({"payload": {}}) == {"type": "disconnect"}
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_handshake_waits_before_retry(make_session, timing):
|
||||||
|
"""Проверяет реальное ожидание задержки перед повторным запросом handshake."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = "ready"
|
||||||
|
task = timing.spawn(case.session.feed({"command": "handshake_answer", "payload": {"status": False, "state": "booting"}}))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert not task.done(), "Повторный handshake выполняется без ожидания задержки"
|
||||||
|
request = await timing.sleeping("session", task)
|
||||||
|
assert request.delay == 1
|
||||||
|
assert case.connection.sent == []
|
||||||
|
request.release()
|
||||||
|
assert await task == {"type": "await handshake", "state": "booting"}
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert case.connection.sent == [{"command": "handshake_ask"}]
|
||||||
|
assert case.session.state == "ready"
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handshake_retry_limit_requests_disconnect(make_session, timing):
|
||||||
|
"""Проверяет отказ от дальнейших повторов после пяти неудачных retry handshake."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
case.session.state = "ready"
|
||||||
|
case.session.timeout_attempt = 5
|
||||||
|
assert await case.session.feed({"command": "handshake_answer", "payload": {"status": False}}) == {"type": "disconnect"}
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopped_session_does_not_send_packets(make_session, timing):
|
||||||
|
"""Проверяет, что после stop новые отправки не поступают закрытому соединению."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_session()
|
||||||
|
await case.session.stop()
|
||||||
|
case.session.send({"command": "telemetry", "payload": {}})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert case.connection.sent == []
|
||||||
|
run(scenario)
|
||||||
193
tests/test_session_manager.py
Normal file
193
tests/test_session_manager.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""События менеджера, окно восстановления и гонки с отложенной очисткой."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def run(scenario):
|
||||||
|
asyncio.run(asyncio.wait_for(scenario(), 3))
|
||||||
|
|
||||||
|
|
||||||
|
async def new_session(case, state="air"):
|
||||||
|
connection = case.connection()
|
||||||
|
uid = await case.manager.new(connection)
|
||||||
|
case.manager[uid].state = state
|
||||||
|
if state == "air":
|
||||||
|
case.router.add(uid)
|
||||||
|
return uid, connection
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_wait(request, allow_cancel=False):
|
||||||
|
request.release()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(request.task, 1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not allow_cancel:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_connections_have_distinct_live_uids(make_manager, timing):
|
||||||
|
"""Проверяет разные UID одновременно существующих соединений."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
first, _ = await new_session(case)
|
||||||
|
second, _ = await new_session(case)
|
||||||
|
assert first != second
|
||||||
|
assert len(case.manager.sessions) == 2
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_and_data_events_reach_router(make_manager, timing):
|
||||||
|
"""Проверяет регистрацию connect и передачу UID/команды data роутеру."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
uid, _ = await new_session(case, "ready")
|
||||||
|
await case.manager.process(uid, {"type": "connect", "uid": uid})
|
||||||
|
assert uid in case.router.routes
|
||||||
|
await case.manager.process(uid, {"type": "data", "data": "telemetry"})
|
||||||
|
assert case.router.data == [(uid, "telemetry")]
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_event_stops_transport_and_retains_record(make_manager, timing):
|
||||||
|
"""Проверяет немедленное закрытие транспорта по событию disconnect и начало окна восстановления."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
uid, connection = await new_session(case)
|
||||||
|
task = timing.spawn(case.manager.process(uid, {"type": "disconnect"}))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert connection.close_count == 1, "Менеджер не обработал событие disconnect"
|
||||||
|
assert case.manager[uid].state == "stop"
|
||||||
|
assert uid in case.manager.sessions
|
||||||
|
request = await timing.sleeping("manager")
|
||||||
|
assert request.delay == 60
|
||||||
|
timing.clock.advance(60)
|
||||||
|
await finish_wait(request)
|
||||||
|
await asyncio.wait_for(task, 1)
|
||||||
|
# If cleanup is detached, wait for its observable effect, not an arbitrary pause.
|
||||||
|
async with asyncio.timeout(1):
|
||||||
|
while uid in case.manager.sessions:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert uid not in case.router.routes
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_preserves_session_until_recovery_window_expires(make_manager, timing):
|
||||||
|
"""Проверяет сохранение сессии на 60 секунд и удаление записи/маршрутов после окна."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
uid, connection = await new_session(case)
|
||||||
|
case.manager[uid].last_data = {"telemetry": {"battery": 87}}
|
||||||
|
task = timing.spawn(case.manager.close(uid))
|
||||||
|
request = await timing.sleeping("manager")
|
||||||
|
assert request.delay == 60
|
||||||
|
assert connection.close_count == 1
|
||||||
|
assert case.manager[uid].state == "stop"
|
||||||
|
assert case.manager[uid].last_data == {"telemetry": {"battery": 87}}
|
||||||
|
assert uid in case.router.routes
|
||||||
|
timing.clock.advance(60)
|
||||||
|
await finish_wait(request)
|
||||||
|
await task
|
||||||
|
assert uid not in case.manager.sessions
|
||||||
|
assert uid not in case.router.routes
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["active", "wrong_upf", "missing", "migrated"])
|
||||||
|
def test_ineligible_recovery_does_not_move_data(make_manager, timing, reason):
|
||||||
|
"""Проверяет отказ восстановления активной, отсутствующей, перенесённой сессии и неверного upf."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
old_uid, _ = await new_session(case)
|
||||||
|
old = case.manager[old_uid]
|
||||||
|
old.state = "air" if reason == "active" else ("migrated" if reason == "migrated" else "stop")
|
||||||
|
old.last_data = {"telemetry": {"battery": 87}}
|
||||||
|
new_uid, _ = await new_session(case, "ready")
|
||||||
|
await case.manager.process(new_uid, {
|
||||||
|
"type": "reconnect",
|
||||||
|
"uid": -1 if reason == "missing" else old_uid,
|
||||||
|
"upf": old.upf + 1 if reason == "wrong_upf" else old.upf,
|
||||||
|
})
|
||||||
|
assert case.router.moves == []
|
||||||
|
assert case.manager[new_uid].last_data == {}
|
||||||
|
assert old.last_data == {"telemetry": {"battery": 87}}
|
||||||
|
assert case.manager[new_uid].state == "ready"
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_recovery_preserves_data_and_is_consumed_once(make_manager, timing):
|
||||||
|
"""Проверяет перенос динамических данных ровно один раз и сохранение собственного handshake."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
old_uid, _ = await new_session(case)
|
||||||
|
old = case.manager[old_uid]
|
||||||
|
old.state = "stop"
|
||||||
|
old.last_data = {"telemetry": {"battery": 87}}
|
||||||
|
old.dynamic_meta = {"active_in": {"control"}, "active_out": {"telemetry"}}
|
||||||
|
expected_data = deepcopy(old.last_data)
|
||||||
|
expected_meta = deepcopy(old.dynamic_meta)
|
||||||
|
new_uid, _ = await new_session(case, "ready")
|
||||||
|
event = {"type": "reconnect", "uid": old_uid, "upf": old.upf}
|
||||||
|
await case.manager.process(new_uid, event)
|
||||||
|
assert case.manager[new_uid].last_data == expected_data
|
||||||
|
assert case.manager[new_uid].dynamic_meta == expected_meta
|
||||||
|
assert case.manager[new_uid].state == "ready"
|
||||||
|
assert case.router.moves == [(old_uid, new_uid)]
|
||||||
|
|
||||||
|
repeated_uid, _ = await new_session(case, "ready")
|
||||||
|
await case.manager.process(repeated_uid, event)
|
||||||
|
assert case.manager[repeated_uid].last_data == {}
|
||||||
|
assert case.router.moves == [(old_uid, new_uid)]
|
||||||
|
assert case.manager[new_uid].last_data == expected_data
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_session_cannot_be_recovered(make_manager, timing):
|
||||||
|
"""Проверяет отказ восстановления после удаления старой сессии по 60-секундному таймеру."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
old_uid, _ = await new_session(case)
|
||||||
|
old_upf = case.manager[old_uid].upf
|
||||||
|
task = timing.spawn(case.manager.close(old_uid))
|
||||||
|
request = await timing.sleeping("manager")
|
||||||
|
timing.clock.advance(60)
|
||||||
|
await finish_wait(request)
|
||||||
|
await task
|
||||||
|
new_uid, _ = await new_session(case, "ready")
|
||||||
|
await case.manager.process(new_uid, {"type": "reconnect", "uid": old_uid, "upf": old_upf})
|
||||||
|
assert case.router.moves == []
|
||||||
|
assert case.manager[new_uid].last_data == {}
|
||||||
|
run(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_cleanup_cannot_delete_recovered_session(make_manager, timing):
|
||||||
|
"""Проверяет, что старый таймер после восстановления не удаляет новую запись и её данные."""
|
||||||
|
async def scenario():
|
||||||
|
async with timing:
|
||||||
|
case = make_manager()
|
||||||
|
old_uid, _ = await new_session(case)
|
||||||
|
old = case.manager[old_uid]
|
||||||
|
old.last_data = {"telemetry": {"battery": 87}}
|
||||||
|
cleanup = timing.spawn(case.manager.close(old_uid))
|
||||||
|
request = await timing.sleeping("manager")
|
||||||
|
new_uid, _ = await new_session(case, "ready")
|
||||||
|
restored = case.manager[new_uid]
|
||||||
|
await case.manager.process(new_uid, {"type": "reconnect", "uid": old_uid, "upf": old.upf})
|
||||||
|
assert restored.last_data == {"telemetry": {"battery": 87}}
|
||||||
|
timing.clock.advance(60)
|
||||||
|
await finish_wait(request, allow_cancel=True)
|
||||||
|
if not cleanup.cancelled():
|
||||||
|
await cleanup
|
||||||
|
assert case.manager[new_uid] is restored
|
||||||
|
assert restored.last_data == {"telemetry": {"battery": 87}}
|
||||||
|
assert new_uid in case.router.routes
|
||||||
|
run(scenario)
|
||||||
27
tests/test_source_validation.py
Normal file
27
tests/test_source_validation.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Проверки исходников, которые работают даже при ошибках импорта сервера."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_python_sources_compile():
|
||||||
|
"""Проверяет синтаксис всех серверных модулей и клиента без запуска и записи pyc."""
|
||||||
|
errors = []
|
||||||
|
for path in sorted((Path(__file__).parents[1] / "src").rglob("*.py")):
|
||||||
|
try:
|
||||||
|
compile(path.read_text(encoding="utf-8"), str(path), "exec")
|
||||||
|
except SyntaxError as error:
|
||||||
|
errors.append(f"{path.relative_to(Path(__file__).parents[1])}:{error.lineno}: {error.msg}")
|
||||||
|
assert not errors, "\n".join(errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_test_has_a_description():
|
||||||
|
"""Проверяет наличие краткого docstring у каждого теста набора."""
|
||||||
|
missing = []
|
||||||
|
for path in Path(__file__).parent.glob("test_*.py"):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
|
||||||
|
if not ast.get_docstring(node):
|
||||||
|
missing.append(f"{path.name}:{node.lineno}: {node.name}")
|
||||||
|
assert not missing, "\n".join(missing)
|
||||||
Reference in New Issue
Block a user