From 83b7f8ffdd4d4d5cdd83e9fe7f4924cf7d286469 Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 19:53:45 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20timer.tick=20=D0=B4=D0=BE=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D1=82=20=D0=B4=D0=BE=20=D0=BF=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B0=20=D0=94=D0=94=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/ws/call.py | 4 ++- backend/app/session/hub.py | 6 +++- backend/tests/test_station_ticker.py | 44 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_station_ticker.py diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 82471d4..121a28b 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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": # Курсант закрывает вызов не карточкой. Отдельное действие, а не diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index ef1944f..bb20c0a 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -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 diff --git a/backend/tests/test_station_ticker.py b/backend/tests/test_station_ticker.py new file mode 100644 index 0000000..23ffb63 --- /dev/null +++ b/backend/tests/test_station_ticker.py @@ -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 From 47bda8fba991a4bdee97e2133b1600178f52f5c3 Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 19:53:48 +0000 Subject: [PATCH 2/2] =?UTF-8?q?lct-11:=20=D1=81=D1=80=D0=BE=D0=BA=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B5=20=D0=94=D0=94=D0=A1=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BA=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 3 ++- frontend/scripts/test-dds-ack.mjs | 33 +++++++++++++++++++++++++++++++ frontend/src/pages/dds/Dds.tsx | 25 +++++++---------------- frontend/src/pages/dds/ack.d.mts | 9 +++++++++ frontend/src/pages/dds/ack.mjs | 31 +++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 frontend/scripts/test-dds-ack.mjs create mode 100644 frontend/src/pages/dds/ack.d.mts create mode 100644 frontend/src/pages/dds/ack.mjs diff --git a/frontend/package.json b/frontend/package.json index 2cdc19b..b70a5c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/scripts/test-dds-ack.mjs b/frontend/scripts/test-dds-ack.mjs new file mode 100644 index 0000000..8904eb1 --- /dev/null +++ b/frontend/scripts/test-dds-ack.mjs @@ -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."); diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx index ada0f5f..c5ec86c 100644 --- a/frontend/src/pages/dds/Dds.tsx +++ b/frontend/src/pages/dds/Dds.tsx @@ -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(null); const [from, setFrom] = useState(""); const [receivedAt, setReceivedAt] = useState(null); - const [elapsed, setElapsed] = useState(0); const [station, setStation] = useState(null); const [timers, setTimers] = useState([]); const [lastReport, setLastReport] = useState(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() { )} {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() { {item.address || "—"} {item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]} - item.limit_ms ? "state-violated" : ""}> - {item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с + item.limit_ms ? "state-violated" : ""}> + {item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с })} @@ -612,8 +601,8 @@ export function Dds() { {pane === "status" &&

Реагирование служб

{station ? CARD_STATUS_LABELS[station.card] : "—"}
- {!primaryRecorded && receivedAt !== null &&
ACK_LIMIT_S ? "state-violated" : "state-ok"}`}> - Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд
} + {ack &&
+ Первичное решение «Принята» / «Не принята»: {Math.ceil(ack.elapsedMs / 1000)} из {Math.ceil(ack.limitMs / 1000)} секунд
} timer.code === "dds_ack" || timer.code === "dds_work")} /> {station && station.services.length > 0 ? <>
Служба {activeService ?? station.services[0]} diff --git a/frontend/src/pages/dds/ack.d.mts b/frontend/src/pages/dds/ack.d.mts new file mode 100644 index 0000000..141dd3e --- /dev/null +++ b/frontend/src/pages/dds/ack.d.mts @@ -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; diff --git a/frontend/src/pages/dds/ack.mjs b/frontend/src/pages/dds/ack.mjs new file mode 100644 index 0000000..1b14b53 --- /dev/null +++ b/frontend/src/pages/dds/ack.mjs @@ -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 }; +}