import { afterEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers'; import { useVoiceStore, VOICE_STORAGE_KEY } from '@/stores/voice'; /** Устройства, которые «видит» браузер в тестах. */ const devices = [ { kind: 'audioinput', deviceId: 'mic-1', label: 'Микрофон USB' }, { kind: 'audioinput', deviceId: 'mic-2', label: '' }, { kind: 'videoinput', deviceId: 'cam-1', label: 'Веб-камера HD' }, { kind: 'audiooutput', deviceId: 'out-1', label: 'Наушники' }, ]; /** Подменяет `navigator.mediaDevices` (в jsdom его нет). */ function installMediaDevices(list = devices) { const mediaDevices = { enumerateDevices: vi.fn(() => Promise.resolve(list)), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: mediaDevices }); return mediaDevices; } afterEach(() => { Reflect.deleteProperty(navigator, 'mediaDevices'); }); function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] { // Дополнительные маршруты идут первыми: при одинаковом шаблоне побеждает // первый зарегистрированный, поэтому тест может переопределить профиль. return [ ...extra, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, ]; } /** Панель пользователя после загрузки оболочки. */ async function bootPanel(extra: FetchRoute[] = []) { installFetch(baseRoutes(extra)); renderApp('/app/empty'); return screen.findByLabelText('Панель пользователя'); } function storedPrefs(): Record { const raw = window.localStorage.getItem(VOICE_STORAGE_KEY); return raw === null ? {} : (JSON.parse(raw) as Record); } describe('панель пользователя: устройства', () => { it('кнопки микрофона, камеры и наушников переключают состояние и сохраняют его', async () => { const panel = await bootPanel(); const mic = within(panel).getByLabelText('Выключить микрофон'); expect(mic).toHaveAttribute('aria-pressed', 'true'); await userEvent.click(mic); // Иконка микрофона перечёркнута, а состояние ушло в localStorage. const muted = within(panel).getByLabelText('Включить микрофон'); expect(muted).toHaveAttribute('aria-pressed', 'false'); expect(storedPrefs()).toMatchObject({ micEnabled: false }); const camera = within(panel).getByLabelText('Включить камеру'); expect(camera).toHaveAttribute('aria-pressed', 'false'); await userEvent.click(camera); expect(within(panel).getByLabelText('Выключить камеру')).toHaveAttribute( 'aria-pressed', 'true', ); const headphones = within(panel).getByLabelText('Выключить звук'); await userEvent.click(headphones); expect(within(panel).getByLabelText('Включить звук')).toHaveAttribute('aria-pressed', 'false'); expect(storedPrefs()).toMatchObject({ micEnabled: false, cameraEnabled: true, headphonesEnabled: false, }); }); it('стрелка микрофона открывает список устройств ввода и слайдер громкости', async () => { installMediaDevices(); const panel = await bootPanel(); await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' }); expect(await within(popover).findByRole('option', { name: 'Микрофон USB' })).toBeVisible(); // Устройство без подписи показываем как «Устройство 2». expect(within(popover).getByRole('option', { name: 'Устройство 2' })).toBeVisible(); expect( within(popover).getByRole('option', { name: 'Устройство по умолчанию' }), ).toHaveAttribute('aria-selected', 'true'); await userEvent.click(within(popover).getByRole('option', { name: 'Микрофон USB' })); expect(storedPrefs()).toMatchObject({ micDeviceId: 'mic-1' }); const volume = within(popover).getByLabelText('Громкость микрофона'); fireEvent.change(volume, { target: { value: '42' } }); expect(storedPrefs()).toMatchObject({ micVolume: 42 }); }); it('стрелка камеры показывает устройства videoinput', async () => { installMediaDevices(); const panel = await bootPanel(); await userEvent.click(within(panel).getByLabelText('Настройки камеры')); const popover = await screen.findByRole('dialog', { name: 'Настройки камеры' }); expect(await within(popover).findByRole('option', { name: 'Веб-камера HD' })).toBeVisible(); expect(within(popover).queryByRole('option', { name: 'Микрофон USB' })).toBeNull(); }); it('стрелка наушников показывает устройства вывода и громкость звука', async () => { installMediaDevices(); const panel = await bootPanel(); await userEvent.click(within(panel).getByLabelText('Настройки звука')); const popover = await screen.findByRole('dialog', { name: 'Настройки звука' }); await userEvent.click(await within(popover).findByRole('option', { name: 'Наушники' })); expect(storedPrefs()).toMatchObject({ outputDeviceId: 'out-1' }); fireEvent.change(within(popover).getByLabelText('Громкость звука'), { target: { value: '10' }, }); expect(storedPrefs()).toMatchObject({ outputVolume: 10 }); }); it('без navigator.mediaDevices показывает «Устройства недоступны»', async () => { const panel = await bootPanel(); await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' }); expect(await within(popover).findByText('Устройства недоступны')).toBeVisible(); }); it('ссылка «Аудио-видео настройки» открывает окно настроек на этом пункте', async () => { installMediaDevices(); const panel = await bootPanel(); await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' }); await userEvent.click( await within(popover).findByRole('button', { name: 'Аудио-видео настройки' }), ); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect(within(dialog).getByRole('button', { name: 'Аудио-видео' })).toHaveAttribute( 'aria-current', 'true', ); // Поповер устройства закрылся. expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull(); }); it('поповеры закрываются по Escape и не открываются одновременно', async () => { installMediaDevices(); const panel = await bootPanel(); await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); await screen.findByRole('dialog', { name: 'Настройки микрофона' }); // Открытие второй панели закрывает первую. await userEvent.click(within(panel).getByLabelText('Настройки звука')); await screen.findByRole('dialog', { name: 'Настройки звука' }); expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull(); await userEvent.keyboard('{Escape}'); await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'Настройки звука' })).toBeNull(); }); }); }); describe('панель пользователя: профиль', () => { it('показывает имя, статус и цветную точку статуса', async () => { installFetch( baseRoutes([ { match: '/api/v1/users/@me', response: () => json({ user: makeUser({ status: 'idle' }) }), }, ]), ); renderApp('/app/empty'); const panel = await screen.findByLabelText('Панель пользователя'); expect(within(panel).getByText('Alice')).toBeVisible(); await waitFor(() => { expect(within(panel).getByText('Неактивен')).toBeVisible(); }); expect(within(panel).getByTestId('user-status-dot')).toHaveClass('bg-warning'); }); it('сохраняет предпочтения устройств в localStorage', async () => { await bootPanel(); useVoiceStore.getState().toggleMic(); useVoiceStore.getState().setOutputVolume(30); expect(storedPrefs()).toMatchObject({ micEnabled: false, outputVolume: 30, headphonesEnabled: true, }); }); it('читает сохранённые предпочтения при загрузке модуля', async () => { window.localStorage.setItem( VOICE_STORAGE_KEY, JSON.stringify({ micEnabled: false, micVolume: 20 }), ); vi.resetModules(); const fresh = await import('@/stores/voice'); expect(fresh.useVoiceStore.getState().micEnabled).toBe(false); expect(fresh.useVoiceStore.getState().micVolume).toBe(20); // Неизвестные поля берут значения по умолчанию. expect(fresh.useVoiceStore.getState().headphonesEnabled).toBe(true); }); }); describe('панель: кнопки устройств', () => { it('стрелки — прямоугольники во всю высоту кнопки и открывают меню', async () => { const panelRoot = await bootPanel(); const visitor = userEvent.setup(); // Стрелка микрофона доступна по подписи и лежит в одной группе с кнопкой. const arrow = within(panelRoot).getByRole('button', { name: 'Настройки микрофона' }); const group = arrow.parentElement; expect(group).not.toBeNull(); expect(group?.className).toContain('h-8'); expect(group?.className).toContain('w-9'); // Кнопка-переключатель — сосед стрелки в той же группе. expect(group?.querySelectorAll('button')).toHaveLength(2); await visitor.click(arrow); const panel = await screen.findByTestId('popover-panel'); // Панель прижата к рабочей области: фиксированное позиционирование и // ограничение высоты, чтобы содержимое не уезжало за экран. expect(panel.style.position).toBe('fixed'); expect(panel.style.maxHeight).not.toBe(''); expect(panel.getAttribute('role')).toBe('dialog'); }); it('попап настроек звука не выходит за пределы окна', async () => { const panelRoot = await bootPanel(); const visitor = userEvent.setup(); await visitor.click(within(panelRoot).getByRole('button', { name: 'Настройки звука' })); const panel = await screen.findByTestId('popover-panel'); const left = Number.parseInt(panel.style.left, 10); const top = Number.parseInt(panel.style.top, 10); expect(left).toBeGreaterThanOrEqual(0); expect(top).toBeGreaterThanOrEqual(0); expect(left).toBeLessThan(window.innerWidth); expect(top).toBeLessThan(window.innerHeight); }); });