Merge branch 'feat/dds-ack-limit'

lct-11: срок решения на пульте ДДС из снимка сервера
This commit is contained in:
GGlamer 2026-09-26 23:01:15 +03:00 • committed by GitHub
commit 02c86e457d
8 changed files with 134 additions and 21 deletions

View file

@ -337,7 +337,9 @@ async def _handle(session_id: UUID, state, event) -> None:
# первого своего действия: отмечаться ему по нему же (lct-33).
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))
tick = TimerTick(timers=state.timers.snapshot())
hub.to_observers(session_id, tick)
hub.to_station(session_id, tick)
case "call.resolve":
# Курсант закрывает вызов не карточкой. Отдельное действие, а не

View file

@ -328,7 +328,11 @@ class SessionHub:
# Keep the pending count and countdown live even while
# the active dispatcher card is being handled.
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
tick = TimerTick(timers=state.timers.snapshot())
self.broadcast(session_id, tick)
# Срок первичного решения пульт берёт отсюда: после передачи
# из 112 без очереди ДДС лимит преподавателя больше нигде не виден.
self.to_station(session_id, tick)
except asyncio.CancelledError:
raise

View file

@ -0,0 +1,44 @@
"""Такт таймеров доходит до АРМ ДДС: пульт строит срок решения по снимку сервера."""
import asyncio
from pathlib import Path
from uuid import uuid4
from app.domain.events import Exercise, SessionMode, TimerTick
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session import hub as hub_module
from app.session.hub import SessionHub
from app.session.state import SessionState
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
async def test_station_receives_dds_ack_with_configured_limit_after_handoff(monkeypatch):
"""Передача из 112 без очереди ДДС: `queue_cards` пуст, и лимит решения,
настроенный преподавателем, пульт видит только в `timer.tick`."""
monkeypatch.setattr(hub_module, "TICK_SECONDS", 0.01)
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.CARD, handoff_to_dds=True, scenario=scenario,
)
state.timers.limits[TimerCode.DDS_ACK] = 45_000
state.on_event("dds.dispatch")
state.dispatch()
local_hub = SessionHub()
local_hub.register(state)
with local_hub.station(state.session_id) as station:
local_hub.start_ticker(state.session_id)
try:
async with asyncio.timeout(1):
while not isinstance(event := await station.get(), TimerTick):
pass
finally:
local_hub.stop_ticker(state.session_id)
ack = next(timer for timer in event.timers if timer.code is TimerCode.DDS_ACK)
assert ack.limit_ms == 45_000
assert not ack.stopped

View file

@ -11,7 +11,8 @@
"check:kio": "node scripts/check-kio-fields.mjs",
"check:a11y": "node scripts/check-accessible-controls.mjs",
"test:ws-outbox": "node scripts/test-ws-outbox.mjs",
"test:dds-history": "node scripts/test-dds-history.mjs"
"test:dds-history": "node scripts/test-dds-history.mjs",
"test:dds-ack": "node scripts/test-dds-ack.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",

View file

@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { ackDeadline } from "../src/pages/dds/ack.mjs";
const queueCard = (overrides) => ({ card_id: "c1", elapsed_ms: 12_000, limit_ms: 45_000,
timer_stopped: false, active: true, ...overrides });
const ackTimer = (overrides) => ({ code: "dds_ack", elapsed_ms: 12_000, limit_ms: 45_000,
state: "ok", stopped: false, ...overrides });
assert.equal(ackDeadline([], []), null, "до передачи карточки отсчёта нет");
assert.deepEqual(ackDeadline([], [queueCard({ active: false }), queueCard({ card_id: "c2", elapsed_ms: 3_000 })]),
{ elapsedMs: 3_000, limitMs: 45_000, violated: false },
"срок берётся у активной карточки очереди, а не у первой");
assert.deepEqual(ackDeadline([ackTimer({ elapsed_ms: 99_000, limit_ms: 30_000 })], [queueCard()]),
{ elapsedMs: 12_000, limitMs: 45_000, violated: false },
"снимок активной карточки важнее timer.tick, который мог остаться от прошлой карточки");
assert.deepEqual(ackDeadline([ackTimer()], []),
{ elapsedMs: 12_000, limitMs: 45_000, violated: false },
"без очереди (передача из 112) — таймер dds_ack из timer.tick");
assert.equal(ackDeadline([ackTimer({ elapsed_ms: 45_000 })], []).violated, false,
"ровно на лимите нарушения нет — как state_for на сервере");
assert.equal(ackDeadline([ackTimer({ elapsed_ms: 45_001 })], []).violated, true);
assert.equal(ackDeadline([], [queueCard({ elapsed_ms: 45_001 })]).violated, true);
assert.equal(ackDeadline([ackTimer({ stopped: true })], []), null, "сервер остановил таймер — отсчёт скрыт");
assert.equal(ackDeadline([ackTimer()], [queueCard({ timer_stopped: true })]), null);
assert.equal(ackDeadline([ackTimer({ code: "dds_work" })], []), null, "чужой таймер не подменяет dds_ack");
console.log("DDS ack deadline follows server snapshots: limit, violation and stop.");

View file

@ -5,13 +5,13 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { KioCard } from "@/features/kio-card/KioCard";
import { Timers } from "@/features/timers/Timers";
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
import { ackDeadline } from "./ack.mjs";
import { filterSortHistory } from "./history.mjs";
import { sessionIdFromUrl } from "@/shared/api/session";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated";
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
const ACK_LIMIT_S = 30;
const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone })));
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
@ -224,7 +224,6 @@ export function Dds() {
const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState("");
const [receivedAt, setReceivedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [station, setStation] = useState<DdsSnapshot | null>(null);
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
@ -307,6 +306,7 @@ export function Dds() {
openCardAfterSwitch.current = false;
setPane(null);
setActiveService(null);
setTimers([]);
}
setCard(applyState(emptyCard, { ...event.card }));
setFrom(event.from_operator);
@ -334,15 +334,6 @@ export function Dds() {
return () => { controller.abort(); ch.close(); channel.current = null; };
}, [sessionId, role]);
const primaryRecorded = Boolean(station?.services.length && station.services.every((service) =>
["accepted", "declined", "responding", "arrived", "working", "completed", "refused"]
.includes(station.statuses[service])));
useEffect(() => {
if (receivedAt === null || primaryRecorded) return;
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
return () => clearInterval(timer);
}, [receivedAt, primaryRecorded]);
useEffect(() => {
const timer = setInterval(() => setClockNow(new Date()), 1000);
return () => clearInterval(timer);
@ -366,6 +357,7 @@ export function Dds() {
if (!completedCards.some((item) => item.card_id === archived.card_id)) completedCards.push(archived);
}
const queueCards = station?.queue_cards ?? [];
const ack = ackDeadline(timers, queueCards);
const query = search.trim().toLocaleLowerCase("ru-RU");
const filteredCompletedCards = statusFilter === "pending" ? [] : completedCards.filter((item) => {
const serviceMatches = serviceFilter === "all" || item.managed_service === serviceFilter;
@ -497,9 +489,6 @@ export function Dds() {
</tr>)}
{visibleQueueCards.map((item) => {
const received = new Date(item.received_at);
const liveElapsed = item.timer_stopped ? item.elapsed_ms / 1000 : Math.max(
item.elapsed_ms / 1000, (clockNow.getTime() - received.getTime()) / 1000,
);
const openCard = () => {
if (item.active) {
send({ type: "card.open", card_id: item.card_id });
@ -527,8 +516,8 @@ export function Dds() {
<td data-label="Адрес">{item.address || "—"}</td>
<td data-label="Статус службы"><span className="dds-cell-value">
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
<small className={!item.timer_stopped && liveElapsed * 1000 > item.limit_ms ? "state-violated" : ""}>
{item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с
<small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
{item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с
</small>
</span></td>
</tr>})}
@ -612,8 +601,8 @@ export function Dds() {
{pane === "status" && <section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
<span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
{!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}>
Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>}
{ack && <div className={`dds-deadline ${ack.violated ? "state-violated" : "state-ok"}`}>
Первичное решение «Принята» / «Не принята»: {Math.ceil(ack.elapsedMs / 1000)} из {Math.ceil(ack.limitMs / 1000)} секунд</div>}
<Timers timers={timers.filter((timer) => timer.code === "dds_ack" || timer.code === "dds_work")} />
{station && station.services.length > 0 ? <>
<div className="dds-status-history"><strong>Служба {activeService ?? station.services[0]}</strong>

View file

@ -0,0 +1,9 @@
import type { DdsQueueCard, TimerSnapshot } from "@/shared/types/generated";
export interface AckDeadline {
elapsedMs: number;
limitMs: number;
violated: boolean;
}
export function ackDeadline(timers: TimerSnapshot[], queueCards: DdsQueueCard[]): AckDeadline | null;

View file

@ -0,0 +1,31 @@
/**
* @typedef {Object} AckDeadline
* @property {number} elapsedMs
* @property {number} limitMs
* @property {boolean} violated
*/
/**
* Срок первичного решения «Принята» / «Не принята» только по снимкам сервера.
*
* Лимит настраивает преподаватель, остановку фиксирует сервер на первом статусе
* из `PRIMARY`: своя константа или свой список статусов на пульте расходятся
* с тем, по чему ставится оценка. Активная карточка очереди важнее `timer.tick` —
* после переключения карточки тик до следующей секунды ещё несёт таймер прежней.
*
* @param {Array<{code: string, elapsed_ms: number, limit_ms: number, stopped?: boolean}>} timers
* @param {Array<{elapsed_ms: number, limit_ms: number, timer_stopped: boolean, active: boolean}>} queueCards
* @returns {AckDeadline | null} null — отсчёт не показывать
*/
export function ackDeadline(timers, queueCards) {
const active = queueCards.find((item) => item.active);
if (active) return active.timer_stopped ? null : deadline(active.elapsed_ms, active.limit_ms);
const timer = timers.find((item) => item.code === "dds_ack");
if (!timer || timer.stopped) return null;
return deadline(timer.elapsed_ms, timer.limit_ms);
}
/** Граница как у `state_for` на сервере: ровно на лимите нарушения ещё нет. */
function deadline(elapsedMs, limitMs) {
return { elapsedMs, limitMs, violated: elapsedMs > limitMs };
}