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( , ); await act(async () => { await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); }); return { sound, voice, fetchMock, audio }; } /** Открыть попап саундборда. */ async function openSoundboard(): Promise { 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'); }); });