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: '', }; /** Сервер снапшота с кастомными эмодзи. */ 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, }), resolveWebhook: () => ({ id: '', name: 'вебхук', avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, isMember: false, }), 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> и ', () => { const tokens = parseInline('привет <:pepe:900> и '); expect(tokens.map((token) => token.kind)).toEqual(['custom-emoji', 'custom-emoji']); expect(tokens.map((token) => token.raw)).toEqual(['<:pepe:900>', '']); expect(parseCustomEmoji('<:pepe:900>')).toEqual({ name: 'pepe', fileId: '900', animated: false, }); expect(parseCustomEmoji('')).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>', '']); }); }); describe('кастомные эмодзи: лента сообщений', () => { it('рисует картинку, если эмодзи есть в снапшоте сервера', () => { useSessionStore.setState({ guilds: [guildWithEmojis()] }); const { container } = render(!'} />); 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( с '} />, ); expect(container.querySelector('img')).toBeNull(); expect(container.querySelector('strong')).toHaveTextContent('жирный'); expect(container.textContent).toBe('жирный и :pepe: с :party_parrot:'); }); it('без снапшота сервера (личная беседа) токен остаётся текстом', () => { const { container } = render(); expect(container.querySelector('img')).toBeNull(); expect(container.textContent).toBe(':pepe:'); }); }); describe('кастомные эмодзи: зеркало композера', () => { it('вне каретки рисует картинку, внутри токена — исходный текст', () => { useSessionStore.setState({ guilds: [guildWithEmojis()] }); const mirrorRef = createRef(); const value = 'эмодзи <:pepe:900>'; const { rerender } = render( , ); const image = mirrorRef.current?.querySelector('img'); expect(image).toHaveAttribute('src', '/files/900'); expect(image).toHaveClass('h-5', 'w-5', 'align-[-0.15em]'); // Каретка внутри токена — показываем маркеры (весь токен) для правки. rerender( , ); expect(mirrorRef.current?.querySelector('img')).toBeNull(); expect(mirrorRef.current?.textContent).toBe(value); }); it('неизвестный эмодзи показывается текстом :имя:', () => { useSessionStore.setState({ guilds: [guildWithEmojis([])] }); const mirrorRef = createRef(); render( , ); 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( 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(['']); }); it('без сервера секция эмодзи сервера не показывается', () => { useSessionStore.setState({ guilds: [guildWithEmojis()] }); render( 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( undefined} disabledReason={null} canAttach /> , ); 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( undefined} disabledReason={null} canAttach /> , ); 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(''), ); }); }); 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( 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(); }); });