2026-09-27 16:35:13 +03:00
|
|
|
|
"""Пауза занятия с пульта преподавателя (lct-39).
|
|
|
|
|
|
|
|
|
|
|
|
Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
|
|
|
|
|
|
проверяя ровно то, что требует карточка — пауза не считается в норматив,
|
|
|
|
|
|
переживает чекпойнт и пишется в аудит.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-09-27 17:18:27 +03:00
|
|
|
|
import time
|
2026-09-27 23:33:46 +03:00
|
|
|
|
from datetime import UTC, datetime, timedelta
|
2026-09-27 16:35:13 +03:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-09-27 17:18:27 +03:00
|
|
|
|
from fastapi.testclient import TestClient
|
2026-09-27 16:35:13 +03:00
|
|
|
|
|
2026-09-27 17:18:27 +03:00
|
|
|
|
from app.api.http import sessions as sessions_http
|
2026-09-27 16:35:13 +03:00
|
|
|
|
from app.api.ws import control as control_ws
|
2026-09-27 18:06:49 +03:00
|
|
|
|
from app.api.ws import call as call_ws
|
2026-09-27 22:23:12 +03:00
|
|
|
|
from app.api.ws import station as station_ws
|
|
|
|
|
|
from app.domain.classifiers import DDSCode
|
|
|
|
|
|
from app.domain.events import (
|
|
|
|
|
|
CallbackDial,
|
|
|
|
|
|
CallEnded,
|
|
|
|
|
|
CallEndReason,
|
|
|
|
|
|
CardAck,
|
|
|
|
|
|
DdsDispatch,
|
|
|
|
|
|
DirectorInject,
|
|
|
|
|
|
ErrorEvent,
|
|
|
|
|
|
Exercise,
|
|
|
|
|
|
HintRequest,
|
|
|
|
|
|
KioPatchIn,
|
|
|
|
|
|
LessonCriteria,
|
|
|
|
|
|
Principal,
|
|
|
|
|
|
SelfAssessmentSubmit,
|
|
|
|
|
|
SessionMode,
|
|
|
|
|
|
SessionPause,
|
|
|
|
|
|
SessionPaused,
|
|
|
|
|
|
SessionResume,
|
|
|
|
|
|
TtsCancel,
|
|
|
|
|
|
TtsEnd,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.domain.roles import Role
|
2026-09-27 23:33:46 +03:00
|
|
|
|
from app.domain.statuses import PhoneReportRecord, ServiceStatus
|
2026-09-27 16:35:13 +03:00
|
|
|
|
from app.domain.timers import TimerCode
|
2026-09-27 17:18:27 +03:00
|
|
|
|
from app.main import app
|
2026-09-27 16:35:13 +03:00
|
|
|
|
from app.scenarios.loader import load_file
|
2026-09-27 17:18:27 +03:00
|
|
|
|
from app.scoring.dispatcher import dispatcher_metrics
|
2026-09-27 16:35:13 +03:00
|
|
|
|
from app.session import timers as timers_module
|
|
|
|
|
|
from app.session.checkpoint import dump_state, load_state
|
2026-09-27 23:33:46 +03:00
|
|
|
|
from app.session.finish import end_session, score_dds_card
|
2026-09-27 16:35:13 +03:00
|
|
|
|
from app.session.hub import hub
|
|
|
|
|
|
from app.session.state import SessionState, now_utc
|
|
|
|
|
|
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
|
|
|
|
|
|
from app.session.timers import Timer
|
|
|
|
|
|
|
|
|
|
|
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
2026-09-27 22:23:12 +03:00
|
|
|
|
TEACHER = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
2026-09-27 16:35:13 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 17:18:27 +03:00
|
|
|
|
def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
|
|
|
|
|
scenario=scenario, owner_login="teacher",
|
|
|
|
|
|
)
|
|
|
|
|
|
state.timers.limits[TimerCode.DDS_ACK] = criteria_ms
|
|
|
|
|
|
from app.session.dds import prepare_queue
|
|
|
|
|
|
|
|
|
|
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|
|
|
|
|
return state, state.desk.active
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 16:35:13 +03:00
|
|
|
|
class FakeClock:
|
|
|
|
|
|
"""Управляемая замена `time.monotonic`: тест продвигает время явно."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, start: float = 1_000.0) -> None:
|
|
|
|
|
|
self.now = start
|
|
|
|
|
|
|
|
|
|
|
|
def __call__(self) -> float:
|
|
|
|
|
|
return self.now
|
|
|
|
|
|
|
|
|
|
|
|
def advance(self, seconds: float) -> None:
|
|
|
|
|
|
self.now += seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def clock(monkeypatch):
|
|
|
|
|
|
fake = FakeClock()
|
|
|
|
|
|
monkeypatch.setattr(timers_module.time, "monotonic", fake)
|
|
|
|
|
|
return fake
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_timer_pause_freezes_elapsed_until_resume(clock):
|
|
|
|
|
|
timer = Timer(code=TimerCode.DDS_ACK)
|
|
|
|
|
|
timer.start(clock.now)
|
|
|
|
|
|
clock.advance(10)
|
|
|
|
|
|
|
|
|
|
|
|
timer.pause(clock.now)
|
|
|
|
|
|
frozen = timer.current_ms(clock.now)
|
|
|
|
|
|
clock.advance(60) # долгая пауза — часы идут, таймер не должен
|
|
|
|
|
|
|
|
|
|
|
|
assert timer.current_ms(clock.now) == frozen == 10_000
|
|
|
|
|
|
|
|
|
|
|
|
timer.resume(clock.now)
|
|
|
|
|
|
clock.advance(15)
|
|
|
|
|
|
|
|
|
|
|
|
assert timer.current_ms(clock.now) == 25_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
|
|
|
|
|
|
"""Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
|
|
|
|
|
|
отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
|
|
|
|
|
scenario=scenario, criteria=LessonCriteria(decision_time_limit_seconds=30),
|
|
|
|
|
|
)
|
|
|
|
|
|
state.timers.limits[TimerCode.DDS_ACK] = 30_000
|
|
|
|
|
|
from app.session.dds import prepare_queue
|
|
|
|
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|
|
|
|
|
card = state.desk.active
|
|
|
|
|
|
|
|
|
|
|
|
clock.advance(10)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
clock.advance(60)
|
|
|
|
|
|
state.resume()
|
|
|
|
|
|
clock.advance(15)
|
|
|
|
|
|
card.on_event("card.ack")
|
|
|
|
|
|
|
|
|
|
|
|
measured = card.timers.measured_ms(TimerCode.DDS_ACK)
|
|
|
|
|
|
assert measured == 25_000
|
|
|
|
|
|
assert measured <= card.timers.limits[TimerCode.DDS_ACK]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
second = scenario.model_copy(deep=True)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
|
|
|
|
|
scenario=scenario,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.session.dds import prepare_queue
|
|
|
|
|
|
prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
|
|
|
|
|
|
|
|
|
|
|
|
due_before = state.desk.next_arrival_at
|
|
|
|
|
|
assert due_before is not None
|
|
|
|
|
|
|
|
|
|
|
|
base = now_utc()
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
idle = timedelta(minutes=1)
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
|
|
|
|
|
state.resume()
|
|
|
|
|
|
|
|
|
|
|
|
# Следующая карточка не должна поступить раньше, чем простояла пауза.
|
|
|
|
|
|
assert state.desk.next_arrival_at == due_before + idle
|
|
|
|
|
|
assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
|
|
|
|
|
scenario=scenario,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.session.dds import prepare_queue
|
|
|
|
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|
|
|
|
|
card = state.desk.active
|
|
|
|
|
|
|
|
|
|
|
|
clock.advance(5)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
saved_at = now_utc()
|
|
|
|
|
|
|
|
|
|
|
|
payload = dump_state(state)
|
|
|
|
|
|
# Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
|
|
|
|
|
|
restored_at = saved_at + timedelta(minutes=1)
|
|
|
|
|
|
restored = load_state(payload, restored_at)
|
|
|
|
|
|
|
|
|
|
|
|
assert restored.paused is True
|
|
|
|
|
|
restored_card = restored.desk.active
|
|
|
|
|
|
restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
|
|
|
|
|
|
# Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
|
|
|
|
|
|
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
|
|
|
|
|
|
assert restored_timer.started_at is None
|
|
|
|
|
|
|
2026-09-27 18:06:49 +03:00
|
|
|
|
# После takeover пауза всё ещё действует; лишь явное возобновление
|
|
|
|
|
|
# продолжает норматив с пяти секунд до прерывания.
|
|
|
|
|
|
restored.resume()
|
|
|
|
|
|
clock.advance(10)
|
|
|
|
|
|
assert restored_timer.current_ms(clock.now) == 15_000
|
|
|
|
|
|
|
2026-09-27 16:35:13 +03:00
|
|
|
|
|
|
|
|
|
|
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
|
|
|
|
|
scenario=scenario, owner_login="teacher",
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.session.dds import prepare_queue
|
|
|
|
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|
|
|
|
|
|
|
|
|
|
|
store = MemorySessionStore()
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", store)
|
|
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
2026-09-27 22:23:12 +03:00
|
|
|
|
await control_ws._command(state.session_id, SessionPause(), TEACHER)
|
2026-09-27 16:35:13 +03:00
|
|
|
|
async with hub.operation(state.session_id):
|
2026-09-27 22:23:12 +03:00
|
|
|
|
await control_ws._command(state.session_id, SessionResume(), TEACHER)
|
2026-09-27 16:35:13 +03:00
|
|
|
|
|
|
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
|
|
|
|
|
|
records = [record for _sid, batch in store.commits for record in batch]
|
|
|
|
|
|
assert any(isinstance(record, LessonPaused) for record in records)
|
|
|
|
|
|
assert any(isinstance(record, LessonResumed) for record in records)
|
|
|
|
|
|
assert state.paused is False
|
2026-09-27 17:18:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
|
|
|
|
|
|
"""Регрессия: `dispatcher_metrics` считала `primary.at - dispatched_at` —
|
|
|
|
|
|
календарную разницу, которую пауза не трогает. Метрика должна брать то
|
|
|
|
|
|
же замороженное время, что и таймер на экране."""
|
|
|
|
|
|
state, card = _dds_state_with_active_card(clock)
|
|
|
|
|
|
services = state.card_services(card)
|
|
|
|
|
|
service = services[0]
|
|
|
|
|
|
|
|
|
|
|
|
clock.advance(10)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
clock.advance(60)
|
|
|
|
|
|
state.resume()
|
|
|
|
|
|
clock.advance(15)
|
|
|
|
|
|
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
|
|
|
|
|
|
card.on_event("card.ack")
|
|
|
|
|
|
|
|
|
|
|
|
metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
|
|
|
|
|
|
assert metric.passed is True
|
|
|
|
|
|
assert "25.0" in metric.fact
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock, monkeypatch):
|
|
|
|
|
|
"""Регрессия: занятие, завершённое прямо на паузе, оставляло `paused=True`
|
|
|
|
|
|
навсегда — самооценка и отчёт отклонялись баннером «Пауза», а простой,
|
|
|
|
|
|
не закрытый явным `session.resume`, не попадал в `total_paused_ms`."""
|
|
|
|
|
|
from app.session import finish as finish_module
|
|
|
|
|
|
|
|
|
|
|
|
async def no_coach(_metrics):
|
|
|
|
|
|
class NoCoaching:
|
|
|
|
|
|
def model_dump(self, **_kwargs):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
return NoCoaching()
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(finish_module, "coach", no_coach)
|
|
|
|
|
|
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
|
|
|
|
|
|
scenario=scenario, owner_login="teacher",
|
|
|
|
|
|
)
|
|
|
|
|
|
store = MemorySessionStore()
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", store)
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
|
|
|
|
|
|
base = now_utc()
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
|
|
|
|
|
clock.advance(5)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
clock.advance(30) # преподаватель завершает занятие, не возобновив его
|
|
|
|
|
|
idle = timedelta(seconds=45)
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
|
|
|
|
|
|
2026-09-27 18:06:49 +03:00
|
|
|
|
events = []
|
|
|
|
|
|
|
2026-09-27 17:18:27 +03:00
|
|
|
|
async def run():
|
2026-09-27 18:06:49 +03:00
|
|
|
|
with hub.trainee(state.session_id) as queue:
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await call_ws._handle(
|
|
|
|
|
|
state.session_id, state,
|
2026-09-27 22:23:12 +03:00
|
|
|
|
SelfAssessmentSubmit(missed=[], comment="Проверил"),
|
2026-09-27 18:06:49 +03:00
|
|
|
|
)
|
|
|
|
|
|
while not queue.empty():
|
|
|
|
|
|
events.append(queue.get_nowait())
|
2026-09-27 17:18:27 +03:00
|
|
|
|
|
|
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
|
|
|
|
|
|
assert state.ended
|
|
|
|
|
|
assert state.paused is False
|
|
|
|
|
|
assert state.total_paused_ms >= 30_000
|
2026-09-27 18:06:49 +03:00
|
|
|
|
assert state.self_assessed
|
|
|
|
|
|
assert state.score is not None
|
|
|
|
|
|
assert state.score["full_report"]["total_paused_ms"] >= 30_000
|
|
|
|
|
|
assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
|
2026-09-27 22:23:12 +03:00
|
|
|
|
# Снятие паузы приходит после `call.ended`: иначе клиент дошлёт отложенные
|
|
|
|
|
|
# на паузе правки в уже закрытое занятие.
|
|
|
|
|
|
kinds = [type(event) for event in events]
|
|
|
|
|
|
assert kinds.index(CallEnded) < kinds.index(SessionPaused)
|
2026-09-27 18:06:49 +03:00
|
|
|
|
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
|
2026-09-27 17:18:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dds_work_overdue_survives_pause():
|
|
|
|
|
|
"""Регрессия: `dds_work_overdue_cards` требовал `started_at is not None`,
|
|
|
|
|
|
который пауза обнуляет — уже случившееся нарушение исчезало из реестра."""
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
from app.session.dds import build_card
|
|
|
|
|
|
from app.session.timers import SessionTimers
|
|
|
|
|
|
|
|
|
|
|
|
limits = {**SessionTimers().limits, TimerCode.DDS_WORK: 1_000}
|
|
|
|
|
|
card = build_card(scenario, 0, limits)
|
|
|
|
|
|
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
|
|
|
|
|
|
timer.start(time.monotonic() - 5.0) # уже превысил норматив в 1 с
|
|
|
|
|
|
timer.pause(time.monotonic())
|
|
|
|
|
|
|
|
|
|
|
|
overdue = (
|
|
|
|
|
|
timer.started_at is not None or timer.paused
|
|
|
|
|
|
) and not timer.stopped and timer.current_ms(time.monotonic()) > limits[TimerCode.DDS_WORK]
|
|
|
|
|
|
assert overdue is True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 18:06:49 +03:00
|
|
|
|
def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
|
|
|
|
|
|
app.dependency_overrides[sessions_http.optional_session] = lambda: None
|
|
|
|
|
|
state, card = _dds_state_with_active_card(clock)
|
|
|
|
|
|
state.owner_login = "dev"
|
|
|
|
|
|
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
|
|
|
|
|
|
card.timers.limits[TimerCode.DDS_WORK] = 1_000
|
|
|
|
|
|
timer.start(clock.now)
|
|
|
|
|
|
clock.advance(5)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
hub._sessions[state.session_id] = state
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = http_client.get("/api/sessions/active")
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
|
|
|
|
|
|
assert row["dds_work_overdue_cards"] == 1
|
|
|
|
|
|
assert row["paused"] is True
|
|
|
|
|
|
finally:
|
|
|
|
|
|
hub._sessions.pop(state.session_id, None)
|
|
|
|
|
|
app.dependency_overrides.pop(sessions_http.optional_session, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 17:18:27 +03:00
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def http_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:
|
|
|
|
|
|
test_client.post("/api/auth/dev-token")
|
|
|
|
|
|
hub.store = MemorySessionStore()
|
|
|
|
|
|
yield test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wait(predicate, timeout: float = 3.0):
|
|
|
|
|
|
deadline = time.monotonic() + timeout
|
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
|
value = predicate()
|
|
|
|
|
|
if value:
|
|
|
|
|
|
return value
|
|
|
|
|
|
time.sleep(0.02)
|
|
|
|
|
|
raise AssertionError("не дождались")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_paused_socket_drops_audio_instead_of_feeding_voice(http_client):
|
|
|
|
|
|
"""Регрессия: пауза блокировала только JSON-команды — бинарные аудиокадры
|
|
|
|
|
|
/ws/call всё равно доходили до счётчика и голосового контура."""
|
|
|
|
|
|
session_id = uuid4()
|
|
|
|
|
|
with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
|
|
|
|
|
|
control.send_json({
|
|
|
|
|
|
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
|
|
|
|
|
"trainee": "Иванов И.И.", "mode": "training",
|
|
|
|
|
|
})
|
|
|
|
|
|
_wait(lambda: hub.get(session_id))
|
|
|
|
|
|
state = hub.get(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
|
|
|
|
|
trainee.send_bytes(b"\x00\x00" * 320)
|
|
|
|
|
|
_wait(lambda: state.audio_frames == 1)
|
|
|
|
|
|
|
|
|
|
|
|
control.send_json({"type": "session.pause"})
|
|
|
|
|
|
_wait(lambda: state.paused)
|
|
|
|
|
|
|
|
|
|
|
|
trainee.send_bytes(b"\x00\x00" * 320)
|
|
|
|
|
|
trainee.send_bytes(b"\x00\x00" * 320)
|
|
|
|
|
|
time.sleep(0.1) # кадры паузы не должны просочиться в счётчик
|
|
|
|
|
|
|
|
|
|
|
|
assert state.audio_frames == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reconnect_to_call_replays_paused_banner(http_client):
|
|
|
|
|
|
"""Регрессия: переподключение к /ws/call не сообщало о текущей паузе —
|
|
|
|
|
|
баннер появлялся только после следующего `session.pause`."""
|
|
|
|
|
|
session_id = uuid4()
|
|
|
|
|
|
with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
|
|
|
|
|
|
control.send_json({
|
|
|
|
|
|
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
|
|
|
|
|
"trainee": "Иванов И.И.", "mode": "training",
|
|
|
|
|
|
})
|
|
|
|
|
|
_wait(lambda: hub.get(session_id))
|
|
|
|
|
|
control.send_json({"type": "session.pause"})
|
|
|
|
|
|
_wait(lambda: hub.get(session_id).paused)
|
|
|
|
|
|
|
|
|
|
|
|
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
|
|
|
|
|
messages = [trainee.receive_json() for _ in range(2)]
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
message["type"] == "session.paused" and message["paused"]
|
|
|
|
|
|
for message in messages
|
|
|
|
|
|
)
|
2026-09-27 18:06:49 +03:00
|
|
|
|
|
|
|
|
|
|
control.send_json({"type": "session.resume"})
|
|
|
|
|
|
_wait(lambda: not hub.get(session_id).paused)
|
|
|
|
|
|
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
|
|
|
|
|
message = trainee.receive_json()
|
|
|
|
|
|
assert message == {"type": "session.paused", "paused": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
|
|
|
|
|
|
from app.voice import pipeline
|
|
|
|
|
|
|
|
|
|
|
|
class FakeVad:
|
|
|
|
|
|
def __init__(self, *_args, **_kwargs):
|
|
|
|
|
|
self.reset_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
def reset(self):
|
|
|
|
|
|
self.reset_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
def push(self, _frame):
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
|
|
|
|
|
|
state, _card = _dds_state_with_active_card(lambda: 1_000.0)
|
|
|
|
|
|
events = []
|
|
|
|
|
|
audio = []
|
|
|
|
|
|
started = asyncio.Event()
|
|
|
|
|
|
release = asyncio.Event()
|
|
|
|
|
|
|
|
|
|
|
|
class Models:
|
|
|
|
|
|
vad_path = None
|
|
|
|
|
|
|
|
|
|
|
|
voice = pipeline.VoiceSession(
|
|
|
|
|
|
state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
|
|
|
|
|
|
)
|
|
|
|
|
|
state.voice = voice
|
|
|
|
|
|
|
|
|
|
|
|
async def slow_synthesis(_text):
|
|
|
|
|
|
started.set()
|
|
|
|
|
|
await release.wait()
|
|
|
|
|
|
return b"\x00\x00" * 2_400
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(voice, "synthesize", slow_synthesis)
|
|
|
|
|
|
try:
|
|
|
|
|
|
speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
|
|
|
|
|
|
await asyncio.wait_for(started.wait(), timeout=1)
|
|
|
|
|
|
voice._queue.put_nowait((b"old", time.monotonic()))
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
release.set()
|
|
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
|
|
|
|
await speaking
|
|
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
|
|
|
|
|
|
assert not any(isinstance(event, TtsEnd) for event in events)
|
|
|
|
|
|
assert audio == []
|
|
|
|
|
|
assert voice._queue.empty()
|
|
|
|
|
|
assert voice._vad.reset_count == 1
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await voice.close()
|
2026-09-27 22:23:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _call_state():
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
return SessionState(
|
|
|
|
|
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
|
|
|
|
|
|
scenario=scenario, owner_login="teacher",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _drain(queue) -> list:
|
|
|
|
|
|
events = []
|
|
|
|
|
|
while not queue.empty():
|
|
|
|
|
|
events.append(queue.get_nowait())
|
|
|
|
|
|
return events
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_timer_start_on_pause_keeps_frozen_elapsed(clock):
|
|
|
|
|
|
"""Повторное событие запуска на паузе не должно пустить часы с нуля."""
|
|
|
|
|
|
timer = Timer(code=TimerCode.CALLBACK)
|
|
|
|
|
|
timer.start(clock.now)
|
|
|
|
|
|
clock.advance(4)
|
|
|
|
|
|
timer.pause(clock.now)
|
|
|
|
|
|
|
|
|
|
|
|
timer.start(clock.now)
|
|
|
|
|
|
clock.advance(120)
|
|
|
|
|
|
|
|
|
|
|
|
assert timer.paused is True
|
|
|
|
|
|
assert timer.current_ms(clock.now) == 4_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_director_inject_rejected_while_paused(clock, monkeypatch):
|
|
|
|
|
|
"""Регрессия: обрыв связи на паузе запускал норматив обратного дозвона,
|
|
|
|
|
|
и весь простой уходил в него; курсант получал `call.ended` под баннером."""
|
|
|
|
|
|
state = _call_state()
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
state.on_event("call.incoming")
|
|
|
|
|
|
state.on_event("call.answer")
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
|
with hub.observer(state.session_id) as observer, hub.trainee(state.session_id) as trainee:
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await control_ws._command(
|
|
|
|
|
|
state.session_id, DirectorInject(directive="line_dropped"), TEACHER,
|
|
|
|
|
|
)
|
|
|
|
|
|
return _drain(observer), _drain(trainee)
|
|
|
|
|
|
|
|
|
|
|
|
observed, to_trainee = asyncio.run(run())
|
|
|
|
|
|
clock.advance(120)
|
|
|
|
|
|
|
|
|
|
|
|
assert any(isinstance(event, ErrorEvent) for event in observed)
|
|
|
|
|
|
assert to_trainee == []
|
|
|
|
|
|
assert state.directives == []
|
|
|
|
|
|
assert TimerCode.CALLBACK not in state.timers.timers
|
|
|
|
|
|
assert state.timers.timers[TimerCode.INTERVIEW].paused is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("event", [
|
|
|
|
|
|
KioPatchIn(fields={"address": "ул. Ленина, 1"}),
|
|
|
|
|
|
HintRequest(),
|
|
|
|
|
|
DdsDispatch(service=DDSCode.FIRE),
|
|
|
|
|
|
CallbackDial(),
|
|
|
|
|
|
], ids=lambda event: event.type)
|
|
|
|
|
|
def test_call_channel_rejects_trainee_actions_while_paused(clock, monkeypatch, event):
|
|
|
|
|
|
"""Истина — сервер: кнопки на фронте выключены, но команда с паузы
|
|
|
|
|
|
не должна ни менять карточку, ни останавливать норматив опроса."""
|
|
|
|
|
|
state = _call_state()
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
state.on_event("call.incoming")
|
|
|
|
|
|
state.on_event("call.answer")
|
|
|
|
|
|
clock.advance(5)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
kio_before = state.kio.model_dump()
|
|
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
|
with hub.trainee(state.session_id) as queue:
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await call_ws._handle(state.session_id, state, event)
|
|
|
|
|
|
return _drain(queue)
|
|
|
|
|
|
|
|
|
|
|
|
events = asyncio.run(run())
|
|
|
|
|
|
|
|
|
|
|
|
assert [type(item) for item in events] == [ErrorEvent]
|
|
|
|
|
|
assert state.kio.model_dump() == kio_before
|
|
|
|
|
|
interview = state.timers.timers[TimerCode.INTERVIEW]
|
|
|
|
|
|
assert interview.paused and not interview.stopped
|
|
|
|
|
|
assert TimerCode.DDS_ACK not in state.timers.timers
|
|
|
|
|
|
assert TimerCode.CALLBACK not in state.timers.timers
|
|
|
|
|
|
assert state.hints_shown == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_station_channel_rejects_card_ack_while_paused(clock, monkeypatch):
|
|
|
|
|
|
state, card = _dds_state_with_active_card(clock)
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
clock.advance(5)
|
|
|
|
|
|
state.pause()
|
|
|
|
|
|
card_before = card.model_dump()
|
|
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
|
with hub.station(state.session_id) as queue:
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await station_ws._handle(state.session_id, state, CardAck(comment="Принято"))
|
|
|
|
|
|
return _drain(queue)
|
|
|
|
|
|
|
|
|
|
|
|
events = asyncio.run(run())
|
|
|
|
|
|
|
|
|
|
|
|
assert [type(item) for item in events] == [ErrorEvent]
|
|
|
|
|
|
assert card.model_dump() == card_before
|
|
|
|
|
|
ack = card.timers.timers[TimerCode.DDS_ACK]
|
|
|
|
|
|
assert ack.paused and not ack.stopped
|
|
|
|
|
|
assert ack.current_ms(clock.now) == 5_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
|
|
|
|
|
|
state, card = _dds_state_with_active_card(clock)
|
|
|
|
|
|
store = MemorySessionStore()
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", store)
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
base = now_utc()
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
|
|
|
|
|
|
|
|
|
|
|
async def command(event):
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await control_ws._command(state.session_id, event, TEACHER)
|
|
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
|
with hub.station(state.session_id) as queue:
|
|
|
|
|
|
clock.advance(5)
|
|
|
|
|
|
await command(SessionPause())
|
|
|
|
|
|
clock.advance(30)
|
|
|
|
|
|
await command(SessionPause()) # второе нажатие не сдвигает начало паузы
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=60))
|
|
|
|
|
|
await command(SessionResume())
|
|
|
|
|
|
await command(SessionResume())
|
|
|
|
|
|
return _drain(queue)
|
|
|
|
|
|
|
|
|
|
|
|
events = asyncio.run(run())
|
|
|
|
|
|
clock.advance(10)
|
|
|
|
|
|
|
|
|
|
|
|
records = [record for _sid, batch in store.commits for record in batch]
|
|
|
|
|
|
assert sum(isinstance(record, LessonPaused) for record in records) == 1
|
|
|
|
|
|
assert sum(isinstance(record, LessonResumed) for record in records) == 1
|
|
|
|
|
|
assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False]
|
|
|
|
|
|
assert state.total_paused_ms == 60_000
|
2026-09-27 23:33:46 +03:00
|
|
|
|
assert state.pause_intervals == [(base, base + timedelta(seconds=60))]
|
2026-09-27 22:23:12 +03:00
|
|
|
|
assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 23:33:46 +03:00
|
|
|
|
def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch):
|
|
|
|
|
|
base = datetime(2026, 9, 27, 10, tzinfo=UTC)
|
|
|
|
|
|
state, card = _dds_state_with_active_card(clock)
|
|
|
|
|
|
service = state.card_services(card)[0]
|
|
|
|
|
|
monkeypatch.setattr("app.session.dds.now_utc", lambda: base)
|
|
|
|
|
|
card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята")
|
|
|
|
|
|
card.phone_reports.append(PhoneReportRecord(
|
|
|
|
|
|
service=service, crew="Бригада 12", phase="dispatched",
|
|
|
|
|
|
text="Бригада выехала", at=base,
|
|
|
|
|
|
))
|
|
|
|
|
|
store = MemorySessionStore()
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", store)
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
|
|
|
|
|
|
async def command(event):
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await control_ws._command(state.session_id, event, TEACHER)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=10))
|
|
|
|
|
|
asyncio.run(command(SessionPause()))
|
|
|
|
|
|
restored = load_state(dump_state(state), base + timedelta(seconds=70))
|
|
|
|
|
|
assert restored.paused and restored.pause_intervals == []
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, restored)
|
|
|
|
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=70))
|
|
|
|
|
|
asyncio.run(command(SessionResume()))
|
|
|
|
|
|
monkeypatch.setattr("app.session.dds.now_utc", lambda: base + timedelta(seconds=80))
|
|
|
|
|
|
restored.desk.active.set_service_status(
|
|
|
|
|
|
service, ServiceStatus.RESPONDING, "Основание: доклад. Сведения: бригада выехала.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = score_dds_card(restored, restored.desk.active)
|
|
|
|
|
|
reaction = next(item for item in result.metrics if item.key == "dds_report_reaction")
|
|
|
|
|
|
assert restored.pause_intervals == [
|
|
|
|
|
|
(base + timedelta(seconds=10), base + timedelta(seconds=70)),
|
|
|
|
|
|
]
|
|
|
|
|
|
assert reaction.passed and "20 с" in reaction.fact
|
|
|
|
|
|
assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 22:23:12 +03:00
|
|
|
|
def test_pause_is_ignored_in_ended_session(clock, monkeypatch):
|
|
|
|
|
|
state, _card = _dds_state_with_active_card(clock)
|
|
|
|
|
|
store = MemorySessionStore()
|
|
|
|
|
|
monkeypatch.setattr(hub, "store", store)
|
|
|
|
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|
|
|
|
|
state.end(CallEndReason.INSTRUCTOR)
|
|
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
|
with hub.station(state.session_id) as queue:
|
|
|
|
|
|
async with hub.operation(state.session_id):
|
|
|
|
|
|
await control_ws._command(state.session_id, SessionPause(), TEACHER)
|
|
|
|
|
|
return _drain(queue)
|
|
|
|
|
|
|
|
|
|
|
|
events = asyncio.run(run())
|
|
|
|
|
|
|
|
|
|
|
|
assert state.paused is False
|
|
|
|
|
|
assert state.paused_at is None
|
|
|
|
|
|
assert events == []
|
|
|
|
|
|
assert not any(isinstance(record, LessonPaused) for _sid, batch in store.commits for record in batch)
|