483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
|
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
|||
|
|
import userEvent from '@testing-library/user-event';
|
|||
|
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
import { FakeRoom } from './fakeLiveKit';
|
|||
|
|
import { installFakeAudio } from './fakeAudio';
|
|||
|
|
import {
|
|||
|
|
createTestQueryClient,
|
|||
|
|
installFetch,
|
|||
|
|
json,
|
|||
|
|
makeUser,
|
|||
|
|
resetStores,
|
|||
|
|
type FetchRoute,
|
|||
|
|
} from './helpers';
|
|||
|
|
import { soundRoutes } from './soundRoutes';
|
|||
|
|
import { makeVoiceState, voiceRoutes } from './voiceRoutes';
|
|||
|
|
import type { Channel } from '@/api/types';
|
|||
|
|
import type { GuildSound } from '@/api/sounds';
|
|||
|
|
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
|||
|
|
import { SoundboardNotice } from '@/components/voice/SoundboardNotice';
|
|||
|
|
import { setQueryClient } from '@/lib/queryClient';
|
|||
|
|
import { resetSoundboardEngine } from '@/lib/soundboard';
|
|||
|
|
import { useSessionStore } from '@/stores/session';
|
|||
|
|
import { useSoundboardStore } from '@/stores/soundboard';
|
|||
|
|
import { useVoiceStore } from '@/stores/voice';
|
|||
|
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
|||
|
|
import { dispatchGatewayEvent } from '@/stores/gateway';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Саундборд (AGENT.md 7.13): попап панели комнаты, оптимистичное
|
|||
|
|
* воспроизведение, защита от двойного звука по событию `SOUNDBOARD_PLAY`,
|
|||
|
|
* лимит 429 и подсказка `voice.not_connected`. Плюс звуковая палитра:
|
|||
|
|
* звук события играется при системном сообщении и упоминании.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const voiceChannel: Channel = { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 };
|
|||
|
|
|
|||
|
|
const airhorn: GuildSound = {
|
|||
|
|
id: 's-1',
|
|||
|
|
name: 'airhorn',
|
|||
|
|
file_id: '900',
|
|||
|
|
kind: 'soundboard',
|
|||
|
|
emoji: '📣',
|
|||
|
|
};
|
|||
|
|
const quack: GuildSound = { id: 's-2', name: 'quack', file_id: '901', kind: 'soundboard' };
|
|||
|
|
const joinSound: GuildSound = {
|
|||
|
|
id: 'u-1',
|
|||
|
|
name: 'join',
|
|||
|
|
file_id: '777',
|
|||
|
|
kind: 'ui',
|
|||
|
|
event: 'member_join',
|
|||
|
|
};
|
|||
|
|
const mentionSound: GuildSound = {
|
|||
|
|
id: 'u-2',
|
|||
|
|
name: 'ping',
|
|||
|
|
file_id: '778',
|
|||
|
|
kind: 'ui',
|
|||
|
|
event: 'mention',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const members = [
|
|||
|
|
{
|
|||
|
|
user_id: 'user-1',
|
|||
|
|
username: 'alice',
|
|||
|
|
display_name: 'Alice',
|
|||
|
|
status: 'online',
|
|||
|
|
is_instance_admin: false,
|
|||
|
|
joined_at: '2026-09-19T09:00:00Z',
|
|||
|
|
role_ids: [],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
user_id: 'user-2',
|
|||
|
|
username: 'bob',
|
|||
|
|
display_name: 'Bob',
|
|||
|
|
status: 'online',
|
|||
|
|
is_instance_admin: false,
|
|||
|
|
joined_at: '2026-09-19T09:00:00Z',
|
|||
|
|
role_ids: [],
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/** Снапшот сессии: сервер со звуками и собой в голосовой комнате. */
|
|||
|
|
function seedSession(sounds: GuildSound[], permissions: string[]): void {
|
|||
|
|
useSessionStore.setState({
|
|||
|
|
user: {
|
|||
|
|
id: 'user-1',
|
|||
|
|
username: 'alice',
|
|||
|
|
display_name: 'Alice',
|
|||
|
|
is_instance_admin: false,
|
|||
|
|
badges: [],
|
|||
|
|
},
|
|||
|
|
guilds: [
|
|||
|
|
{
|
|||
|
|
id: 'g-1',
|
|||
|
|
name: 'Сервер',
|
|||
|
|
owner_id: 'user-1',
|
|||
|
|
is_main: true,
|
|||
|
|
member_count: 2,
|
|||
|
|
my_role_ids: [],
|
|||
|
|
my_permissions: permissions,
|
|||
|
|
channels: [voiceChannel],
|
|||
|
|
emojis: [],
|
|||
|
|
sounds,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
voiceStates: { 'g-1': [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })] },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface PanelOptions {
|
|||
|
|
sounds?: GuildSound[];
|
|||
|
|
permissions?: string[];
|
|||
|
|
playError?: { code: string; status: number; retryAfterMs?: number };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Рендер панели комнаты с поднятой голосовой сессией (фейковый SDK). */
|
|||
|
|
async function bootPanel(options: PanelOptions = {}) {
|
|||
|
|
const queryClient = createTestQueryClient();
|
|||
|
|
setQueryClient(queryClient);
|
|||
|
|
resetStores();
|
|||
|
|
resetSoundboardEngine();
|
|||
|
|
|
|||
|
|
const sounds = options.sounds ?? [airhorn, quack];
|
|||
|
|
seedSession(sounds, options.permissions ?? ['VIEW_GUILD', 'USE_SOUNDBOARD']);
|
|||
|
|
|
|||
|
|
const sound = soundRoutes({
|
|||
|
|
sounds,
|
|||
|
|
...(options.playError ? { playError: options.playError } : {}),
|
|||
|
|
});
|
|||
|
|
const voice = voiceRoutes({
|
|||
|
|
channelIds: ['c-voice'],
|
|||
|
|
states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })],
|
|||
|
|
});
|
|||
|
|
const fetchMock = installFetch([
|
|||
|
|
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
|
|||
|
|
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) },
|
|||
|
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
|
|||
|
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
|||
|
|
...sound.routes,
|
|||
|
|
...voice.routes,
|
|||
|
|
] as FetchRoute[]);
|
|||
|
|
|
|||
|
|
const audio = installFakeAudio();
|
|||
|
|
render(
|
|||
|
|
<QueryClientProvider client={queryClient}>
|
|||
|
|
<VoiceRoomPanel guildId="g-1" channel={voiceChannel} />
|
|||
|
|
<SoundboardNotice />
|
|||
|
|
</QueryClientProvider>,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await act(async () => {
|
|||
|
|
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { sound, voice, fetchMock, audio };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Открыть попап саундборда. */
|
|||
|
|
async function openSoundboard(): Promise<HTMLElement> {
|
|||
|
|
await userEvent.click(await screen.findByTestId('voice-soundboard-button'));
|
|||
|
|
return screen.findByTestId('voice-soundboard-panel');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
FakeRoom.reset();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('саундборд: панель комнаты', () => {
|
|||
|
|
it('кнопка появляется только в комнате и с правом USE_SOUNDBOARD', async () => {
|
|||
|
|
// Без права кнопки нет, хотя сессия поднята.
|
|||
|
|
await bootPanel({ permissions: ['VIEW_GUILD'] });
|
|||
|
|
expect(screen.queryByTestId('voice-soundboard-button')).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('проигрывает звук: POST на сервер и локальное воспроизведение', async () => {
|
|||
|
|
const { sound, audio } = await bootPanel();
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(sound.playCount()).toBe(1);
|
|||
|
|
});
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
expect(audio[0]?.src).toBe('/files/900');
|
|||
|
|
// Оптимистичная подпись «кто включил звук» появляется сразу.
|
|||
|
|
expect(await screen.findByTestId('soundboard-notice')).toHaveTextContent(
|
|||
|
|
'Alice включил звук «airhorn»',
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('не проигрывает свой звук дважды по событию шлюза', async () => {
|
|||
|
|
const { audio } = await bootPanel();
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Эхо своего же нажатия: сопоставление по sound_id + user_id + времени.
|
|||
|
|
act(() => {
|
|||
|
|
useSoundboardStore.getState().applyGatewayPlay({
|
|||
|
|
guild_id: 'g-1',
|
|||
|
|
channel_id: 'c-voice',
|
|||
|
|
sound_id: 's-1',
|
|||
|
|
file_id: '900',
|
|||
|
|
name: 'airhorn',
|
|||
|
|
user_id: 'user-1',
|
|||
|
|
played_at: '2026-09-19T10:00:00Z',
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('играет звук другого участника по событию SOUNDBOARD_PLAY', async () => {
|
|||
|
|
const { audio } = await bootPanel();
|
|||
|
|
|
|||
|
|
act(() => {
|
|||
|
|
useSoundboardStore.getState().applyGatewayPlay({
|
|||
|
|
guild_id: 'g-1',
|
|||
|
|
channel_id: 'c-voice',
|
|||
|
|
sound_id: 's-2',
|
|||
|
|
file_id: '901',
|
|||
|
|
name: 'quack',
|
|||
|
|
user_id: 'user-2',
|
|||
|
|
played_at: '2026-09-19T10:00:00Z',
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
expect(audio[0]?.src).toBe('/files/901');
|
|||
|
|
expect(await screen.findByTestId('soundboard-notice')).toHaveTextContent(
|
|||
|
|
'Bob включил звук «quack»',
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('на 429 показывает обратный отсчёт', async () => {
|
|||
|
|
await bootPanel({
|
|||
|
|
playError: { code: 'rate_limited', status: 429, retryAfterMs: 5_000 },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
|
|||
|
|
|
|||
|
|
expect(await screen.findByTestId('voice-soundboard-limit')).toHaveTextContent(
|
|||
|
|
/Слишком часто: повторите через \d с\./u,
|
|||
|
|
);
|
|||
|
|
// Пока идёт лимит, кнопки проигрывания выключены.
|
|||
|
|
expect(within(panel).getByTestId('voice-soundboard-sound-s-1')).toBeDisabled();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('на voice.not_connected подсказывает войти в комнату', async () => {
|
|||
|
|
await bootPanel({
|
|||
|
|
playError: { code: 'voice.not_connected', status: 403 },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
|
|||
|
|
|
|||
|
|
expect(await screen.findByTestId('voice-soundboard-error')).toHaveTextContent(
|
|||
|
|
'Сначала войдите в голосовую комнату.',
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('фильтрует звуки по имени и показывает пустой набор', async () => {
|
|||
|
|
await bootPanel();
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.type(within(panel).getByTestId('voice-soundboard-search'), 'quack');
|
|||
|
|
|
|||
|
|
expect(within(panel).queryByTestId('voice-soundboard-sound-s-1')).toBeNull();
|
|||
|
|
expect(within(panel).getByTestId('voice-soundboard-sound-s-2')).toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('без звуков показывает понятный текст', async () => {
|
|||
|
|
await bootPanel({ sounds: [] });
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
expect(panel).toHaveTextContent('На сервере пока нет звуков саундборда.');
|
|||
|
|
expect(screen.queryByTestId('voice-soundboard-sound-s-1')).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('не играет звуки, когда саундборд выключен в настройках', async () => {
|
|||
|
|
const { sound, audio } = await bootPanel();
|
|||
|
|
useVoiceStore.getState().setSoundboardEnabled(false);
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
|
|||
|
|
|
|||
|
|
// Запрос на сервер уходит, но локально звук не играет: выключено в настройках.
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(sound.playCount()).toBe(1);
|
|||
|
|
});
|
|||
|
|
expect(audio).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('не играет звуки при выключенном звуке приложения (дефен)', async () => {
|
|||
|
|
const { sound, audio } = await bootPanel();
|
|||
|
|
// Кнопка «Звук выключен» глушит и саундборд.
|
|||
|
|
useVoiceStore.getState().setHeadphonesEnabled(false);
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(sound.playCount()).toBe(1);
|
|||
|
|
});
|
|||
|
|
expect(audio).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('обновляет набор звуков по событию GUILD_SOUNDS_UPDATE', async () => {
|
|||
|
|
await bootPanel({ sounds: [airhorn] });
|
|||
|
|
|
|||
|
|
expect(await screen.findByTestId('voice-soundboard-button')).toBeVisible();
|
|||
|
|
act(() => {
|
|||
|
|
dispatchGatewayEvent({
|
|||
|
|
op: 0,
|
|||
|
|
t: 'GUILD_SOUNDS_UPDATE',
|
|||
|
|
s: 1,
|
|||
|
|
d: {
|
|||
|
|
guild_id: 'g-1',
|
|||
|
|
sounds: [
|
|||
|
|
{ id: 's-1', name: 'airhorn', file_id: '900', kind: 'soundboard' },
|
|||
|
|
{ id: 's-3', name: 'bell', file_id: '902', kind: 'soundboard' },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const panel = await openSoundboard();
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(within(panel).getByTestId('voice-soundboard-sound-s-3')).toBeVisible();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('играет звук mic_off при выключении своего микрофона', async () => {
|
|||
|
|
const micOff: GuildSound = {
|
|||
|
|
id: 'u-3',
|
|||
|
|
name: 'click',
|
|||
|
|
file_id: '779',
|
|||
|
|
kind: 'ui',
|
|||
|
|
event: 'mic_off',
|
|||
|
|
};
|
|||
|
|
const { audio } = await bootPanel({ sounds: [airhorn, micOff] });
|
|||
|
|
|
|||
|
|
// Кнопка микрофона в панели своей плитки.
|
|||
|
|
const controls = screen.getByLabelText('Управление голосом');
|
|||
|
|
await userEvent.click(within(controls).getByLabelText('Выключить микрофон'));
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
expect(audio[0]?.src).toBe('/files/779');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('звуковая палитра: события', () => {
|
|||
|
|
interface MessageInput {
|
|||
|
|
id: string;
|
|||
|
|
content: string;
|
|||
|
|
type?: string;
|
|||
|
|
author_id?: string;
|
|||
|
|
mentions?: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Событие MESSAGE_CREATE так, как его присылает шлюз. */
|
|||
|
|
function messageCreate(input: MessageInput): void {
|
|||
|
|
dispatchGatewayEvent({
|
|||
|
|
op: 0,
|
|||
|
|
t: 'MESSAGE_CREATE',
|
|||
|
|
s: 1,
|
|||
|
|
d: {
|
|||
|
|
id: input.id,
|
|||
|
|
channel_id: 'c-voice',
|
|||
|
|
content: input.content,
|
|||
|
|
type: input.type ?? 'default',
|
|||
|
|
pinned: false,
|
|||
|
|
attachments: [],
|
|||
|
|
mentions: input.mentions ?? [],
|
|||
|
|
reactions: [],
|
|||
|
|
created_at: '2026-09-19T10:00:00Z',
|
|||
|
|
...(input.author_id === undefined ? {} : { author_id: input.author_id }),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
it('играет звук member_join при системном сообщении', async () => {
|
|||
|
|
resetStores();
|
|||
|
|
resetSoundboardEngine();
|
|||
|
|
seedSession([joinSound], ['VIEW_GUILD']);
|
|||
|
|
const audio = installFakeAudio();
|
|||
|
|
|
|||
|
|
messageCreate({
|
|||
|
|
id: 'm-1',
|
|||
|
|
content: JSON.stringify({ event: 'member_join', user_id: 'user-2' }),
|
|||
|
|
type: 'system',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
expect(audio[0]?.src).toBe('/files/777');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('играет звук mention при упоминании и молчит на своё сообщение', async () => {
|
|||
|
|
resetStores();
|
|||
|
|
resetSoundboardEngine();
|
|||
|
|
seedSession([mentionSound], ['VIEW_GUILD']);
|
|||
|
|
const audio = installFakeAudio();
|
|||
|
|
|
|||
|
|
messageCreate({
|
|||
|
|
id: 'm-2',
|
|||
|
|
content: 'привет <@user-1>',
|
|||
|
|
author_id: 'user-2',
|
|||
|
|
mentions: ['user-1'],
|
|||
|
|
});
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Своё сообщение звуком не сопровождается.
|
|||
|
|
messageCreate({ id: 'm-3', content: 'моё', author_id: 'user-1' });
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('не играет звуки событий, когда они выключены в настройках', () => {
|
|||
|
|
resetStores();
|
|||
|
|
resetSoundboardEngine();
|
|||
|
|
seedSession([joinSound], ['VIEW_GUILD']);
|
|||
|
|
const audio = installFakeAudio();
|
|||
|
|
useVoiceStore.getState().setEventSoundsEnabled(false);
|
|||
|
|
|
|||
|
|
messageCreate({
|
|||
|
|
id: 'm-4',
|
|||
|
|
content: JSON.stringify({ event: 'member_join', user_id: 'user-2' }),
|
|||
|
|
type: 'system',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(audio).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('играет звук voice_join, когда участник входит в мою комнату', async () => {
|
|||
|
|
resetStores();
|
|||
|
|
resetSoundboardEngine();
|
|||
|
|
seedSession(
|
|||
|
|
[{ id: 'u-4', name: 'whoosh', file_id: '780', kind: 'ui', event: 'voice_join' }],
|
|||
|
|
['VIEW_GUILD'],
|
|||
|
|
);
|
|||
|
|
useVoiceConnectionStore.setState({ guildId: 'g-1', channelId: 'c-voice', status: 'connected' });
|
|||
|
|
const audio = installFakeAudio();
|
|||
|
|
|
|||
|
|
act(() => {
|
|||
|
|
dispatchGatewayEvent({
|
|||
|
|
op: 0,
|
|||
|
|
t: 'VOICE_STATE_UPDATE',
|
|||
|
|
s: 1,
|
|||
|
|
d: makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(audio).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
expect(audio[0]?.src).toBe('/files/780');
|
|||
|
|
});
|
|||
|
|
});
|