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,