diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index be795a1..c91eb16 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -25,7 +25,7 @@ from app.db.models import Group, Score, Session, Trainee from app.domain.events import Exercise, SessionMode, SessionReport from app.domain.roles import Role from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current -from app.domain.timers import TimerCode +from app.domain.timers import TimerCode, TimerState from app.scoring.export import to_csv, to_pdf from app.scoring.report import build as build_report from app.session.access import can_access @@ -93,7 +93,7 @@ class Signal(BaseModel): `severity`; текст обязателен и не заменяется цветом.""" kind: Literal["backlog", "refusals", "offline"] - severity: Literal["warn", "violated"] + severity: TimerState text: str @@ -212,14 +212,18 @@ def _signals( """ settings = get_settings() signals: list[Signal] = [] - if len(queue) >= settings.signal_backlog_threshold: + # Необработанная — без первичного статуса: он останавливает норматив + # DDS_ACK. Принятая или отклонённая карточка висит в очереди до + # «Следующей», но реакции курсанта уже не ждёт. + pending = sum(not card.timer_stopped for card in queue) + if pending >= settings.signal_backlog_threshold: signals.append(Signal( - kind="backlog", severity="warn", - text=f"Очередь: {len(queue)} необработанных карточек", + kind="backlog", severity=TimerState.WARN, + text=f"Очередь: {pending} необработанных карточек", )) if state.consecutive_refusals >= settings.signal_refusals_threshold: signals.append(Signal( - kind="refusals", severity="warn", + kind="refusals", severity=TimerState.WARN, text=f"Подряд отказов: {state.consecutive_refusals}", )) if live: @@ -233,10 +237,10 @@ def _signals( ) if not connected and offline_seconds >= settings.signal_offline_window_seconds: signals.append(Signal( - kind="offline", severity="violated", + kind="offline", severity=TimerState.VIOLATED, text=f"Курсант не на связи {int(offline_seconds)} с", )) - return sorted(signals, key=lambda signal: signal.severity != "violated") + return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED) @router.get("/active", response_model=list[ActiveSessionOut]) diff --git a/backend/tests/test_session_signals.py b/backend/tests/test_session_signals.py index e75127e..cc4ba7a 100644 --- a/backend/tests/test_session_signals.py +++ b/backend/tests/test_session_signals.py @@ -3,17 +3,18 @@ import asyncio import time from datetime import UTC, datetime, timedelta -from types import SimpleNamespace from uuid import uuid4 import pytest from fastapi.testclient import TestClient +from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData from starlette.websockets import WebSocketDisconnect from app.api.auth import Principal from app.api.http import sessions as sessions_http from app.api.ws import session as session_ws from app.config import get_settings +from app.db.models import Session from app.domain.events import Exercise, SessionMode from app.domain.roles import Role from app.main import app @@ -156,6 +157,28 @@ def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch): control.__exit__(None, None, None) +def test_declined_card_leaves_backlog(client, monkeypatch): + monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2") + get_settings.cache_clear() + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + first_card = state.desk.ordered()[0] + with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: + read_until(station, "station.state") + station.send_json({ + "type": "card.status", "service": state.card_services(first_card)[0], + "status": "declined", "comment": "не наш адрес, передано в УК", + }) + read_until(station, "station.state") + row = row_for(client, session_id) + assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте" + assert "backlog" not in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + def test_signal_refusals_after_two_consecutive_declines(client): session_id, control = start_two_card_dds(client) try: @@ -406,11 +429,12 @@ def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch): checkpoint_at = datetime.now(UTC) hub.drop(session_id) + row = Session(id=session_id, owner_login="dev", + live_state=snapshot, checkpoint_at=checkpoint_at) + class CheckpointDB: async def scalars(self, _query): - row = SimpleNamespace(id=session_id, owner_login="dev", - live_state=snapshot, checkpoint_at=checkpoint_at) - return SimpleNamespace(all=lambda: [row]) + return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars() monkeypatch.setattr(sessions_http, "require", lambda _request, _role: Principal(login="dev", full_name="Преподаватель", diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts index ee7bf9e..691daed 100644 --- a/frontend/src/shared/api/http.ts +++ b/frontend/src/shared/api/http.ts @@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated"; +import type { + Level, SessionMode, SessionReport, StationSnapshot, TimerState, +} from "@/shared/types/generated"; import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs"; @@ -96,7 +98,7 @@ export interface SessionInfo { export interface Signal { kind: "backlog" | "refusals" | "offline"; - severity: "warn" | "violated"; + severity: TimerState; text: string; }