lct-39: пауза не искажает оценку ДДС и не блокирует завершённое занятие

This commit is contained in:
kaifarikman 2026-09-27 17:18:27 +03:00
commit 2304d94877
5 changed files with 202 additions and 10 deletions

View file

@ -256,7 +256,9 @@ async def active(
), ),
dds_work_overdue_cards=sum( dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None (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 not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK] and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values() for card in state.desk.cards.values()

View file

@ -31,6 +31,7 @@ from app.domain.events import (
PatchSource, PatchSource,
ScoreReady, ScoreReady,
SessionMode, SessionMode,
SessionPaused,
StationState, StationState,
TimerTick, TimerTick,
TextTurnAccepted, TextTurnAccepted,
@ -398,6 +399,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
_who, state = entered _who, state = entered
with hub.trainee(session_id) as queue: 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: if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing 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). # каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None: if message.get("bytes") is not None:
if state.paused:
# JSON-команды отказывает `_handle`; бинарные кадры сюда
# не заходят, поэтому пауза глушит звук здесь же (lct-39).
continue
if state.exercise is Exercise.CALL: if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"]) _on_audio(session_id, state, message["bytes"])
else: else:

View file

@ -22,7 +22,7 @@ from app.domain.statuses import (
) )
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric 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) PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
@ -282,7 +282,7 @@ def evaluate_dispatcher(
def dispatcher_metrics( def dispatcher_metrics(
state, card,
deadline_ms: int, deadline_ms: int,
expected_decision: str = "accept", expected_decision: str = "accept",
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
@ -295,8 +295,11 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса. источник информации, но не обязательный шлюз статуса.
""" """
metrics: list[Metric] = [] metrics: list[Metric] = []
for service in (services if services is not None else state.managed_services()): # Через таймер, а не `primary.at - dispatched_at`: календарная разница
marks = [mark for mark in state.status_log if mark.service == service] # включила бы паузу преподавателя в норматив (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} statuses = {mark.status for mark in marks}
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0): 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) 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, add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0) primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с", add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
@ -326,7 +327,7 @@ def dispatcher_metrics(
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0) expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline": if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue continue
crew = state.crew_assignments.get(service) crew = card.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew), add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана", crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0) "необходимую бригаду выбирает ДДС вручную", 2.0)

View file

@ -454,7 +454,14 @@ class SessionState(PersistedSession):
return self.ended_at is not None return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime: def end(self, reason: CallEndReason) -> datetime:
"""Закрыть занятие; возвращает момент окончания для журнала.""" """Закрыть занятие; возвращает момент окончания для журнала.
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
потеряет последний, ещё не закрытый интервал паузы (lct-39).
"""
self.resume()
self.ended_at = now_utc() self.ended_at = now_utc()
self.end_reason = reason self.end_reason = reason
return self.ended_at return self.ended_at

View file

@ -6,18 +6,25 @@
""" """
import asyncio import asyncio
import time
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest 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.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.domain.timers import TimerCode
from app.main import app
from app.scenarios.loader import load_file 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 import timers as timers_module
from app.session.checkpoint import dump_state, load_state 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.hub import hub
from app.session.state import SessionState, now_utc from app.session.state import SessionState, now_utc
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore 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" 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: class FakeClock:
"""Управляемая замена `time.monotonic`: тест продвигает время явно.""" """Управляемая замена `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, LessonPaused) for record in records)
assert any(isinstance(record, LessonResumed) for record in records) assert any(isinstance(record, LessonResumed) for record in records)
assert state.paused is False 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
)