lct-15: самооценка курсанта до показа автооценки

Правило сервера, а не интерфейса: score.ready курсанту уходит только
после self_assessment.submit, обойти его через DevTools нельзя.
Преподаватель и монитор получают оценку сразу — им ждать нечего.

Чек-лист для самооценки отдаётся отдельной точкой и только после конца
звонка: во время разговора это содержимое подсказок, и открыть его
значило бы выдать в контрольном режиме то, чего там быть не должно.

Попутно подключён расчёт оценки при завершении звонка — детерминированный
слой был написан в lct-12, но его никто не вызывал.

Тест на отсутствие события смотрит очередь курсанта, а не ждёт из сокета:
ожидание того, чего не будет, вешает прогон навсегда.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:24:34 +03:00
commit 25143645d3
10 changed files with 260 additions and 2 deletions

View file

@ -11,6 +11,7 @@ import { type Capture, startCapture } from "@/shared/audio/capture";
import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
@ -33,6 +34,9 @@ export function useCall(sessionId: string | null) {
const [callerSpeaking, setCallerSpeaking] = useState(false);
const [micOn, setMicOn] = useState(false);
const [card, setCard] = useState<CardState>(emptyCard);
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
const [selfAssessed, setSelfAssessed] = useState(false);
const [scoreReady, setScoreReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
@ -98,6 +102,15 @@ export function useCall(sessionId: string | null) {
case "call.ended":
setPhase("ended");
audio.current?.ambience.stop();
// Чек-лист открывается только после звонка: во время него это
// содержимое подсказок.
void fetch(`/api/sessions/${sessionId}/checklist`)
.then((response) => (response.ok ? response.json() : []))
.then(setChecklist)
.catch(() => setChecklist([]));
break;
case "score.ready":
setScoreReady(true);
break;
case "error":
setError(event.message);
@ -162,8 +175,14 @@ export function useCall(sessionId: string | null) {
channel.current?.send({ type: "dds.dispatch", service });
}, []);
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true);
}, []);
return {
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
answer, hangup, hint, patchKio, dispatch,
checklist, selfAssessed, scoreReady,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
};
}

View file

@ -0,0 +1,65 @@
// Самооценка курсанта — до показа автооценки.
//
// Педагогический смысл — рефлексия: курсант сверяет своё ощущение с объективной
// оценкой, а расхождение — отдельный материал для преподавателя. Курсант,
// не заметивший, что пропустил вопрос о пострадавших, — более важный случай,
// чем сама ошибка (docs/product/DEBRIEF.md).
import { useState } from "react";
export interface ChecklistItem {
id: string;
question: string;
}
export function SelfAssessment({
checklist,
onSubmit,
}: {
checklist: ChecklistItem[];
onSubmit: (missed: string[], comment: string) => void;
}) {
const [missed, setMissed] = useState<string[]>([]);
const [comment, setComment] = useState("");
const toggle = (id: string) =>
setMissed((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
return (
<section>
<h2>Самооценка</h2>
<p>Отметьте вопросы, которые, по вашему мнению, вы пропустили. Оценка откроется после.</p>
<table className="grid">
<tbody>
{checklist.map((item) => (
<tr key={item.id}>
<th>
<label>
<input type="checkbox" checked={missed.includes(item.id)} onChange={() => toggle(item.id)} />{" "}
пропустил
</label>
</th>
<td>{item.question}</td>
</tr>
))}
<tr>
<th>Комментарий</th>
<td>
<input
type="text"
value={comment}
placeholder="например: запнулся на адресе, растерялся"
onChange={(event) => setComment(event.target.value)}
/>
</td>
</tr>
</tbody>
</table>
<p>
<button type="button" onClick={() => onSubmit(missed, comment)}>
Отправить самооценку
</button>
</p>
</section>
);
}

View file

@ -3,6 +3,7 @@
import { KioCard } from "@/features/kio-card/KioCard";
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { display } from "@/features/kio-card/merge";
import { useCall } from "@/features/call/useCall";
@ -54,6 +55,13 @@ export function Call() {
{call.error && <p className="violated">{call.error}</p>}
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
)}
{call.selfAssessed && (
<p>{call.scoreReady ? "Оценка готова — разбор открывает преподаватель." : "Самооценка принята, оценка считается…"}</p>
)}
<div className="columns">
<section>
<h2>Карточка информационного обмена</h2>