diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index ea7e2d1..89a4ee2 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -256,7 +256,9 @@ async def active( ), dds_work_overdue_cards=sum( (timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None - and timer.started_at is not None + # Пауза держит `started_at=None`, хотя таймер уже шёл: судить + # по нему одному спрятало бы уже случившееся нарушение (lct-39). + and (timer.started_at is not None or timer.paused) and not timer.stopped and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK] for card in state.desk.cards.values() diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index fc74cb0..a8a3c48 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -31,6 +31,7 @@ from app.domain.events import ( PatchSource, ScoreReady, SessionMode, + SessionPaused, StationState, TimerTick, TextTurnAccepted, @@ -398,6 +399,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None: _who, state = entered with hub.trainee(session_id) as queue: + if state.paused: + # Переподключение не ждёт следующего `session.pause`: баннер должен + # быть на экране сразу, раз сервер уже отклоняет команды (lct-39). + hub.to_trainee(session_id, SessionPaused(paused=True)) if state.exercise is Exercise.CARD: from app.api.ws.control import card_briefing @@ -440,6 +445,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None: # Бинарные кадры — аудио, текстовые — события. Направление определяется # каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md). if message.get("bytes") is not None: + if state.paused: + # JSON-команды отказывает `_handle`; бинарные кадры сюда + # не заходят, поэтому пауза глушит звук здесь же (lct-39). + continue if state.exercise is Exercise.CALL: _on_audio(session_id, state, message["bytes"]) else: diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 0be2530..8ad28f1 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -22,7 +22,7 @@ from app.domain.statuses import ( ) from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.events import Metric -from app.domain.timers import GOST_REF +from app.domain.timers import GOST_REF, TimerCode #: Статусы, означающие, что реагирование действительно шло. PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING) @@ -282,7 +282,7 @@ def evaluate_dispatcher( def dispatcher_metrics( - state, + card, deadline_ms: int, expected_decision: str = "accept", expected_decision_reason: str | None = None, @@ -295,8 +295,11 @@ def dispatcher_metrics( источник информации, но не обязательный шлюз статуса. """ metrics: list[Metric] = [] - for service in (services if services is not None else state.managed_services()): - marks = [mark for mark in state.status_log if mark.service == service] + # Через таймер, а не `primary.at - dispatched_at`: календарная разница + # включила бы паузу преподавателя в норматив (lct-39). + elapsed = card.timers.measured_ms(TimerCode.DDS_ACK) + for service in (services if services is not None else card.managed_services()): + marks = [mark for mark in card.status_log if mark.service == service] statuses = {mark.status for mark in marks} def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0): @@ -307,8 +310,6 @@ def dispatcher_metrics( )) primary = next((mark for mark in marks if mark.status in PRIMARY), None) - elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000) - if primary and state.dispatched_at else None) add("dds_primary", "решение по карточке", primary is not None, primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0) add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с", @@ -326,7 +327,7 @@ def dispatcher_metrics( expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0) if primary.status is ServiceStatus.DECLINED or expected_decision == "decline": continue - crew = state.crew_assignments.get(service) + crew = card.crew_assignments.get(service) add("dds_crew", "назначение бригады", bool(crew), crew or "бригада не выбрана", "необходимую бригаду выбирает ДДС вручную", 2.0) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index aed1ea7..51bd24d 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -454,7 +454,14 @@ class SessionState(PersistedSession): return self.ended_at is not None def end(self, reason: CallEndReason) -> datetime: - """Закрыть занятие; возвращает момент окончания для журнала.""" + """Закрыть занятие; возвращает момент окончания для журнала. + + Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить», + не возобновив): снять флаг и досчитать простой сейчас, иначе курсант + навсегда останется за баннером «Пауза, ждите преподавателя», а разбор + потеряет последний, ещё не закрытый интервал паузы (lct-39). + """ + self.resume() self.ended_at = now_utc() self.end_reason = reason return self.ended_at diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py index 39e8f13..602889a 100644 --- a/backend/tests/test_session_pause.py +++ b/backend/tests/test_session_pause.py @@ -6,18 +6,25 @@ """ import asyncio +import time from datetime import timedelta from pathlib import Path from uuid import uuid4 import pytest +from fastapi.testclient import TestClient +from app.api.http import sessions as sessions_http from app.api.ws import control as control_ws -from app.domain.events import Exercise, LessonCriteria, SessionMode +from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode +from app.domain.statuses import ServiceStatus from app.domain.timers import TimerCode +from app.main import app from app.scenarios.loader import load_file +from app.scoring.dispatcher import dispatcher_metrics from app.session import timers as timers_module from app.session.checkpoint import dump_state, load_state +from app.session.finish import end_session from app.session.hub import hub from app.session.state import SessionState, now_utc from app.session.store import LessonPaused, LessonResumed, MemorySessionStore @@ -26,6 +33,20 @@ from app.session.timers import Timer LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" +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 + + class FakeClock: """Управляемая замена `time.monotonic`: тест продвигает время явно.""" @@ -174,3 +195,155 @@ def test_pause_and_resume_are_recorded_for_audit(monkeypatch): assert any(isinstance(record, LessonPaused) for record in records) assert any(isinstance(record, LessonResumed) for record in records) assert state.paused is False + + +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) + + async def run(): + async with hub.operation(state.session_id): + await end_session(state.session_id, state, CallEndReason.INSTRUCTOR) + + asyncio.run(run()) + + assert state.ended + assert state.paused is False + assert state.total_paused_ms >= 30_000 + + +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 + + +@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 + )