lct-39: пауза не искажает оценку ДДС и не блокирует завершённое занятие
This commit is contained in:
parent
b77d65c82e
commit
2304d94877
5 changed files with 202 additions and 10 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue