diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.test.ts b/apps/internal-ui/src/features/conversations/AudioCallOverlay.test.ts new file mode 100644 index 0000000..2833d10 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAudioMode } from "./AudioCallOverlay"; +import type { ApiCall } from "./model"; + +// Экран аудиозвонка у оператора: что он показывает в каждой фазе. + +function call(status: ApiCall["status"]): ApiCall { + return { id: "c1", conversationId: 1, status, kind: "AUDIO", connectedAt: null, endedBy: null, durationSeconds: null } as ApiCall; +} + +describe("resolveAudioMode", () => { + it("показывает разговор, пока звонок идёт", () => { + expect(resolveAudioMode(call("ACTIVE"), "", "connected", "none")).toBe("active"); + }); + + it("считает звонок завершённым, даже когда соединение ещё считает себя живым", () => { + // Клиент положил трубку: сервер уже в терминале, а peer connection узнаёт + // об этом позже — или не узнаёт вовсе, если сокет мёртв. + expect(resolveAudioMode(call("ENDED"), "", "connected", "none")).toBe("status"); + expect(resolveAudioMode(call("DECLINED"), "", "connected", "none")).toBe("status"); + expect(resolveAudioMode(call("FAILED"), "", "connected", "none")).toBe("status"); + }); + + it("до ответа клиента звонок исходящий", () => { + expect(resolveAudioMode(call("RINGING"), "", "idle", "none")).toBe("ringing"); + }); + + it("принятый звонок без соединения — соединение", () => { + expect(resolveAudioMode(call("ACCEPTED"), "", "idle", "none")).toBe("connecting"); + }); +}); diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx index 06a083e..0995e43 100644 --- a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -67,7 +67,13 @@ export function AudioCallOverlay(props: Props) { // состоянии (карточку звонка могли открыть из списка), а сам запрос может // упасть — тогда звонок заканчивается вторым путём, по сессии оператора. const finish = async () => { - if (!call || isTerminalCallStatus(call.status)) return; + if (!call) return; + // Звонок уже закончил кто-то другой — завершать нечего, окно закрывается. + if (isTerminalCallStatus(call.status)) { + rtc.stop(); + props.onClose(); + return; + } const token = props.access?.accessToken; try { if (token) props.onCallChange(await endCallByAccess(token)); @@ -123,16 +129,21 @@ export function AudioCallOverlay(props: Props) { ); } -function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode { +export function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode { if (errorText || !call) return "status"; if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; + // Завершённый звонок завершён, чем бы ни было занято RTC-соединение. Клиент + // кладёт трубку, сервер переводит звонок в терминал, а peer connection у + // оператора об этом узнаёт позже — а если сокет уже мёртв, то и не узнаёт + // вовсе. Пока эта проверка стояла последней, у оператора оставался экран + // активного разговора с кнопкой «Завершить», которой нечего было завершать. + if (isTerminalCallStatus(call.status)) return "status"; // Звонок всегда инициирует оператор, поэтому до ответа клиента это исходящий. if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing"; if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting"; if (connection === "reconnecting") return "reconnecting"; if (connection === "failed") return "status"; if (call.status === "ACTIVE" || connection === "connected") return "active"; - if (isTerminalCallStatus(call.status)) return "status"; return "connecting"; } diff --git a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx index becc8a4..97a0a62 100644 --- a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx @@ -69,7 +69,13 @@ export function VideoCallOverlay(props: Props) { // «Завершить» обязано сработать всегда: нет токена доступа или запрос не // прошёл — звонок заканчивается вторым путём, по сессии оператора. const finish = async () => { - if (!call || TERMINAL[call.status]) return; + if (!call) return; + // Звонок уже закончил кто-то другой — завершать нечего, окно закрывается. + if (TERMINAL[call.status]) { + rtc.stop(); + props.onClose(); + return; + } const token = props.access?.accessToken; try { if (token) props.onCallChange(await endCallByAccess(token));