Files
glchat/web/tests/dmCallRoutes.ts
T
grendervill 2b9bf55f16 feat(web): интерфейс звонков в беседах (Фаза 7)
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и
история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок,
который не поднимется, нельзя.

Входящий звонок показывается карточкой поверх любого экрана с принять и
отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а
в области переписки вместо ленты открывается панель с плитками участников и
кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто
звонил, когда, сколько длился разговор и кто не ответил.

Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit,
входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию
и его снятие после завершения, флаги микрофона, камеры и экрана, история с
пропущенными и длительностью, выключенный голос и маркер после
перезагрузки (active_call в READY).
2026-09-26 17:12:56 +03:00

194 lines
5.7 KiB
TypeScript

import { apiError, json, type FetchRoute } from './helpers';
import { fakeVoiceToken } from './voiceRoutes';
import type { DmCall, DmCallMedia, DmCallParticipantState } from '@/api/calls';
/**
* Маршруты звонков в беседах для тестов: состояние живёт в замыкании, поэтому
* ответы согласованы между запросами — как у настоящего сервера (AGENT.md 7.8).
*/
export function makeDmCall(overrides: Partial<DmCall> & Pick<DmCall, 'id' | 'channel_id'>): DmCall {
return {
initiator_id: 'user-1',
status: 'ringing',
media: 'audio',
created_at: '2026-09-26T10:00:00Z',
duration_seconds: 0,
participants: [],
...overrides,
};
}
/** Участник звонка в моке. */
export function makeCallParticipant(
user_id: string,
state: DmCallParticipantState,
overrides: Partial<DmCall['participants'][number]> = {},
): DmCall['participants'][number] {
return {
user_id,
state,
self_mute: false,
self_deaf: false,
camera: false,
screen: false,
...overrides,
};
}
export interface DmCallRoutes {
routes: FetchRoute[];
/** Текущий звонок «сервера»: тесты читают его после запросов. */
call: () => DmCall | null;
/** Что вернул сервер на последний запрос; для проверки тела PATCH. */
setCall: (call: DmCall | null) => void;
}
export interface DmCallRoutesOptions {
channelId?: string;
/** Стартовый звонок: null — никто не звонит. */
call?: DmCall | null;
/** История звонков беседы. */
history?: DmCall[];
/** Код ошибки на создание звонка (`voice.disabled`, `dm.call_limit`, ...). */
startError?: { code: string; status: number };
token?: string;
url?: string;
}
export function dmCallRoutes(options: DmCallRoutesOptions = {}): DmCallRoutes {
const channelId = options.channelId ?? 'c-dm';
let call: DmCall | null = options.call ?? null;
const history = options.history ?? [];
const join = (): Response => {
if (call === null) {
return apiError('dm.call_not_found', 404);
}
return json({
call,
token: options.token ?? fakeVoiceToken(),
url: options.url ?? 'wss://gl.localhost/rtc',
room: `dm_call_${call.id}`,
});
};
const routes: FetchRoute[] = [
{
match: `/channels/${channelId}/call/accept`,
method: 'POST',
response: () => {
if (call !== null) {
call = {
...call,
status: 'active',
started_at: '2026-09-26T10:00:05Z',
participants: call.participants.map((participant) =>
participant.state === 'invited' ? { ...participant, state: 'joined' } : participant,
),
};
}
return join();
},
},
{
match: `/channels/${channelId}/call/decline`,
method: 'POST',
response: () => {
if (call !== null) {
call = {
...call,
status: 'ended',
end_reason: 'declined',
ended_at: '2026-09-26T10:00:06Z',
participants: call.participants.map((participant) =>
participant.state === 'invited' ? { ...participant, state: 'declined' } : participant,
),
};
}
return json({ call });
},
},
{
match: `/channels/${channelId}/call/end`,
method: 'POST',
response: () => {
if (call !== null) {
call = {
...call,
status: 'ended',
end_reason: 'completed',
ended_at: '2026-09-26T10:01:00Z',
participants: call.participants.map((participant) =>
participant.state === 'joined' ? { ...participant, state: 'left' } : participant,
),
};
}
return json({ call });
},
},
{
match: `/channels/${channelId}/call/@me`,
method: 'PATCH',
response: ({ body }) => {
const patch = (body ?? {}) as Record<string, boolean>;
if (call !== null) {
call = {
...call,
participants: call.participants.map((participant) =>
participant.user_id === 'user-1'
? {
...participant,
self_mute: patch['self_mute'] ?? participant.self_mute,
self_deaf: patch['self_deaf'] ?? participant.self_deaf,
camera: patch['camera'] ?? participant.camera,
screen: patch['screen'] ?? participant.screen,
}
: participant,
),
};
}
return json({ call });
},
},
{
match: `/channels/${channelId}/calls`,
method: 'GET',
response: () => json({ calls: history }),
},
{
match: `/channels/${channelId}/call`,
method: 'GET',
response: () => json({ call }),
},
{
match: `/channels/${channelId}/call`,
method: 'POST',
response: ({ body }) => {
if (options.startError !== undefined) {
return apiError(options.startError.code, options.startError.status);
}
const media = ((body ?? {}) as { media?: DmCallMedia }).media ?? 'audio';
call = makeDmCall({
id: 'call-1',
channel_id: channelId,
media,
participants: [
makeCallParticipant('user-1', 'joined'),
makeCallParticipant('user-2', 'invited'),
],
});
return join();
},
},
];
return {
routes,
call: () => call,
setCall: (next) => {
call = next;
},
};
}