import { describe, expect, it } from 'vitest'; import type { Message } from '@/api/messages'; import type { GuildMember, Role } from '@/api/types'; import { GROUP_WINDOW_MS, buildChatRows, canGroup, compareMessageIds, copyText, dayKey, dayLabel, escapeSelector, formatMessageTime, memberDisplayName, roleColorForMember, } from '@/lib/chat'; const NOW = new Date(2026, 8, 19, 15, 0, 0); function at(day: number, hour: number, minute: number): string { return new Date(2026, 8, day, hour, minute, 0).toISOString(); } function message(overrides: Partial & { id: string }): Message { return { channel_id: 'c-1', content: 'текст', type: 'default', pinned: false, attachments: [], mentions: [], reactions: [], created_at: at(19, 12, 0), ...overrides, }; } describe('группировка сообщений', () => { it('объединяет сообщения одного автора в пределах 5 минут', () => { const first = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0) }); const second = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 4) }); expect(canGroup(first, second)).toBe(true); expect( canGroup(first, message({ id: 'm-3', author_id: 'user-1', created_at: at(19, 12, 6) })), ).toBe(false); }); it('разрывает группу при смене автора, ответе и системном сообщении', () => { const first = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0) }); expect(canGroup(first, message({ id: 'm-2', author_id: 'user-2' }))).toBe(false); expect(canGroup(first, message({ id: 'm-2', author_id: 'user-1', reply_to_id: 'm-9' }))).toBe( false, ); expect(canGroup(first, message({ id: 'm-2', author_id: 'user-1', type: 'system' }))).toBe( false, ); }); it('строит строки ленты с разделителями дат', () => { const rows = buildChatRows( [ message({ id: 'm-1', author_id: 'user-1', created_at: at(18, 22, 0) }), message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 10, 0) }), message({ id: 'm-3', author_id: 'user-1', created_at: at(19, 10, 2) }), ], { unreadAnchorId: null }, ); expect(rows.map((row) => row.kind)).toEqual(['date', 'group', 'date', 'group']); const second = rows[3]; expect(second?.kind === 'group' ? second.group.messages.map((item) => item.id) : []).toEqual([ 'm-2', 'm-3', ]); }); it('ставит разделитель «новые сообщения» перед первым непрочитанным', () => { const rows = buildChatRows( [ message({ id: 'm-1', author_id: 'user-1' }), message({ id: 'm-2', author_id: 'user-1' }), message({ id: 'm-3', author_id: 'user-2' }), ], { unreadAnchorId: 'm-1' }, ); // Разделитель разрывает группу: m-2 попадает в новую группу после линии. expect(rows.map((row) => row.kind)).toEqual(['date', 'group', 'unread', 'group', 'group']); const unread = rows[3]; expect(unread?.kind === 'group' ? unread.group.messages[0]?.id : null).toBe('m-2'); }); it('считает «0» отметкой «комната не читалась»', () => { const rows = buildChatRows([message({ id: 'm-1', author_id: 'user-1' })], { unreadAnchorId: '0', }); expect(rows.map((row) => row.kind)).toEqual(['date', 'unread', 'group']); }); it('не ставит разделитель, если отметка прочтения неизвестна', () => { const rows = buildChatRows([message({ id: 'm-1', author_id: 'user-1' })], { unreadAnchorId: 'm-404', }); expect(rows.map((row) => row.kind)).toEqual(['date', 'group']); }); it('ставит разделитель перед окном, если отметка прочтения старше загруженного', () => { const rows = buildChatRows( [message({ id: '200', author_id: 'user-1' }), message({ id: '201', author_id: 'user-1' })], { unreadAnchorId: '100' }, ); expect(rows.map((row) => row.kind)).toEqual(['date', 'unread', 'group']); }); it('сравнивает идентификаторы как снежинки', () => { expect(compareMessageIds('9', '10')).toBe(-1); expect(compareMessageIds('10', '10')).toBe(0); expect(compareMessageIds('pending:x', '10')).toBe(1); }); }); describe('даты сообщений', () => { it('различает сегодня, вчера и дату', () => { expect(dayLabel(at(19, 9, 0), NOW)).toBe('today'); expect(dayLabel(at(18, 23, 0), NOW)).toBe('yesterday'); expect(dayLabel(at(1, 12, 0), NOW)).toBe('date'); }); it('даёт ключ локального дня', () => { expect(dayKey(at(19, 12, 0))).toBe('2026-09-19'); }); it('форматирует время сообщения по локали', () => { expect(formatMessageTime(at(19, 9, 5), 'ru')).toMatch(/\d{1,2}:\d{2}/u); expect(formatMessageTime('не дата', 'ru')).toBe(''); }); it('хранит окно группировки в 5 минут', () => { expect(GROUP_WINDOW_MS).toBe(300_000); }); }); describe('авторы сообщений', () => { const roles: Role[] = [ { id: 'r-low', name: 'Низкая', color: 0x00ff00, position: 1, permissions: '0', is_default: true, hoist: false, mentionable: false, }, { id: 'r-high', name: 'Высокая', color: 0xff0000, position: 9, permissions: '0', is_default: false, hoist: true, mentionable: true, }, ]; const member: GuildMember = { user_id: 'user-1', username: 'alice', display_name: 'Alice', nickname: 'Аля', status: 'online', is_instance_admin: false, joined_at: '2026-09-01T10:00:00Z', role_ids: ['r-low', 'r-high'], }; it('берёт цвет самой высокой роли', () => { expect(roleColorForMember(member, roles)).toBe('#ff0000'); }); it('игнорирует роли без цвета', () => { const colorless: Role[] = [roles[0] as Role, { ...(roles[1] as Role), color: 0 }]; expect(roleColorForMember(member, colorless)).toBe('#00ff00'); }); it('предпочитает ник участника', () => { expect(memberDisplayName(member)).toBe('Аля'); expect(memberDisplayName({ ...member, nickname: '' })).toBe('Alice'); expect(memberDisplayName(undefined)).toBeNull(); }); }); describe('утилиты чата', () => { it('экранирует селекторы и не падает без CSS.escape', () => { const original = globalThis.CSS; // @ts-expect-error — проверяем запасную ветку без CSS.escape delete globalThis.CSS; try { expect(escapeSelector('pending:abc')).toBe('pending\\:abc'); } finally { globalThis.CSS = original; } }); it('копирование без clipboard не падает', async () => { await expect(copyText('текст')).resolves.toBe(false); }); });