fix: пауза отклоняет директивы пульта и повторный запуск таймера, снимается после конца занятия

This commit is contained in:
GGlamer 2026-09-27 22:23:12 +03:00
commit c26c8ba332
4 changed files with 220 additions and 12 deletions

View file

@ -418,6 +418,14 @@ async def _command(session_id: UUID, event, who) -> None:
case "director.inject": case "director.inject":
if state is None: if state is None:
return return
if state.paused:
# Обрыв на паузе запустил бы норматив обратного дозвона, и простой
# ушёл бы в него; курсант же за баннером паузы не может ответить.
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Занятие на паузе",
))
return
if state.exercise is not Exercise.CALL: if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent( hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, code=ErrorKind.UNSUPPORTED_EVENT,

View file

@ -190,8 +190,6 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
at=ended_at, author="система", role="system", at=ended_at, author="система", role="system",
paused_ms=state.total_paused_ms - paused_ms_before, paused_ms=state.total_paused_ms - paused_ms_before,
)) ))
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
if state.exercise is Exercise.CARD and state.dispatched_card is None: if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end") state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов. # Норматив отработки фиксируется событием, а не текущим значением часов.
@ -210,6 +208,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
hub.to_station(session_id, SessionEnded(reason=reason)) hub.to_station(session_id, SessionEnded(reason=reason))
else: else:
hub.to_trainee(session_id, CallEnded(reason=reason)) hub.to_trainee(session_id, CallEnded(reason=reason))
if was_paused:
# Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
# на паузе правки карточки в уже закрытое занятие.
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
if state.score is None: if state.score is None:
return return
hub.to_observers(session_id, ScoreReady(session_id=session_id)) hub.to_observers(session_id, ScoreReady(session_id=session_id))

View file

@ -113,6 +113,10 @@ class Timer(BaseModel):
return data return data
def start(self, now: float) -> None: def start(self, now: float) -> None:
if self.paused:
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
# набранное время, а ход до `resume` посчитал бы паузу.
return
if self.stopped: if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон). # Повторный запуск после остановки — это новая попытка (обратный дозвон).
self.attempt += 1 self.attempt += 1

View file

@ -9,7 +9,6 @@ import asyncio
import time import time
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@ -18,7 +17,30 @@ from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http from app.api.http import sessions as sessions_http
from app.api.ws import control as control_ws from app.api.ws import control as control_ws
from app.api.ws import call as call_ws from app.api.ws import call as call_ws
from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode, SessionPaused, TtsCancel, TtsEnd 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
from app.domain.statuses import ServiceStatus from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.main import app from app.main import app
@ -33,6 +55,7 @@ from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
from app.session.timers import Timer from app.session.timers import Timer
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
TEACHER = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
def _dds_state_with_active_card(clock, criteria_ms: int = 30_000): def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
@ -185,17 +208,12 @@ def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
store = MemorySessionStore() store = MemorySessionStore()
monkeypatch.setitem(hub._sessions, state.session_id, state) monkeypatch.setitem(hub._sessions, state.session_id, state)
monkeypatch.setattr(hub, "store", store) monkeypatch.setattr(hub, "store", store)
who = type("Who", (), {"login": "teacher", "role": type("R", (), {"value": "instructor"})()})()
async def run(): async def run():
async with hub.operation(state.session_id): async with hub.operation(state.session_id):
await control_ws._command( await control_ws._command(state.session_id, SessionPause(), TEACHER)
state.session_id, type("E", (), {"type": "session.pause"})(), who,
)
async with hub.operation(state.session_id): async with hub.operation(state.session_id):
await control_ws._command( await control_ws._command(state.session_id, SessionResume(), TEACHER)
state.session_id, type("E", (), {"type": "session.resume"})(), who,
)
asyncio.run(run()) asyncio.run(run())
@ -267,7 +285,7 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
async with hub.operation(state.session_id): async with hub.operation(state.session_id):
await call_ws._handle( await call_ws._handle(
state.session_id, state, state.session_id, state,
SimpleNamespace(type="self_assessment.submit", missed=[], comment="Проверил"), SelfAssessmentSubmit(missed=[], comment="Проверил"),
) )
while not queue.empty(): while not queue.empty():
events.append(queue.get_nowait()) events.append(queue.get_nowait())
@ -281,6 +299,10 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
assert state.score is not None assert state.score is not None
assert state.score["full_report"]["total_paused_ms"] >= 30_000 assert state.score["full_report"]["total_paused_ms"] >= 30_000
assert any(isinstance(event, SessionPaused) and not event.paused for event in events) assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
# Снятие паузы приходит после `call.ended`: иначе клиент дошлёт отложенные
# на паузе правки в уже закрытое занятие.
kinds = [type(event) for event in events]
assert kinds.index(CallEnded) < kinds.index(SessionPaused)
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch) assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
@ -450,3 +472,174 @@ async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
assert voice._vad.reset_count == 1 assert voice._vad.reset_count == 1
finally: finally:
await voice.close() await voice.close()
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
assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000
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)