555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
|
|
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,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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();
|
|||
|
|
});
|
|||
|
|
});
|