import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { findRequest, installFetch, installGatewaySocket, json, makeReadySnapshot, makeUser, renderApp, resetStores, type FetchRoute, } from './helpers'; import { makeVoiceState } from './voiceRoutes'; import { setAudioContextFactory, unlockInterfaceSounds, type AudioContextLike, } from '@/lib/interfaceSounds'; import { playEventSound } from '@/stores/soundboard'; import { useSessionStore } from '@/stores/session'; import { useVoiceStore } from '@/stores/voice'; /** * Замечания пользователя: иконка должна меняться сразу, баннер сервера виден в * шапке, есть кнопка приглашения, а звуки событий играют даже без своих файлов * на сервере (AGENT.md 7.4, 7.13, 7.15). */ const user = makeUser(); const channels = [{ id: 'c-general', name: 'общий', type: 'text' as const, position: 0 }]; /** * Права, которые отдаёт REST-сводка сервера: тест выдачи роли меняет их и * проверяет, что клиент перечитал сводку по событию MEMBER_UPDATE. */ let restPermissions: string[] | null = null; function guildSummary(overrides: Record = {}) { return { id: 'g-1', name: 'Сервер', owner_id: 'user-1', is_main: true, member_count: 1, my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'], my_role_ids: [], ...overrides, }; } function guildDetail(overrides: Record = {}) { return { ...guildSummary(overrides), description: '', roles: [], channels, }; } /** Маршруты оболочки, сервера и приглашений. */ function routes(guild: Record = guildDetail()): FetchRoute[] { return [ { match: '/api/v1/guilds/g-1/invites', method: 'POST', response: () => json({ invite: { code: 'abc123', guild_id: 'g-1', max_uses: 0, uses: 0, max_age_seconds: 86400, created_at: '2026-09-20T10:00:00Z', url: 'https://gl.test/invite/abc123', }, }), }, { match: '/api/v1/guilds/g-1/appearance/icon', method: 'POST', response: () => json({ guild: guildDetail({ icon_file_id: '777', banner_file_id: '555' }) }), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [ guildSummary( restPermissions === null ? guild : { ...guild, my_permissions: restPermissions }, ), ], }), }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels }) }, { match: '/api/v1/guilds/g-1', response: () => json({ guild }) }, ]; } /** * Рендерит приложение с READY-снапшотом сервера. Права и владелец берутся из * того же описания сервера, что и ответ REST: снапшот — источник правды. */ async function bootApp(guild: Record = guildDetail()) { const permissions = Array.isArray(guild.my_permissions) ? (guild.my_permissions as string[]) : ['MANAGE_GUILD', 'CREATE_INVITES']; const ownerId = typeof guild.owner_id === 'string' ? guild.owner_id : 'user-1'; const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Сервер', is_main: true, owner_id: ownerId, my_permissions: permissions, channels, voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })], // Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild. ...(typeof guild.banner_file_id === 'string' ? { banner_file_id: guild.banner_file_id } : {}), ...(typeof guild.icon_file_id === 'string' ? { icon_file_id: guild.icon_file_id } : {}), }, ]); const fetchMock = installFetch(routes(guild)); const gateway = installGatewaySocket(snapshot); renderApp('/app/g-1/c-general'); await gateway.greet(); await screen.findByTestId('guild-rail'); return { fetchMock, gateway }; } beforeEach(() => { resetStores(); restPermissions = null; }); describe('оформление сервера в шапке и рейке', () => { it('показывает баннер в шапке и иконку в рейке', async () => { await bootApp(guildDetail({ banner_file_id: '555', icon_file_id: '777' })); const header = await screen.findByTestId('guild-header'); expect(header.getAttribute('style') ?? '').toContain('/files/555'); expect(within(header).getByTestId('guild-banner')).toBeInTheDocument(); const rail = screen.getByTestId('guild-rail'); expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777'); }); it('обновляет иконку сразу после загрузки, без перезагрузки', async () => { const { fetchMock } = await bootApp(guildDetail()); // Загружаем иконку через секцию оформления в настройках сервера. const dialog = await openServerAppearance(); const section = within(dialog).getByTestId('guild-appearance'); await userEvent.upload( within(section).getByTestId('appearance-icon-input'), new File(['картинка'], 'icon.png', { type: 'image/png' }), ); await waitFor(() => { expect(findRequest(fetchMock, { url: '/appearance/icon', method: 'POST' })).toBeDefined(); }); // Рейка берёт иконку из session-store: она обязана обновиться сразу. await waitFor(() => { const rail = screen.getByTestId('guild-rail'); expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777'); }); expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('777'); }); it('применяет оформление из GUILD_UPDATE другого клиента', async () => { const { gateway } = await bootApp(guildDetail()); act(() => { gateway.dispatch('GUILD_UPDATE', { guild_id: 'g-1', name: 'Сервер', icon_file_id: '999', banner_file_id: '888', accent_color: 0x2ecc71, }); }); await waitFor(() => { expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('999'); }); expect(useSessionStore.getState().guilds[0]?.banner_file_id).toBe('888'); const header = screen.getByTestId('guild-header'); expect(header.getAttribute('style') ?? '').toContain('/files/888'); }); }); /** Открывает настройки сервера и ждёт секцию оформления. */ async function openServerAppearance(): Promise { await userEvent.click(await screen.findByLabelText('Настройки сервера')); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await within(dialog).findByTestId('guild-appearance'); return dialog; } describe('приглашение из шапки сервера', () => { it('создаёт ссылку и копирует её', async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, }); const { fetchMock } = await bootApp(); await userEvent.click(await screen.findByTestId('guild-invite')); const panel = await screen.findByTestId('guild-invite-panel'); const link = await within(panel).findByTestId('guild-invite-link'); expect(findRequest(fetchMock, { url: '/invites', method: 'POST' })).toBeDefined(); expect(link).toHaveValue('https://gl.test/invite/abc123'); await userEvent.click(within(panel).getByTestId('guild-invite-copy')); await waitFor(() => { expect(writeText).toHaveBeenCalledWith('https://gl.test/invite/abc123'); }); expect(await within(panel).findByText('Скопировано')).toBeInTheDocument(); }); it('не показывает кнопку без права приглашать', async () => { await bootApp(guildDetail({ my_permissions: [], owner_id: 'user-9' })); await screen.findByTestId('guild-header'); expect(screen.queryByTestId('guild-invite')).toBeNull(); }); it('появляет кнопки после выдачи роли, без перезагрузки', async () => { // Роль выдали уже в открытом клиенте: сводка сервера перечитывается по // MEMBER_UPDATE, иначе кнопки управления ждали бы перезагрузки (AGENT.md 11.6). const { gateway } = await bootApp(guildDetail({ my_permissions: [], owner_id: 'user-9' })); await screen.findByTestId('guild-header'); expect(screen.queryByTestId('guild-invite')).toBeNull(); restPermissions = ['MANAGE_GUILD', 'CREATE_INVITES']; act(() => { gateway.dispatch('MEMBER_UPDATE', { guild_id: 'g-1', user_id: 'user-1' }); }); await waitFor(() => { expect(screen.getByTestId('guild-invite')).toBeInTheDocument(); }); expect(screen.getByLabelText('Настройки сервера')).toBeInTheDocument(); expect(screen.getByLabelText('Создать комнату')).toBeInTheDocument(); }); }); describe('встроенные звуки интерфейса', () => { /** Простой счётчик осцилляторов: проверяем, что сигнал действительно играет. */ function fakeAudio(options: { suspended?: boolean } = {}): { context: AudioContextLike; started: number[]; resumed: () => number; } { const started: number[] = []; let resumeCount = 0; const context: AudioContextLike = { currentTime: 0, ...(options.suspended === true ? { state: 'suspended', resume: () => { resumeCount += 1; return Promise.resolve(); }, } : {}), destination: {} as AudioNode, createOscillator: () => ({ type: 'sine', frequency: { value: 0 }, connect: () => undefined, start: (when: number) => started.push(when), stop: () => undefined, }) as unknown as OscillatorNode, createGain: () => ({ gain: { setValueAtTime: () => undefined, linearRampToValueAtTime: () => undefined, }, connect: () => undefined, }) as unknown as GainNode, }; return { context, started, resumed: () => resumeCount }; } it('играет сигнал события, когда на сервере нет своего звука', () => { const { context, started } = fakeAudio(); setAudioContextFactory(() => context); playEventSound('g-1', 'message'); expect(started).toHaveLength(1); }); it('не играет, когда встроенные звуки выключены', () => { const { context, started } = fakeAudio(); setAudioContextFactory(() => context); useVoiceStore.setState({ interfaceSoundsEnabled: false }); playEventSound('g-1', 'message'); expect(started).toHaveLength(0); }); it('молчит при выключенном звуке', () => { const { context, started } = fakeAudio(); setAudioContextFactory(() => context); useVoiceStore.setState({ headphonesEnabled: false }); playEventSound('g-1', 'message'); expect(started).toHaveLength(0); }); it('уважает звук сервера из палитры', () => { const { context, started } = fakeAudio(); setAudioContextFactory(() => context); useSessionStore.setState({ guilds: [ { ...guildSummary(), channels, emojis: [], sounds: [{ id: 's-1', name: 'ping', file_id: '42', kind: 'ui', event: 'message' }], }, ], }); playEventSound('g-1', 'message'); // Встроенный сигнал не играет: приоритет у звука сервера. expect(started).toHaveLength(0); }); it('будит звук первым жестом: до него браузер молчит', () => { const { context, resumed } = fakeAudio({ suspended: true }); setAudioContextFactory(() => context); unlockInterfaceSounds(); window.dispatchEvent(new Event('pointerdown')); // Контекст создан уже внутри жеста и сразу разбужен. expect(resumed()).toBe(1); }); });