"""Каналы занятия целиком: преподаватель запускает, курсант работает, наблюдатель смотрит и не может вмешаться. Проверяются пункты приёмки карточки lct-05, а не отдельные функции. """ import asyncio import contextlib import time import wave from uuid import uuid4 import numpy as np import pytest from fastapi.testclient import TestClient from app.api.http import sessions as sessions_http from app.main import app from app.scenarios import store from app.session.hub import hub @pytest.fixture def client(monkeypatch): async def audit_in_memory(*_args, **_kwargs): return None monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory) with TestClient(app) as test_client: # Сокеты закрыты ролями (lct-23): тесты входят так же, # как `make lesson`, — через dev-token за флагом. test_client.post("/api/auth/dev-token") 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_ekp_dispatch_needs_no_manual_dds_and_reaches_station(client): with lesson(client) as (session_id, _): state = hub.get(session_id) with client.websocket_connect(f"/ws/station/{session_id}") as station: with client.websocket_connect(f"/ws/call/{session_id}") as trainee: read_until(trainee, "call.incoming") trainee.send_json({"type": "call.answer"}) trainee.send_json({"type": "kio.patch", "fields": { "signs": ["жилой дом", "балкон", "открытое пламя"] }}) auto = next( event for event in (trainee.receive_json() for _ in range(8)) if event["type"] == "kio.patch" and event["source"] == "auto" ) assert auto["fields"]["incident_code"] assert auto["fields"]["notify"] trainee.send_json({"type": "dds.dispatch"}) received = read_until(station, "card.received") assert received["card"]["notify"] assert received["card"]["dds"] is None assert received["card"]["response_status"] == "transferred" assert state.dispatched_card is not None def test_legacy_dispatch_without_routing_needs_manual_dds(client): with lesson(client) as (session_id, _): state = hub.get(session_id) with client.websocket_connect(f"/ws/call/{session_id}") as trainee: read_until(trainee, "call.incoming") trainee.send_json({"type": "dds.dispatch"}) error = read_until(trainee, "error") assert "ДДС" in error["message"] assert state.dispatched_card is None trainee.send_json({"type": "dds.dispatch", "service": "01"}) wait_for(lambda: state.dispatched_card) assert state.kio.dds.value == "01" def test_late_call_join_replays_incoming_and_current_kio(client): with lesson(client) as (session_id, _): with client.websocket_connect(f"/ws/call/{session_id}") as first: read_until(first, "call.incoming") first.send_json({"type": "kio.patch", "fields": { "address": "улица Ленина, 14", "signs": ["жилой дом", "балкон", "открытое пламя"], }}) wait_for(lambda: hub.get(session_id).kio.incident_code) with client.websocket_connect(f"/ws/call/{session_id}") as late: incoming = read_until(late, "call.incoming") assert incoming["scenario_id"] == "fire-apartment-l2" assert "dds" not in incoming["required_fields"] card = read_until(late, "kio.patch") assert card["fields"]["address"] == "улица Ленина, 14" assert card["fields"]["notify"] 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_control_contract_contains_only_handled_commands(): """Не рекламировать команду WebSocket, у которой нет ветки обработчика.""" import ast from pathlib import Path from app.domain.events import InstructorToServer from typing import get_args union = get_args(get_args(InstructorToServer)[0]) commands = {model.model_fields["type"].default for model in union} source = Path(__file__).parents[1] / "app" / "api" / "ws" / "control.py" tree = ast.parse(source.read_text(encoding="utf-8")) control_matches = [ node for node in ast.walk(tree) if isinstance(node, ast.Match) and isinstance(node.subject, ast.Attribute) and isinstance(node.subject.value, ast.Name) and node.subject.value.id == "event" and node.subject.attr == "type" ] assert len(control_matches) == 1, "не удалось однозначно найти dispatch control-событий" handled = { case.pattern.value.value for case in control_matches[0].cases if isinstance(case.pattern, ast.MatchValue) and isinstance(case.pattern.value, ast.Constant) and isinstance(case.pattern.value.value, str) } assert commands == handled 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" def test_audio_frames_reach_the_server(client): """Веха lct-08: кадры PCM16 16 кГц по 20 мс долетают до бэкенда.""" with lesson(client) as (session_id, _): state = hub.get(session_id) with client.websocket_connect(f"/ws/call/{session_id}") as trainee: for _ in range(50): trainee.send_bytes(b"\x00\x00" * 320) # секунда тишины trainee.send_bytes(b"\x00" * 100) # кадр не того размера wait_value(lambda: state.bad_frames or None) assert state.audio_frames == 50 assert state.bad_frames == 1, "кадр не того размера должен отбрасываться, а не считаться звуком" def test_events_still_work_between_audio_frames(client): """Звук и события идут по одному сокету: бинарь не должен ломать разбор JSON.""" with lesson(client) as (session_id, _): state = hub.get(session_id) with client.websocket_connect(f"/ws/call/{session_id}") as trainee: trainee.send_bytes(b"\x00\x00" * 320) trainee.send_json({"type": "kio.patch", "fields": {"floor": "5"}}) trainee.send_bytes(b"\x00\x00" * 320) wait_for(lambda: state.kio.floor == "5") assert state.audio_frames == 2 def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path): from app.api.ws import call as call_api from app.voice.recording import CallRecorder # Exercise the production audio callback without loading models: caller # audio uses the same send_audio path whether it came from TTS or a test. caller_pcm = (2000).to_bytes(2, "little", signed=True) * 480 class FakeVoice: def __init__(self, *, send_audio, **_kwargs): self.send_audio = send_audio self.speak_count = 0 def speak(self, *_args): self.speak_count += 1 asyncio.get_running_loop().call_later(0.01, self.send_audio, caller_pcm) def feed(self, _pcm): return None async def close(self): return None monkeypatch.setattr( call_api, "start_recording", lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"), ) monkeypatch.setattr(call_api, "get_voice_models", lambda: object()) monkeypatch.setattr(call_api, "VoiceSession", FakeVoice) with lesson(client) as (session_id, _): state = hub.get(session_id) path = tmp_path / f"{session_id}.wav" with client.websocket_connect(f"/ws/call/{session_id}") as trainee: read_until(trainee, "call.incoming") trainee.send_json({"type": "call.answer"}) read_until(trainee, "call.started") wait_for(lambda: state.recorder and len(state.recorder._segments) >= 1) for _ in range(10): outgoing = trainee.receive() if outgoing.get("bytes") is not None: assert outgoing["bytes"] == caller_pcm break else: raise AssertionError("TTS-реплика звонящего не пришла по WebSocket") first_started_at = state.started_at trainee.send_json({"type": "call.answer"}) read_until(trainee, "call.started") assert state.started_at == first_started_at, "повторное подключение перезапустило таймер" assert state.voice.speak_count == 1, "повторное подключение заново проиграло вводную" trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320) wait_for(lambda: len(state.recorder._segments) >= 2) # Drop process-local runtime objects but leave the durable call journal, # as if the backend process had been killed and restored from PostgreSQL. state.recorder._journal.close() state.recorder = None state.voice = None with client.websocket_connect(f"/ws/call/{session_id}") as trainee: read_until(trainee, "call.incoming") read_until(trainee, "call.started") assert len(state.recorder._segments) == 2 assert state.voice.speak_count == 0, "после восстановления повторилась первая реплика" trainee.send_bytes((3000).to_bytes(2, "little", signed=True) * 320) wait_for(lambda: len(state.recorder._segments) >= 3) trainee.send_json({"type": "call.hangup"}) read_until(trainee, "call.ended") wait_for(path.is_file) assert state.recording_path == str(path) with wave.open(str(path), "rb") as source: assert source.getframerate() == 16_000 samples = source.readframes(source.getnframes()) assert source.getnframes() >= 640 decoded = np.frombuffer(samples, dtype="= 0 def test_trainee_gets_no_score_without_self_assessment(client): """Отсутствие события проверяется подпиской на очередь курсанта: ждать его из сокета нечем — чтение заблокируется навсегда.""" from app.domain.events import ScoreReady with lesson(client) as (session_id, _): state = hub.get(session_id) with hub.trainee(session_id) as queue: with client.websocket_connect(f"/ws/call/{session_id}") as trainee: trainee.send_json({"type": "call.answer"}) trainee.send_json({"type": "call.hangup"}) wait_for(lambda: state.score is not None) time.sleep(0.3) delivered = [] while not queue.empty(): delivered.append(queue.get_nowait()) assert not any(isinstance(event, ScoreReady) for event in delivered), ( "оценка ушла курсанту до самооценки" ) def test_checklist_is_closed_until_the_call_is_over(client): """Во время звонка чек-лист — это содержимое подсказок.""" with lesson(client) as (session_id, _): during = client.get(f"/api/sessions/{session_id}/checklist") assert during.status_code == 409, during.text with client.websocket_connect(f"/ws/call/{session_id}") as trainee: trainee.send_json({"type": "call.answer"}) trainee.send_json({"type": "call.hangup"}) wait_for(lambda: hub.get(session_id).ended) after = client.get(f"/api/sessions/{session_id}/checklist") assert after.status_code == 200 assert all(item["question"] for item in after.json()) def test_report_shows_missed_questions_and_self_assessment_gap(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": "hint.request"}) trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}}) trainee.send_json({"type": "call.hangup"}) wait_for(lambda: hub.get(session_id).score is not None) # Курсант считает, что пропустил только адрес — а не добыл он всё. trainee.send_json({"type": "self_assessment.submit", "missed": ["q_address"], "comment": "торопился"}) wait_for(lambda: hub.get(session_id).self_assessed) response = client.get(f"/api/sessions/{session_id}/report") assert response.status_code == 200, response.text report = response.json() assert report["missed_checklist"], "не добытые пункты должны быть перечислены" questions = {item["checklist_id"]: item["question"] for item in report["reference_questions"]} assert all(questions.get(item) for item in report["missed_checklist"]), "у пропущенного нет эталонного вопроса" for finding in report["findings"]: assert finding["code"] and finding["fact"], "отметка без обоснования" diff = report["self_assessment_diff"] assert "q_address" in diff["overcautious"] or "q_address" in diff["noticed"] assert diff["unnoticed"], "курсант не заметил часть пропущенного — это и есть материал разбора" assert report["hints_used"], "использованные подсказки попадают в разбор" def test_instructor_correction_keeps_the_automatic_score(client, postgres_access): from app.db.base import get_sessionmaker from app.session.journal import DbJournal # Unlike the websocket-only cases above, this regression exercises the # durable HTTP correction endpoint against a real PostgreSQL score row. journal = DbJournal(get_sessionmaker()) try: 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": "call.hangup"}) wait_for(lambda: hub.get(session_id).score is not None) # The websocket-only fixture deliberately disables journalling; # persist its computed result before testing the HTTP override. state = hub.get(session_id) client.portal.call( journal.start_lesson, session_id, state.scenario_id, state.mode.value, state.trainee_name, state.trainee_id, "dev", ) client.portal.call(journal.score, session_id, state.score["score_auto"], state.score) auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"] response = client.patch( f"/api/sessions/{session_id}/report", json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"}, ) assert response.status_code == 200, response.text corrected = response.json() finally: hub.journal = None assert corrected["score_final"] == 80.0 assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом" assert corrected["overridden_by"] == "dev" def test_ws_score_override_rejects_other_session_and_invalid_value(client): with lesson(client) as (session_id, control): state = hub.get(session_id) with client.websocket_connect(f"/ws/call/{session_id}") as trainee: trainee.send_json({"type": "call.answer"}) trainee.send_json({"type": "call.hangup"}) wait_for(lambda: state.score is not None) auto = state.score["score_auto"] control.send_json({ "type": "score.override", "session_id": str(uuid4()), "verdict": "90", "comment": "не тот номер", }) control.send_json({ "type": "score.override", "session_id": str(session_id), "verdict": "nan", "comment": "не число", }) time.sleep(0.1) assert state.score["score_auto"] == auto assert "score_final" not in state.score control.send_json({ "type": "score.override", "session_id": str(session_id), "verdict": "85", "comment": "ручная проверка", }) wait_for(lambda: state.score.get("score_final") == 85) assert state.score["overridden_by"] == "dev" def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client): class FailedJournal: async def score_override(self, *_args): return False async def checkpoint(self, *_args): return None with lesson(client) as (session_id, control): state = hub.get(session_id) with client.websocket_connect(f"/ws/call/{session_id}") as trainee: trainee.send_json({"type": "call.answer"}) trainee.send_json({"type": "call.hangup"}) wait_for(lambda: state.score is not None) auto = state.score["score_auto"] hub.journal = FailedJournal() control.send_json({ "type": "score.override", "session_id": str(session_id), "verdict": "85", "comment": "manual review", }) time.sleep(0.1) assert state.score["score_auto"] == auto assert "score_final" not in state.score def test_soft_directive_changes_how_the_caller_sounds(client): from app.domain.events import Mood with lesson(client) as (session_id, control): state = hub.get(session_id) control.send_json({"type": "director.inject", "directive": "turns_aggressive", "mode": "next_turn"}) wait_for(lambda: state.persona.directive == "turns_aggressive") assert state.persona.mood is Mood.AGGRESSIVE, "директива должна перекрывать дугу сценария" def test_wrong_address_makes_the_operator_ask_again(client): """Жёсткая директива правит факты: раскрытый адрес снимается, и в оценке полнота опроса снова считает его недобытым.""" with lesson(client) as (session_id, control): state = hub.get(session_id) if state.slots is None: pytest.skip("нет модели эмбеддингов — make models") state.slots.hear("Назовите адрес") assert "f_address" in state.slots.revealed control.send_json({"type": "director.inject", "directive": "address_wrong", "mode": "next_turn"}) wait_for(lambda: "f_address" not in state.slots.revealed) assert "f_address" in state.slots.missing_required() def test_second_victim_corrects_the_reference(client): with lesson(client) as (session_id, control): state = hub.get(session_id) before = state.scenario.ground_truth.victims control.send_json({"type": "director.inject", "directive": "second_victim", "mode": "next_turn"}) wait_for(lambda: state.scenario.ground_truth.victims != before) assert state.scenario.ground_truth.victims == before + 1 library = store.get(state.scenario_id) assert library.ground_truth.victims == before, "правка занятия не должна менять библиотеку" def test_dropped_line_ends_the_call_and_starts_callback(client): from app.domain.timers import TimerCode with lesson(client) as (session_id, control): 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: state.started_at) control.send_json({"type": "director.inject", "directive": "line_dropped", "mode": "immediate"}) message = read_until(trainee, "call.ended") assert message["reason"] == "dropped" assert TimerCode.CALLBACK in state.timers.timers, "норматив обратного дозвона должен пойти" def test_free_text_directive_says_it_needs_network(client): """Офлайн-дерево предгенерировано: произвольную фразу взять неоткуда.""" with lesson(client) as (session_id, control): with client.websocket_connect(f"/ws/observe/{session_id}") as observer: observer.receive_json() control.send_json({"type": "director.inject", "directive": "скажи, что у тебя кот на балконе", "mode": "next_turn"}) message = read_until(observer, "error") assert message["code"] == "directive_needs_network"