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

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

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

372 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { currentRingtone } from '@/lib/ringtone';
import { useDmCallStore } from '@/stores/dmCall';
import { useSessionStore } from '@/stores/session';
import { dmCallRoutes, makeCallParticipant, makeDmCall } from './dmCallRoutes';
import { installFakeAudioContext } from './fakeAudio';
import { FakeRoom } from './fakeLiveKit';
import {
findRequest,
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
vi.mock('livekit-client', async () => {
const fake = await import('./fakeLiveKit');
return {
Room: fake.FakeRoom,
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
/**
* Ответы оболочки: мета с включённым голосом (без неё кнопок звонка нет),
* профиль, инстанс, серверы, связи и список бесед.
*/
const baseRoutes = [
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) },
{
match: '/api/v1/users/@me/relationships',
response: () => json({ friends: [], incoming: [], outgoing: [], blocked: [] }),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: [] }),
},
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
{
match: '/api/v1/instance',
response: () => json({ instance: { name: 'glchat-test', registration_enabled: true } }),
},
];
/** Беседа 1:1 с Бобом в снапшоте READY. */
const dmChannel = {
id: 'c-dm',
name: 'Bob',
recipient_id: 'user-2',
status: 'online',
};
/** Снапшот со звонком в беседе: Алиса приглашена, Боб уже «в комнате». */
function ringingCallSnapshot() {
return makeReadySnapshot(
[],
[
{
...dmChannel,
active_call: makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
media: 'video',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
}),
},
],
);
}
/** Поднимает приложение на беседе с подключённым шлюзом. */
async function bootDm(options: {
snapshot?: unknown;
routes: Parameters<typeof installFetch>[0];
path?: string;
}) {
const fetchMock = installFetch([...baseRoutes, ...options.routes]);
const gateway = installGatewaySocket(options.snapshot ?? makeReadySnapshot([], [dmChannel]));
renderApp(options.path ?? '/app/friends/c-dm');
await gateway.greet();
await screen.findByTestId('dm-header');
return { fetchMock, gateway };
}
describe('звонки в беседах', () => {
beforeEach(() => {
FakeRoom.reset();
installFakeAudioContext();
});
it('начинает аудиозвонок из шапки и подключается к комнате LiveKit', async () => {
const calls = dmCallRoutes();
const { fetchMock } = await bootDm({ routes: calls.routes });
fireEvent.click(screen.getByTestId('dm-call-menu'));
fireEvent.click(await screen.findByTestId('dm-call-audio'));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/channels/c-dm/call', method: 'POST' })).toBeDefined();
});
const start = recordedRequests(fetchMock).find(
(request) => request.method === 'POST' && request.url.includes('/channels/c-dm/call'),
);
expect(start?.body).toEqual({ media: 'audio' });
// Комната поднялась по адресу из ответа сервера, панель звонка на месте.
await waitFor(() => {
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
});
expect(FakeRoom.latest().connectCalls[0]?.url).toBe('wss://gl.localhost/rtc');
expect(await screen.findByTestId('dm-call-panel')).toBeInTheDocument();
expect(await screen.findByTestId('dm-call-tile-user-1')).toBeInTheDocument();
// Свои гудки: звонок ещё ringing, играет исходящий сигнал.
expect(currentRingtone()).toBe('outgoing');
expect(useDmCallStore.getState().channelId).toBe('c-dm');
});
it('показывает входящий звонок, играет рингтон и принимает его', async () => {
const calls = dmCallRoutes({
call: makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
media: 'video',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
}),
});
const { fetchMock } = await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes });
const card = await screen.findByTestId('dm-incoming-call');
expect(card).toBeInTheDocument();
expect(screen.getByTestId('dm-incoming-call-kind')).toHaveTextContent('Входящий видеозвонок');
// Рингтон входящего играет, пока мы не ответили.
await waitFor(() => {
expect(currentRingtone()).toBe('incoming');
});
fireEvent.click(screen.getByTestId('dm-incoming-call-accept'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/accept', method: 'POST' }),
).toBeDefined();
});
expect(await screen.findByTestId('dm-call-panel')).toBeInTheDocument();
await waitFor(() => {
expect(currentRingtone()).toBeNull();
});
});
it('отклоняет входящий звонок и убирает карточку', async () => {
const calls = dmCallRoutes({
call: makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
participants: [makeCallParticipant('user-1', 'invited')],
}),
});
const { fetchMock } = await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes });
fireEvent.click(await screen.findByTestId('dm-incoming-call-decline'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/decline', method: 'POST' }),
).toBeDefined();
});
// Локальная сессия не поднималась: комнаты LiveKit нет.
expect(FakeRoom.instances).toHaveLength(0);
});
it('показывает «идёт звонок» в списке и шапке по событию и убирает после завершения', async () => {
const calls = dmCallRoutes();
const { gateway } = await bootDm({ routes: calls.routes });
expect(screen.queryByTestId('dm-call-c-dm')).not.toBeInTheDocument();
expect(screen.queryByTestId('dm-call-header-status')).not.toBeInTheDocument();
gateway.dispatch(
'DM_CALL_RING',
makeDmCall({
id: 'call-9',
channel_id: 'c-dm',
initiator_id: 'user-2',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
}),
);
expect(await screen.findByTestId('dm-call-c-dm')).toBeInTheDocument();
expect(screen.getByTestId('dm-call-header-status')).toHaveTextContent('Идёт звонок');
expect(screen.getByTestId('dm-call-c-dm')).toHaveAttribute('data-status', 'ringing');
expect(await screen.findByTestId('dm-incoming-call')).toBeInTheDocument();
gateway.dispatch(
'DM_CALL_END',
makeDmCall({
id: 'call-9',
channel_id: 'c-dm',
initiator_id: 'user-2',
status: 'ended',
end_reason: 'canceled',
participants: [
makeCallParticipant('user-2', 'left'),
makeCallParticipant('user-1', 'missed'),
],
}),
);
await waitFor(() => {
expect(screen.queryByTestId('dm-call-c-dm')).not.toBeInTheDocument();
});
expect(screen.queryByTestId('dm-call-header-status')).not.toBeInTheDocument();
expect(screen.queryByTestId('dm-incoming-call')).not.toBeInTheDocument();
expect(currentRingtone()).toBeNull();
});
it('обновляет флаги звонка на сервере и в комнате', async () => {
const activeCall = makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
});
const calls = dmCallRoutes({ call: activeCall });
const { fetchMock } = await bootDm({
snapshot: makeReadySnapshot([], [{ ...dmChannel, active_call: activeCall }]),
routes: calls.routes,
});
fireEvent.click(await screen.findByTestId('dm-incoming-call-accept'));
await screen.findByTestId('dm-call-panel');
fireEvent.click(screen.getByTestId('dm-call-mic'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/@me', method: 'PATCH' }),
).toBeDefined();
});
const patch = recordedRequests(fetchMock).find(
(request) => request.method === 'PATCH' && request.url.includes('/call/@me'),
);
expect(patch?.body).toEqual({ self_mute: true });
// Микрофон выключен и на стороне SDK.
const micCalls = FakeRoom.latest().localParticipant.calls.filter(
(call) => call.method === 'setMicrophoneEnabled',
);
expect(micCalls.at(-1)?.value).toBe(false);
fireEvent.click(screen.getByTestId('dm-call-camera'));
await waitFor(() => {
const cameraCalls = FakeRoom.latest().localParticipant.calls.filter(
(call) => call.method === 'setCameraEnabled',
);
expect(cameraCalls.at(-1)?.value).toBe(true);
});
fireEvent.click(screen.getByTestId('dm-call-screen'));
await waitFor(() => {
expect(FakeRoom.latest().localParticipant.screenOptions.at(-1)?.enabled).toBe(true);
});
// Выход завершает звонок и гасит сессию.
fireEvent.click(screen.getByTestId('dm-call-leave'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/end', method: 'POST' }),
).toBeDefined();
});
await waitFor(() => {
expect(useDmCallStore.getState().channelId).toBeNull();
});
});
it('показывает историю звонков с пропущенными и длительностью', async () => {
const calls = dmCallRoutes({
history: [
makeDmCall({
id: 'call-2',
channel_id: 'c-dm',
initiator_id: 'user-2',
status: 'ended',
end_reason: 'missed',
started_at: '2026-09-26T09:00:00Z',
ended_at: '2026-09-26T09:00:00Z',
participants: [
makeCallParticipant('user-2', 'left'),
makeCallParticipant('user-1', 'missed'),
],
}),
makeDmCall({
id: 'call-1',
channel_id: 'c-dm',
initiator_id: 'user-1',
status: 'ended',
end_reason: 'completed',
duration_seconds: 125,
started_at: '2026-09-26T08:00:00Z',
ended_at: '2026-09-26T08:02:05Z',
participants: [
makeCallParticipant('user-1', 'left'),
makeCallParticipant('user-2', 'left'),
],
}),
],
});
await bootDm({ routes: calls.routes });
fireEvent.click(screen.getByTestId('dm-call-menu'));
fireEvent.click(await screen.findByTestId('dm-call-history'));
const panel = await screen.findByTestId('dm-call-history');
expect(panel).toBeInTheDocument();
expect(await screen.findByTestId('dm-call-history-call-2')).toHaveAttribute(
'data-missed',
'true',
);
expect(screen.getByTestId('dm-call-history-call-1')).toHaveAttribute('data-missed', 'false');
expect(screen.getByTestId('dm-call-duration-call-1')).toHaveTextContent('2 мин 5 с');
});
it('не предлагает звонок, когда голос на инстансе выключен', async () => {
const calls = dmCallRoutes();
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: false } }) },
...baseRoutes.slice(1),
...calls.routes,
]);
const gateway = installGatewaySocket(ringingCallSnapshot());
renderApp('/app/friends/c-dm');
await gateway.greet();
await screen.findByTestId('dm-header');
expect(screen.queryByTestId('dm-call-menu')).not.toBeInTheDocument();
expect(screen.queryByTestId('dm-incoming-call')).not.toBeInTheDocument();
expect(currentRingtone()).toBeNull();
expect(fetchMock).toBeDefined();
});
it('переживает перезагрузку: маркер звонка приходит в READY', async () => {
const calls = dmCallRoutes();
await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes });
// Список бесед из снапшота уже знает про звонок — запросов за ним не нужно.
expect(await screen.findByTestId('dm-call-c-dm')).toBeInTheDocument();
expect(screen.getByTestId('dm-call-c-dm')).toHaveAttribute('data-status', 'ringing');
expect(useSessionStore.getState().dmChannels[0]?.active_call?.id).toBe('call-7');
});
});