629 lines
30 KiB
Python
629 lines
30 KiB
Python
"""Каналы занятия целиком: преподаватель запускает, курсант работает,
|
||
наблюдатель смотрит и не может вмешаться.
|
||
|
||
Проверяются пункты приёмки карточки 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="<i2")
|
||
assert 1000 in decoded, "в записи потерян микрофонный звук оператора"
|
||
assert 2000 in decoded, "в записи потерян звук звонящего"
|
||
assert 3000 in decoded, "после восстановления потерян микрофонный звук оператора"
|
||
|
||
|
||
def test_score_waits_for_self_assessment(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()
|
||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||
trainee.send_json({"type": "call.answer"})
|
||
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14"}})
|
||
trainee.send_json({"type": "call.hangup"})
|
||
|
||
# Преподавателю и монитору — сразу: им ждать нечего.
|
||
assert read_until(observer, "score.ready")["session_id"] == str(session_id)
|
||
wait_for(lambda: state.score is not None)
|
||
|
||
trainee.send_json({"type": "self_assessment.submit",
|
||
"missed": ["q_people"], "comment": "растерялся на адресе"})
|
||
message = read_until(trainee, "score.ready")
|
||
|
||
assert message["session_id"] == str(session_id)
|
||
assert state.self_assessment == {"missed": ["q_people"], "comment": "растерялся на адресе"}
|
||
assert state.score["score_auto"] >= 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"
|