From f6d0653e68876d1b8be663ff016b4c7477e681f1 Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 20:59:50 +0000 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B6=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=20hook=20useSessionReport=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=BC=20409?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/scripts/test-session-report.mjs | 21 ++++++++ frontend/src/features/call/useCall.ts | 15 +----- frontend/src/features/observe/useObserve.ts | 32 +++++------- frontend/src/pages/dds/Dds.tsx | 57 +++++---------------- frontend/src/shared/api/http.ts | 28 +++++++++- frontend/src/shared/api/report.d.mts | 4 ++ frontend/src/shared/api/report.mjs | 28 ++++++++++ 7 files changed, 106 insertions(+), 79 deletions(-) create mode 100644 frontend/scripts/test-session-report.mjs create mode 100644 frontend/src/shared/api/report.d.mts create mode 100644 frontend/src/shared/api/report.mjs diff --git a/frontend/scripts/test-session-report.mjs b/frontend/scripts/test-session-report.mjs new file mode 100644 index 0000000..56ed8d0 --- /dev/null +++ b/frontend/scripts/test-session-report.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; + +import { REPORT_RETRY_LIMIT, reportErrorMessage, reportRetryDelay, shouldRetryReport } from "../src/shared/api/report.mjs"; + +assert.equal(reportErrorMessage(403), "Отчёт недоступен: это занятие другого обучающегося."); +assert.equal(reportErrorMessage(409), "Оценка ещё не готова. Попробуйте получить разбор чуть позже."); +assert.equal(reportErrorMessage(500), "Не удалось получить отчёт (HTTP 500)."); +assert.equal(reportErrorMessage(null), "Не удалось получить отчёт.", "сеть упала — статуса нет"); + +assert.equal(shouldRetryReport(0, 409), true, "оценка ещё считается — повторяем сами"); +assert.equal(shouldRetryReport(REPORT_RETRY_LIMIT - 1, 409), true); +assert.equal(shouldRetryReport(REPORT_RETRY_LIMIT, 409), false, "число попыток ограничено"); +assert.equal(shouldRetryReport(0, 403), false, "чужое занятие не станет своим — не повторяем"); +assert.equal(shouldRetryReport(0, 404), false); +assert.equal(shouldRetryReport(0, 500), false); + +assert.ok(reportRetryDelay(0) > 0); +assert.ok(reportRetryDelay(1) >= reportRetryDelay(0), "паузы не сокращаются"); +assert.ok(reportRetryDelay(50) <= 5_000, "пауза ограничена сверху"); + +console.log("Session report: unified errors, 409 retried with a limit, 403 not retried."); diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index 03cdcc0..dac8097 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useSessionReport } from "@/shared/api/http"; import { type ChannelStatus, callChannel } from "@/shared/api/ws"; import { Ambience } from "@/shared/audio/ambience"; import { type Capture, startCapture } from "@/shared/audio/capture"; @@ -18,7 +19,6 @@ import type { CardBriefing, DDSCode, ServerToTrainee, - SessionReport, TimerSnapshot, } from "@/shared/types/generated"; @@ -48,8 +48,7 @@ export function useCall(sessionId: string | null) { const [checklist, setChecklist] = useState([]); const [selfAssessed, setSelfAssessed] = useState(false); const [scoreReady, setScoreReady] = useState(false); - const [report, setReport] = useState(null); - const [reportError, setReportError] = useState(null); + const { report, error: reportError } = useSessionReport(sessionId, scoreReady); const [error, setError] = useState(null); const channel = useRef | null>(null); @@ -153,16 +152,6 @@ export function useCall(sessionId: string | null) { break; case "score.ready": setScoreReady(true); - setReportError(null); - void fetch(`/api/sessions/${sessionId}/report`) - .then((response) => { - if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся."); - if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже."); - if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`); - return response.json(); - }) - .then(setReport) - .catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт.")); break; case "error": setError(event.message); diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts index 066be98..7505865 100644 --- a/frontend/src/features/observe/useObserve.ts +++ b/frontend/src/features/observe/useObserve.ts @@ -4,9 +4,11 @@ // и он должен показать текущее состояние, а не ждать событий. Отправлять в этот // канал нечего и нельзя: тип `Out` — `never` (docs/arch/CONTRACT.md). +import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { type CardState, applyState, empty as emptyCard } from "@/features/kio-card/merge"; +import { useSessionReport } from "@/shared/api/http"; import { observeChannel, type ChannelStatus } from "@/shared/api/ws"; import type { SessionMode, @@ -43,7 +45,12 @@ export function useObserve(sessionId: string | null): ObservedSession { const [hintsUsed, setHintsUsed] = useState(0); const [notes, setNotes] = useState>(new Map()); const [ended, setEnded] = useState(false); - const [report, setReport] = useState(null); + // Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг: + // при переключении истории старый флаг не должен дёрнуть запрос новой сессии. + const [reportFor, setReportFor] = useState(null); + // Ошибку отчёта наблюдение не показывает: пока оценки нет, это не ошибка. + const { report } = useSessionReport(sessionId, reportFor === sessionId); + const queries = useQueryClient(); const [error, setError] = useState(null); useEffect(() => { @@ -58,7 +65,6 @@ export function useObserve(sessionId: string | null): ObservedSession { setHintsUsed(0); setNotes(new Map()); setEnded(false); - setReport(null); setError(null); if (!sessionId) return; let active = true; @@ -66,23 +72,7 @@ export function useObserve(sessionId: string | null): ObservedSession { // could only return score_not_ready, competing with the snapshot under load. // Ask for the report only after completion or when no live session exists // (e.g. history opened after a backend restart). - let reportLoading = false; - let reportLoaded = false; - const loadReport = () => { - if (reportLoading || reportLoaded) return; - reportLoading = true; - void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" }) - .then((response) => (response.ok ? response.json() : null)) - .then((saved) => { - if (!active) return; - if (saved) { - reportLoaded = true; - setReport(saved as SessionReport); - } - }) - .catch(() => undefined) - .finally(() => { reportLoading = false; }); - }; + const loadReport = () => setReportFor(sessionId); const channel = observeChannel(sessionId, { onStatus: (next) => { if (active) setStatus(next); }, onEvent: (event) => { @@ -128,12 +118,14 @@ export function useObserve(sessionId: string | null): ObservedSession { break; case "score.ready": loadReport(); + // После session.ended hook мог исчерпать повторы 409 до готовности оценки. + void queries.invalidateQueries({ queryKey: ["session-report", sessionId] }); break; } }, }).connect(); return () => { active = false; channel.close(); }; - }, [sessionId]); + }, [sessionId, queries]); return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error }; } diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx index c5ec86c..90d23df 100644 --- a/frontend/src/pages/dds/Dds.tsx +++ b/frontend/src/pages/dds/Dds.tsx @@ -7,9 +7,10 @@ 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 { useSessionReport } from "@/shared/api/http"; 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 type { DdsQueueCard, ServiceStatus, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated"; import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses"; const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone }))); @@ -232,9 +233,10 @@ export function Dds() { const [checkText, setCheckText] = useState(""); const [error, setError] = useState(""); const [ended, setEnded] = useState(false); - const [report, setReport] = useState(null); - const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle"); - const [reportError, setReportError] = useState(""); + // Разбор просим только после `score.ready`; новая карточка снимает флаг. + const [scoreReady, setScoreReady] = useState(false); + const { report, loading: reportLoading, error: reportError, refetch: refetchReport } = + useSessionReport(sessionId, scoreReady); const [zone, setZone] = useState(null); const [reply, setReply] = useState({ text: "", dirty: false }); const [advancePending, setAdvancePending] = useState(false); @@ -244,28 +246,6 @@ export function Dds() { useEffect(() => { if (!sessionId) return; - const controller = new AbortController(); - async function loadReport() { - setReportState("loading"); - setReportError(""); - try { - const response = await fetch(`/api/sessions/${sessionId}/report`, { - credentials: "same-origin", - signal: controller.signal, - }); - if (!response.ok) { - if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося."); - if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже."); - throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`); - } - setReport(await response.json() as SessionReport); - setReportState("ready"); - } catch (cause) { - if (controller.signal.aborted) return; - setReportState("error"); - setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт."); - } - } const ch = stationChannel(sessionId, role, { onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); }, onEvent: (event) => { @@ -300,8 +280,7 @@ export function Dds() { setBrief({ address: "", incident: "", request: "" }); setCheckText(""); setZone(null); - setReport(null); - setReportState("idle"); + setScoreReady(false); setView(openCardAfterSwitch.current ? "card" : "registry"); openCardAfterSwitch.current = false; setPane(null); @@ -319,7 +298,7 @@ export function Dds() { } else if (event.type === "timer.tick") setTimers(event.timers); else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); } else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); } - else if (event.type === "score.ready") { void loadReport(); } + else if (event.type === "score.ready") setScoreReady(true); else if (event.type === "phone.report") { setLastReport(event); setDialing(false); @@ -331,7 +310,7 @@ export function Dds() { }, }).connect(); channel.current = ch; - return () => { controller.abort(); ch.close(); channel.current = null; }; + return () => { ch.close(); channel.current = null; }; }, [sessionId, role]); useEffect(() => { @@ -390,7 +369,7 @@ export function Dds() { {error &&
Действие не принято: {error}
} {ended &&
Занятие завершено. Оценка появится после расчёта.
} - {(ended || reportState !== "idle") &&
+ {(ended || scoreReady) &&

Итог упражнения

{report ?

Итоговая оценка: {report.score_final.toFixed(0)} из 100 @@ -425,20 +404,8 @@ export function Dds() { {report.findings.length === 0 && Замечаний нет.}

:
- {reportState === "loading" ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"} - {reportState === "error" && } + {reportLoading ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"} + {reportError && }
}
} diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts index 562464c..369e206 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, StationSnapshot } from "@/shared/types/generated"; +import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated"; + +import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs"; export class ApiError extends Error { constructor( @@ -328,6 +330,30 @@ export const useProfile = (traineeId: string | null) => enabled: Boolean(traineeId), }); +const errorStatus = (error: unknown) => (error instanceof ApiError ? error.status : null); + +/** + * Отчёт сессии. `ready = false` — запроса нет и отчёт не показывается: его + * просят после `score.ready` или у уже завершённой сессии. 409 (оценка ещё + * считается) повторяется сам с ограниченным числом попыток. + */ +export function useSessionReport(sessionId: string | null, ready: boolean) { + const enabled = Boolean(sessionId) && ready; + const query = useQuery({ + queryKey: ["session-report", sessionId], + queryFn: () => request(`/api/sessions/${sessionId}/report`), + enabled, + retry: (failureCount, error) => shouldRetryReport(failureCount, errorStatus(error)), + retryDelay: reportRetryDelay, + }); + return { + report: enabled ? query.data ?? null : null, + loading: enabled && query.isFetching, + error: enabled && query.isError ? reportErrorMessage(errorStatus(query.error)) : "", + refetch: query.refetch, + }; +} + export const useCreateSession = () => useMutation({ mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) => diff --git a/frontend/src/shared/api/report.d.mts b/frontend/src/shared/api/report.d.mts new file mode 100644 index 0000000..ece46bb --- /dev/null +++ b/frontend/src/shared/api/report.d.mts @@ -0,0 +1,4 @@ +export const REPORT_RETRY_LIMIT: number; +export function reportErrorMessage(status: number | null): string; +export function shouldRetryReport(failureCount: number, status: number | null): boolean; +export function reportRetryDelay(attempt: number): number; diff --git a/frontend/src/shared/api/report.mjs b/frontend/src/shared/api/report.mjs new file mode 100644 index 0000000..d096b92 --- /dev/null +++ b/frontend/src/shared/api/report.mjs @@ -0,0 +1,28 @@ +// Правила чтения отчёта сессии — одни для пульта ДДС, звонка 112 и наблюдения. +// Отдельный .mjs, чтобы проверять их node-скриптом без сборки. + +/** Сколько раз повторять 409: оценка к этому моменту обычно уже посчитана. */ +export const REPORT_RETRY_LIMIT = 6; + +/** @param {number | null} status HTTP-статус; `null` — ответа не было. */ +export function reportErrorMessage(status) { + if (status === 403) return "Отчёт недоступен: это занятие другого обучающегося."; + if (status === 409) return "Оценка ещё не готова. Попробуйте получить разбор чуть позже."; + if (status == null) return "Не удалось получить отчёт."; + return `Не удалось получить отчёт (HTTP ${status}).`; +} + +/** + * 409 — оценка ещё считается, она появится сама. 403 и прочее от повтора + * не изменятся. + * @param {number} failureCount + * @param {number | null} status + */ +export function shouldRetryReport(failureCount, status) { + return status === 409 && failureCount < REPORT_RETRY_LIMIT; +} + +/** @param {number} attempt */ +export function reportRetryDelay(attempt) { + return Math.min(1_000 * 2 ** attempt, 5_000); +} From 3dc116534d5d726674e52d5241ac4a9f3c3623a9 Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 20:59:50 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20useControl.start=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=2014?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 4 ++- frontend/scripts/test-scenario-start.mjs | 31 ++++++++++++++++ frontend/src/features/instructor/start.d.mts | 36 +++++++++++++++++++ frontend/src/features/instructor/start.mjs | 26 ++++++++++++++ .../src/features/instructor/useControl.ts | 32 ++++------------- frontend/src/pages/instructor/Instructor.tsx | 22 ++++++++---- 6 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 frontend/scripts/test-scenario-start.mjs create mode 100644 frontend/src/features/instructor/start.d.mts create mode 100644 frontend/src/features/instructor/start.mjs diff --git a/frontend/package.json b/frontend/package.json index b70a5c1..6799fbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,9 @@ "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-ack": "node scripts/test-dds-ack.mjs" + "test:dds-ack": "node scripts/test-dds-ack.mjs", + "test:session-report": "node scripts/test-session-report.mjs", + "test:scenario-start": "node scripts/test-scenario-start.mjs" }, "dependencies": { "@tanstack/react-query": "^5.103.1", diff --git a/frontend/scripts/test-scenario-start.mjs b/frontend/scripts/test-scenario-start.mjs new file mode 100644 index 0000000..70c9764 --- /dev/null +++ b/frontend/scripts/test-scenario-start.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; + +import { scenarioStartMessage } from "../src/features/instructor/start.mjs"; + +const minimal = JSON.parse(JSON.stringify(scenarioStartMessage({ + scenarioId: "s1", mode: "exam", trainee: "Иванов", +}))); +assert.deepEqual(minimal, { + type: "scenario.start", scenario_id: "s1", mode: "exam", trainee: "Иванов", + group_id: null, trainee_id: null, dds_service: null, exercise: "dds", handoff_to_dds: false, + dds_arrival_interval_seconds: 60, dds_max_waiting: 3, +}, "умолчания прежние, пустые списки и критерии в сокет не уходят"); + +const criteria = { allowed_errors: 2 }; +const full = scenarioStartMessage({ + scenarioId: "s1", mode: "training", trainee: "Петров", exercise: "call", groupId: "g1", + traineeId: "t1", ddsService: "01", scenarioIds: ["s1", "s2"], randomScenarioIds: ["s3"], + ddsArrivalIntervalSeconds: 30, ddsMaxWaiting: 5, handoffToDds: true, criteria, +}); +assert.deepEqual(full, { + type: "scenario.start", scenario_id: "s1", mode: "training", trainee: "Петров", + group_id: "g1", trainee_id: "t1", dds_service: "01", exercise: "call", handoff_to_dds: true, + scenario_ids: ["s1", "s2"], random_scenario_ids: ["s3"], + dds_arrival_interval_seconds: 30, dds_max_waiting: 5, criteria, +}); + +const empty = scenarioStartMessage({ scenarioId: "s1", mode: "exam", trainee: "И", scenarioIds: [], randomScenarioIds: [] }); +assert.equal(empty.scenario_ids, undefined, "пустой список не отправляется"); +assert.equal(empty.random_scenario_ids, undefined); + +console.log("scenario.start: named options build the same socket message."); diff --git a/frontend/src/features/instructor/start.d.mts b/frontend/src/features/instructor/start.d.mts new file mode 100644 index 0000000..35f1cad --- /dev/null +++ b/frontend/src/features/instructor/start.d.mts @@ -0,0 +1,36 @@ +import type { LessonCriteria, SessionMode } from "@/shared/types/generated"; + +export interface ScenarioStartOptions { + scenarioId: string; + mode: SessionMode; + trainee: string; + exercise?: "dds" | "call" | "card"; + groupId?: string; + traineeId?: string; + ddsService?: string; + scenarioIds?: string[]; + randomScenarioIds?: string[]; + ddsArrivalIntervalSeconds?: number; + ddsMaxWaiting?: number; + handoffToDds?: boolean; + criteria?: LessonCriteria; +} + +export interface ScenarioStartMessage { + type: "scenario.start"; + scenario_id: string; + mode: SessionMode; + trainee: string; + group_id: string | null; + trainee_id: string | null; + dds_service: string | null; + exercise: "dds" | "call" | "card"; + handoff_to_dds: boolean; + scenario_ids: string[] | undefined; + random_scenario_ids: string[] | undefined; + dds_arrival_interval_seconds: number; + dds_max_waiting: number; + criteria: LessonCriteria | undefined; +} + +export function scenarioStartMessage(options: ScenarioStartOptions): ScenarioStartMessage; diff --git a/frontend/src/features/instructor/start.mjs b/frontend/src/features/instructor/start.mjs new file mode 100644 index 0000000..488e62a --- /dev/null +++ b/frontend/src/features/instructor/start.mjs @@ -0,0 +1,26 @@ +// Сборка `scenario.start` из именованных параметров консоли преподавателя. +// Отдельный .mjs, чтобы состав сообщения проверялся node-скриптом без сборки. + +/** @param {import("./start.d.mts").ScenarioStartOptions} options */ +export function scenarioStartMessage({ + scenarioId, mode, trainee, exercise = "dds", groupId, traineeId, ddsService, + scenarioIds, randomScenarioIds, ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, + handoffToDds = false, criteria, +}) { + return { + type: "scenario.start", + scenario_id: scenarioId, + mode, + trainee, + group_id: groupId || null, + trainee_id: traineeId || null, + dds_service: ddsService || null, + exercise, + handoff_to_dds: handoffToDds, + scenario_ids: scenarioIds?.length ? scenarioIds : undefined, + random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined, + dds_arrival_interval_seconds: ddsArrivalIntervalSeconds, + dds_max_waiting: ddsMaxWaiting, + criteria, + }; +} diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts index 92e7496..02b16d3 100644 --- a/frontend/src/features/instructor/useControl.ts +++ b/frontend/src/features/instructor/useControl.ts @@ -8,7 +8,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { controlChannel, type ChannelStatus } from "@/shared/api/ws"; -import type { LessonCriteria, SessionMode } from "@/shared/types/generated"; + +import { scenarioStartMessage, type ScenarioStartOptions } from "./start.mjs"; export function useControl(sessionId: string | null) { const [status, setStatus] = useState("closed"); @@ -37,30 +38,11 @@ export function useControl(sessionId: string | null) { }; }, [sessionId]); - const start = useCallback( - (targetSessionId: string, scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria, randomScenarioIds?: string[]) => { - const message = { - type: "scenario.start", - scenario_id: scenarioId, - mode, - trainee, - group_id: group || null, - trainee_id: traineeId || null, - dds_service: ddsService || null, - exercise, - handoff_to_dds: handoffToDds, - scenario_ids: scenarioIds?.length ? scenarioIds : undefined, - random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined, - dds_arrival_interval_seconds: ddsArrivalIntervalSeconds, - dds_max_waiting: ddsMaxWaiting, - criteria, - } as const; - const current = channel.current; - if (!current || current.sessionId !== targetSessionId) return false; - return current.socket.send(message); - }, - [], - ); + const start = useCallback((targetSessionId: string, options: ScenarioStartOptions) => { + const current = channel.current; + if (!current || current.sessionId !== targetSessionId) return false; + return current.socket.send(scenarioStartMessage(options)); + }, []); const note = useCallback( (transcriptRef: string, text: string) => diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 481eac9..3c1f435 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -283,19 +283,27 @@ export function Instructor() { && !(exercise === "dds" && wholeTicket) ? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id) : undefined; - const sent = control.start(sessionId, primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант", - exercise === "card_dds" ? "card" : exercise, - undefined, selected?.id, selected?.service ?? undefined, - scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined, - ddsArrivalIntervalSeconds, ddsMaxWaiting, - exercise === "card_dds", { + const sent = control.start(sessionId, { + scenarioId: primaryScenarioId, + mode, + trainee: selected?.name || trainee || "Демо-курсант", + exercise: exercise === "card_dds" ? "card" : exercise, + traineeId: selected?.id, + ddsService: selected?.service ?? undefined, + scenarioIds: scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined, + randomScenarioIds, + ddsArrivalIntervalSeconds, + ddsMaxWaiting, + handoffToDds: exercise === "card_dds", + criteria: { decision_time_limit_seconds: decisionTimeLimit, card_fill_time_limit_seconds: cardFillTimeLimit, dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit, allowed_errors: allowedErrors, require_correct_grammar: requireCorrectGrammar, score_weights: lessonScoreWeights, - }, randomScenarioIds); + }, + }); if (sent) toStart.current = null; }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,