lct-hack/backend/tests/test_ws.py
Ivan Gerasimov e7a0798ba7 lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.

Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.

Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.

Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.

Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00

170 lines
8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Каналы занятия целиком: преподаватель запускает, курсант работает,
наблюдатель смотрит и не может вмешаться.
Проверяются пункты приёмки карточки lct-05, а не отдельные функции.
"""
import contextlib
import time
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None # тесты не пишут в БД: проверяется поведение каналов
yield test_client
def wait_for(predicate, timeout: float = 3.0):
"""Ждёт истинного значения. Для чисел не годится: опрос в тесте длится
0 мс, и ноль — законный результат. Для них есть wait_value."""
return _wait(predicate, timeout, lambda value: bool(value))
def wait_value(predicate, timeout: float = 3.0):
"""Ждёт любого значения, кроме None: ноль миллисекунд — тоже измерение."""
return _wait(predicate, timeout, lambda value: value is not None)
def _wait(predicate, timeout: float, ready):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if ready(value):
return value
time.sleep(0.02)
raise AssertionError("не дождались")
def read_until(ws, event_type: str, limit: int = 20) -> dict:
"""Пропускает такты таймера: они идут раз в секунду и мешают разбирать поток."""
for _ in range(limit):
message = ws.receive_json()
if message["type"] == event_type:
return message
raise AssertionError(f"событие {event_type} не пришло")
@contextlib.contextmanager
def lesson(client, mode: str = "training"):
"""Занятие, запущенное преподавателем.
Сокет закрывается выходом из контекстного менеджера, а не `close()`:
`close()` шлёт кадр закрытия, но не дожидается завершения задачи
соединения, и тестовый клиент потом не может закрыться.
"""
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"trainee": "Иванов И.И.",
"mode": mode,
}
)
wait_for(lambda: hub.get(session_id))
yield session_id, control
def test_observer_joining_midway_sees_the_whole_state(client):
"""Монитор в классе включают посреди занятия — он обязан показать
текущее состояние, а не ждать следующего события."""
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "kio.patch", "fields": {"floor": "5"}})
wait_for(lambda: hub.get(session_id).kio.floor == "5")
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
snapshot = observer.receive_json()
assert snapshot["type"] == "session.snapshot"
assert snapshot["scenario_title"] == "Пожар в квартире, паникующий заявитель"
assert snapshot["kio"]["floor"] == "5", "карточка в снимке отстаёт от состояния"
assert snapshot["mode"] == "training"
assert snapshot["required_fields"], "обязательные поля не доехали до наблюдателя"
def test_interview_timer_starts_on_answer_and_stops_on_dispatch(client):
"""Центральная метрика продукта. Таймер останавливается событием,
а не таймаутом: иначе время опроса недоказуемо."""
from app.domain.timers import TimerCode
with lesson(client) as (session_id, _):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
wait_for(lambda: TimerCode.INTERVIEW in state.timers.timers)
assert state.timers.measured_ms(TimerCode.INTERVIEW) is None, "таймер уже остановлен"
trainee.send_json({"type": "dds.dispatch", "service": "01"})
measured = wait_value(lambda: state.timers.measured_ms(TimerCode.INTERVIEW))
assert measured >= 0
assert state.kio.dds.value == "01"
assert state.dispatched_card is not None, "карточка не заморожена снимком"
def test_card_edit_reaches_observer(client):
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
observer.receive_json() # снимок при подключении
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14"}})
message = read_until(observer, "kio.state")
assert message["kio"]["address"] == "улица Ленина, 14"
def test_observer_cannot_influence_the_lesson(client):
"""Разделение прав архитектурное: на сокете наблюдателя нет ни одного
обработчика входящих, поэтому влиять нечем."""
with lesson(client) as (session_id, _):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
observer.receive_json()
observer.send_json({"type": "kio.patch", "fields": {"address": "подмена"}})
observer.send_json({"type": "session.stop"})
time.sleep(0.3)
assert state.kio.address is None, "наблюдатель изменил карточку"
assert not state.ended, "наблюдатель завершил занятие"
def test_hint_is_denied_in_exam_and_given_in_training(client):
with lesson(client, mode="exam") as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "hint.request"})
message = read_until(trainee, "error")
assert message["code"] == "hint_denied_in_exam"
with lesson(client, mode="training") as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "hint.request"})
message = read_until(trainee, "hint.shown")
assert message["question"], "подсказка без вопроса"
assert hub.get(session_id).hints_shown, "использование подсказки не зафиксировано"
def test_instructor_cannot_touch_the_card(client):
"""В канале `control` нет ни одной команды, меняющей карточку курсанта."""
from app.domain.events import InstructorToServer
from typing import get_args
union = get_args(get_args(InstructorToServer)[0])
fields = {name for model in union for name in model.model_fields}
assert "fields" not in fields and "kio" not in fields
def test_call_socket_refuses_session_that_was_not_started(client):
with client.websocket_connect(f"/ws/call/{uuid4()}") as trainee:
message = trainee.receive_json()
assert message["type"] == "error" and message["code"] == "session_not_found"