2026-09-20 00:15:18 +03:00
|
|
|
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<Message> & { 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,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 12:06:22 +03:00
|
|
|
it('не присоединяет обычное сообщение к действию, шёпоту и личному', () => {
|
|
|
|
|
const next = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 1) });
|
|
|
|
|
for (const type of ['action', 'whisper', 'scream', 'private', 'system']) {
|
|
|
|
|
const previous = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0), type });
|
|
|
|
|
expect(canGroup(previous, next)).toBe(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|