basic working mvp
This commit is contained in:
2026-10-07 15:12:38 +03:00
parent 785a6980f6
commit fac12b2ea8
24 changed files with 1696 additions and 1 deletions

View 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)