Files
glchat/web/tests/guildEmojis.test.tsx
T
grendervill 619bd91df4 feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.

- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
  play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
  наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
  попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
  подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
  участника, упоминание, сообщение, вход/выход из голосовой, свои
  микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
  счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
  удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
  комнату с выключенным микрофоном, удержание включает публикацию, отпускание
  и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
  и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
  публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
  `useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
  расширенный фейковый LiveKit SDK.
2026-09-20 19:30:43 +03:00

555 lines
21 KiB
TypeScript
Raw 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 { createRef } from 'react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import type { Message } from '@/api/messages';
import { parseGuildEmojisUpdate } from '@/api/gatewayEvents';
import { parseGuildEmojiList } from '@/api/emojis';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { ComposerMirror } from '@/components/chat/ComposerMirror';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
import { parseInline } from '@/lib/markdown';
import { dispatchGatewayEvent } from '@/stores/gateway';
import { useSessionStore, type SessionGuild } from '@/stores/session';
import {
createTestQueryClient,
findRequest,
installFetch,
installGatewaySocket,
json,
makeMessage,
makeReadySnapshot,
makeUser,
messagesRoutes,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Кастомные эмодзи сервера (AGENT.md 7.12): токен `<:имя:file_id>` в разметке,
* лента и зеркало рисуют картинку, пикер и автокомплит `:` вставляют токен,
* реакции ходят тем же токеном, а набор эмодзи живёт в снапшоте и обновляется
* событием `GUILD_EMOJIS_UPDATE`.
*/
const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' };
const parrot = {
id: 'e-2',
name: 'party_parrot',
file_id: '901',
animated: true,
token: '<a:party_parrot:901>',
};
/** Сервер снапшота с кастомными эмодзи. */
function guildWithEmojis(emojis: SessionGuild['emojis'] = [pepe, parrot]): SessionGuild {
return {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis,
sounds: [],
};
}
const authors: AuthorDirectory = {
resolve: () => ({
id: 'user-2',
name: 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: true,
}),
resolveMention: (userId) => ({ name: userId, isMe: false }),
resolveMemberName: (userId) => `user ${userId}`,
loaded: true,
};
const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
beforeEach(() => {
resetStores();
});
afterEach(() => {
resetStores();
});
describe('кастомные эмодзи: разбор токена', () => {
it('распознаёт <:имя:id> и <a:имя:id>', () => {
const tokens = parseInline('привет <:pepe:900> и <a:party_parrot:901>');
expect(tokens.map((token) => token.kind)).toEqual(['custom-emoji', 'custom-emoji']);
expect(tokens.map((token) => token.raw)).toEqual(['<:pepe:900>', '<a:party_parrot:901>']);
expect(parseCustomEmoji('<:pepe:900>')).toEqual({
name: 'pepe',
fileId: '900',
animated: false,
});
expect(parseCustomEmoji('<a:party_parrot:901>')).toEqual({
name: 'party_parrot',
fileId: '901',
animated: true,
});
expect(customEmojiShortcode('pepe')).toBe(':pepe:');
});
it('битый токен остаётся текстом и не ломает разметку', () => {
const content = '**жирный** <:pepe:> <:p:1> <:pepe:abc> <:имя:1>';
expect(parseInline(content)).toHaveLength(1);
expect(parseInline(content)[0]?.kind).toBe('bold');
expect(parseCustomEmoji('<:pepe:>')).toBeNull();
expect(parseCustomEmoji('<:pepe:abc>')).toBeNull();
expect(parseCustomEmoji('<:p:1>')).toBeNull();
});
it('не конфликтует с упоминаниями и ссылками на комнаты', () => {
const kinds = parseInline('<@42> <#7> <:pepe:900>').map((token) => token.kind);
expect(kinds).toEqual(['mention', 'channel-link', 'custom-emoji']);
});
it('разбирает список эмодзи из ответа сервера', () => {
const parsed = parseGuildEmojiList([
{ id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' },
// Токен не пришёл — собираем сами по тому же формату.
{ id: 'e-2', name: 'kek', file_id: '902', animated: true },
// Битая запись отбрасывается.
{ id: 'e-3', name: 'no_file' },
]);
expect(parsed.map((emoji) => emoji.token)).toEqual(['<:pepe:900>', '<a:kek:902>']);
});
});
describe('кастомные эмодзи: лента сообщений', () => {
it('рисует картинку, если эмодзи есть в снапшоте сервера', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const { container } = render(<MessageContent content={'привет <:pepe:900>!'} />);
const image = container.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveAttribute('alt', ':pepe:');
expect(image).toHaveAttribute('title', ':pepe:');
expect(image).toHaveAttribute('loading', 'lazy');
expect(image).toHaveClass('h-5', 'w-5');
expect(container.textContent).toBe('привет !');
});
it('неизвестный файл показывается текстом :имя:, разметка остаётся целой', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const { container } = render(
<MessageContent content={'**жирный** и <:pepe:900> с <a:party_parrot:901>'} />,
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('strong')).toHaveTextContent('жирный');
expect(container.textContent).toBe('жирный и :pepe: с :party_parrot:');
});
it('без снапшота сервера (личная беседа) токен остаётся текстом', () => {
const { container } = render(<MessageContent content="<:pepe:900>" />);
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: зеркало композера', () => {
it('вне каретки рисует картинку, внутри токена — исходный текст', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const mirrorRef = createRef<HTMLDivElement>();
const value = 'эмодзи <:pepe:900>';
const { rerender } = render(
<ComposerMirror value={value} selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
);
const image = mirrorRef.current?.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveClass('h-5', 'w-5', 'align-[-0.15em]');
// Каретка внутри токена — показываем маркеры (весь токен) для правки.
rerender(
<ComposerMirror value={value} selection={{ start: 10, end: 10 }} mirrorRef={mirrorRef} />,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(value);
});
it('неизвестный эмодзи показывается текстом :имя:', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const mirrorRef = createRef<HTMLDivElement>();
render(
<ComposerMirror value="<:pepe:900>" selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: пикер', () => {
it('показывает эмодзи сервера, ищет по имени и отдаёт токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const picked: string[] = [];
render(
<EmojiPicker
open
guildId="g-1"
onClose={() => undefined}
onPick={(emoji) => picked.push(emoji)}
/>,
);
const section = screen.getByTestId('emoji-picker-server');
expect(within(section).getByText(':pepe:')).toBeVisible();
expect(within(section).getByText(':party_parrot:')).toBeVisible();
expect(section.querySelector('img[src="/files/900"]')).not.toBeNull();
expect(within(section).getByText('анимированный')).toBeVisible();
await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party');
expect(within(section).queryByText(':pepe:')).toBeNull();
await visitor.click(
within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }),
);
expect(picked).toEqual(['<a:party_parrot:901>']);
});
it('без сервера секция эмодзи сервера не показывается', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(<EmojiPicker open guildId={null} onClose={() => undefined} onPick={() => undefined} />);
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
});
});
describe('кастомные эмодзи: пикер в композере', () => {
it('вставляет токен по клику в позицию каретки', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId="g-1"
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.click(screen.getByRole('button', { name: 'Эмодзи' }));
await visitor.click(screen.getByRole('button', { name: 'Выбрать эмодзи pepe' }));
expect(input).toHaveValue('<:pepe:900>');
// Зеркало сразу показывает картинку — предпросмотр совпадает с лентой.
await waitFor(() => {
expect(screen.getByTestId('composer-mirror').querySelector('img')).toHaveAttribute(
'src',
'/files/900',
);
});
});
});
describe('кастомные эмодзи: автокомплит (:)', () => {
function renderComposer(guildId: string | null = 'g-1') {
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
const view = render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId={guildId}
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
return { ...view, fetchMock };
}
it('дополняет список эмодзи сервера и вставляет токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
const list = await screen.findByTestId('composer-autocomplete');
expect(within(list).getByText(':pepe:')).toBeVisible();
expect(list.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:pepe:900>');
});
it('точное совпадение по имени выше юникод-словаря', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({
guilds: [
guildWithEmojis([
{ id: 'e-3', name: 'fire', file_id: '950', animated: false, token: '<:fire:950>' },
]),
],
});
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':fire');
const options = await screen.findAllByTestId('composer-option-emoji');
expect(options[0]).toHaveTextContent(':fire:');
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:fire:950>');
});
it('в личной беседе кастомных эмодзи в подсказке нет', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer(null);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
// Юникод-словарь по `pep` пуст, кастомных эмодзи без сервера нет.
await waitFor(() => {
expect(screen.queryByTestId('composer-autocomplete')).toBeNull();
});
});
});
describe('кастомные эмодзи: реакции', () => {
const user = makeUser();
const members = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online' as const,
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: [],
},
];
/** Приложение на маршруте комнаты сервера с кастомными эмодзи в READY. */
async function boot(messages: Message[], extraRoutes: FetchRoute[] = []) {
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: ['SEND_MESSAGES', 'ADD_REACTIONS'],
channels: [{ id: 'c-1', name: 'общий' }],
emojis: [pepe, parrot],
},
]);
const fetchMock = installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ 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/meta', response: () => json({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', messages, extraRoutes),
]);
const gateway = installGatewaySocket(snapshot);
const view = renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
return { ...view, fetchMock, gateway };
}
it('рисует чип реакции картинкой и ходит тем же токеном', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[
makeMessage({
id: 'm-1',
channel_id: 'c-1',
author_id: 'user-2',
content: 'реагируй',
reactions: [{ emoji: '<:pepe:900>', count: 1, me: false, user_ids: ['user-2'] }],
}),
],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
const chip = await screen.findByRole('button', { name: 'Реакция :pepe:: 1' });
expect(chip.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.click(chip);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :pepe:: 2' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
// Токен уезжает в пути закодированным (%3C%3Apepe%3A900%3E).
const request = findRequest(fetchMock, { url: '/reactions/', method: 'PUT' });
expect(request?.url).toContain(encodeURIComponent('<:pepe:900>'));
});
it('ставит реакцию кастомным эмодзи из пикера', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-2', content: 'меню' })],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
await screen.findByText('меню');
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
await visitor.click(
within(screen.getByTestId('emoji-picker-server')).getByRole('button', {
name: 'Выбрать эмодзи party_parrot',
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :party_parrot:: 1' })).toBeVisible();
});
expect(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })?.url).toContain(
encodeURIComponent('<a:party_parrot:901>'),
);
});
});
describe('кастомные эмодзи: снапшот и событие шлюза', () => {
it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => {
const snapshot = makeReadySnapshot([{ id: 'g-1', name: 'Сервер', emojis: [pepe] }]);
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: snapshot });
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe]);
dispatchGatewayEvent({
op: 0,
t: 'GUILD_EMOJIS_UPDATE',
s: 2,
d: { guild_id: 'g-1', emojis: [pepe, parrot] },
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe, parrot]);
});
it('разбирает payload события и отбрасывает битые записи', () => {
const parsed = parseGuildEmojisUpdate({
guild_id: 'g-1',
emojis: [{ id: 'e-1', name: 'pepe', file_id: '900', animated: false }],
});
expect(parsed?.guild_id).toBe('g-1');
expect(parsed?.emojis[0]?.token).toBe('<:pepe:900>');
expect(parseGuildEmojisUpdate({ emojis: [] })).toBeNull();
});
it('без события эмодзи сервера остаются пустыми', () => {
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 1,
d: makeReadySnapshot([{ id: 'g-1', name: 'Сервер' }]),
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([]);
});
});
describe('кастомные эмодзи: беседа без сервера', () => {
it('в личной беседе пикер реакций не показывает эмодзи сервера', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(
<MessageList
channelId="dm-1"
channelName="Боб"
direct
messages={[
makeMessage({ id: 'm-1', channel_id: 'dm-1', author_id: 'user-2', content: 'привет' }),
]}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages={false}
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
expect(screen.getByText('привет')).toBeVisible();
// Сервер комнаты неизвестен, значит кастомных эмодзи в пикере нет.
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
expect(screen.getByRole('dialog', { name: 'Выбор эмодзи' })).toBeVisible();
});
});