🐛 fix(calls): отбой клиента закрывает окно звонка у оператора

Клиент кладёт трубку, сервер переводит звонок в терминал, а peer connection у оператора узнаёт об этом позже — или не узнаёт вовсе. Проверка терминального статуса в аудиозвонке стояла последней, после состояния соединения, поэтому у оператора оставался экран активного разговора с кнопкой «Завершить», которой нечего было завершать: она молча выходила по той же проверке. Теперь терминальный статус решает первым (как в видеозвонке), а «Завершить» на уже завершённом звонке просто закрывает окно.

Проверено: четыре теста разбора фазы аудиозвонка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-15 07:13:59 +03:00
1 parent e5d3e98ce3
commit ca1b892e11
3 files changed
+53 -4

No files matched your search

@@ -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");
});
});
@@ -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";
}
@@ -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));