584 lines
21 KiB
JavaScript
584 lines
21 KiB
JavaScript
/**
|
||
* Frontend тесты для двунаправленной загрузки сообщений
|
||
*
|
||
* Проверяемые компоненты:
|
||
* - MessageLoaderV2 (loadHistory, loadNewer, loadAround)
|
||
* - ScrollManagerV2 (IntersectionObserver, автозагрузка)
|
||
* - MessageRendererV2 (prependMessages, appendMessages)
|
||
* - MessageStoreV2 (boundaries, hasMoreAfter)
|
||
*
|
||
* Запуск:
|
||
* npm test -- bidirectional-loading.test.js
|
||
*
|
||
* Или через браузер:
|
||
* Открыть в браузере с включенным live server
|
||
*/
|
||
|
||
import { jest } from '@jest/globals';
|
||
import { MessageLoaderV2 } from '../loaders/messageLoaderV2.js';
|
||
import { ScrollManagerV2 } from '../managers/scrollManagerV2.js';
|
||
import { MessageRendererV2 } from '../renderers/messageRendererV2.js';
|
||
import { MessageStoreV2 } from '../stores/messageStoreV2.js';
|
||
|
||
class MockIntersectionObserver {
|
||
constructor(callback) {
|
||
this.callback = callback;
|
||
this.observe = jest.fn();
|
||
this.unobserve = jest.fn();
|
||
this.disconnect = jest.fn();
|
||
}
|
||
}
|
||
|
||
global.IntersectionObserver = MockIntersectionObserver;
|
||
|
||
// ==================== Mock Data ====================
|
||
|
||
const MOCK_MESSAGES = {
|
||
// Сообщения за 1 января
|
||
jan1: Array.from({ length: 30 }, (_, i) => ({
|
||
id: i + 1,
|
||
chat_id: 1,
|
||
author_id: 1,
|
||
content: `Message from Jan 1 - ${i}`,
|
||
created_ts: new Date(2026, 0, 1, 12, i).getTime(),
|
||
author: { id: 1, name: 'Test User' }
|
||
})),
|
||
|
||
// Сообщения за 5 января
|
||
jan5: Array.from({ length: 40 }, (_, i) => ({
|
||
id: i + 31,
|
||
chat_id: 1,
|
||
author_id: 1,
|
||
content: `Message from Jan 5 - ${i}`,
|
||
created_ts: new Date(2026, 0, 5, 12, i).getTime(),
|
||
author: { id: 1, name: 'Test User' }
|
||
})),
|
||
|
||
// Сообщения за 10 января
|
||
jan10: Array.from({ length: 30 }, (_, i) => ({
|
||
id: i + 71,
|
||
chat_id: 1,
|
||
author_id: 1,
|
||
content: `Message from Jan 10 - ${i}`,
|
||
created_ts: new Date(2026, 0, 10, 12, i).getTime(),
|
||
author: { id: 1, name: 'Test User' }
|
||
}))
|
||
};
|
||
|
||
const ALL_MESSAGES = [
|
||
...MOCK_MESSAGES.jan1,
|
||
...MOCK_MESSAGES.jan5,
|
||
...MOCK_MESSAGES.jan10
|
||
];
|
||
|
||
// ==================== Mock API ====================
|
||
|
||
class MockAPI {
|
||
static fetchMessages(params = {}) {
|
||
const { before_id, after_id, around_id, limit = 30 } = params;
|
||
const numericLimit = Number(limit) || 30;
|
||
|
||
let filtered = [...ALL_MESSAGES];
|
||
|
||
// Загрузка вокруг даты (timestamp)
|
||
if (around_id && around_id > 1_000_000_000) {
|
||
const targetTimestamp = around_id;
|
||
|
||
// Находим ближайшее сообщение
|
||
const closest = filtered.reduce((prev, curr) => {
|
||
return Math.abs(curr.created_ts - targetTimestamp) < Math.abs(prev.created_ts - targetTimestamp)
|
||
? curr
|
||
: prev;
|
||
});
|
||
|
||
const closestIndex = filtered.indexOf(closest);
|
||
const start = Math.max(0, closestIndex - Math.floor(numericLimit / 2));
|
||
const end = Math.min(filtered.length, start + numericLimit);
|
||
|
||
filtered = filtered.slice(start, end);
|
||
|
||
return {
|
||
results: filtered,
|
||
anchor_id: closest.id,
|
||
anchor_index: closestIndex,
|
||
has_more_before: start > 0,
|
||
has_more_after: end < ALL_MESSAGES.length
|
||
};
|
||
}
|
||
|
||
// Загрузка после ID (новые сообщения)
|
||
if (after_id) {
|
||
filtered = filtered.filter(msg => msg.id > after_id);
|
||
filtered = filtered.slice(0, numericLimit);
|
||
|
||
const lastId = filtered.length > 0 ? filtered[filtered.length - 1].id : after_id;
|
||
const hasMoreAfter = ALL_MESSAGES.some(msg => msg.id > lastId);
|
||
|
||
return {
|
||
results: filtered,
|
||
has_more: false,
|
||
has_more_after: hasMoreAfter,
|
||
next_after_id: hasMoreAfter ? lastId : null
|
||
};
|
||
}
|
||
|
||
// Загрузка перед ID (старые сообщения)
|
||
if (before_id) {
|
||
filtered = filtered.filter(msg => msg.id < before_id);
|
||
filtered = filtered.slice(-numericLimit);
|
||
|
||
const firstId = filtered.length > 0 ? filtered[0].id : before_id;
|
||
const hasMoreBefore = ALL_MESSAGES.some(msg => msg.id < firstId);
|
||
|
||
return {
|
||
results: filtered,
|
||
has_more: hasMoreBefore,
|
||
has_more_after: true
|
||
};
|
||
}
|
||
|
||
// Загрузка последних сообщений
|
||
filtered = filtered.slice(-numericLimit);
|
||
|
||
return {
|
||
results: filtered,
|
||
has_more: ALL_MESSAGES.length > numericLimit,
|
||
has_more_after: false
|
||
};
|
||
}
|
||
}
|
||
|
||
// ==================== Test Suite: MessageLoaderV2 ====================
|
||
|
||
describe('MessageLoaderV2 - Bidirectional Loading', () => {
|
||
let loader;
|
||
let store;
|
||
|
||
beforeEach(() => {
|
||
store = new MessageStoreV2({ currentUserId: 1 });
|
||
|
||
// Mock fetch
|
||
global.fetch = jest.fn((url) => {
|
||
const urlObj = new URL(url, 'http://localhost');
|
||
const params = Object.fromEntries(urlObj.searchParams);
|
||
const data = MockAPI.fetchMessages(params);
|
||
|
||
return Promise.resolve({
|
||
ok: true,
|
||
json: () => Promise.resolve(data)
|
||
});
|
||
});
|
||
|
||
loader = new MessageLoaderV2({ store });
|
||
});
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks();
|
||
});
|
||
|
||
test('Test 1: loadHistory загружает старые сообщения', async () => {
|
||
const chatId = 1;
|
||
|
||
// Загружаем последние
|
||
await loader.loadInitial(chatId);
|
||
const oldestBefore = store.getOldestMessage(chatId);
|
||
|
||
// Загружаем историю
|
||
const messages = await loader.loadHistory(chatId);
|
||
|
||
expect(messages.length).toBeGreaterThan(0);
|
||
|
||
// Проверяем что все сообщения старше
|
||
messages.forEach(msg => {
|
||
expect(msg.id).toBeLessThan(oldestBefore.id);
|
||
});
|
||
|
||
expect(loader.hasMoreHistory(chatId)).toBe(true);
|
||
});
|
||
|
||
test('Test 2: loadNewer загружает новые сообщения', async () => {
|
||
const chatId = 1;
|
||
|
||
// Загружаем сообщения за 5 января через loadAround
|
||
const jan5Timestamp = new Date(2026, 0, 5, 12, 0).getTime();
|
||
await loader.loadAround(chatId, jan5Timestamp);
|
||
|
||
const newestBefore = store.getNewestMessage(chatId);
|
||
|
||
// Загружаем новые сообщения
|
||
const messages = await loader.loadNewer(chatId);
|
||
|
||
expect(messages.length).toBeGreaterThan(0);
|
||
|
||
// Проверяем что все сообщения новее
|
||
messages.forEach(msg => {
|
||
expect(msg.id).toBeGreaterThan(newestBefore.id);
|
||
});
|
||
});
|
||
|
||
test('Test 3: loadAround загружает сообщения вокруг даты', async () => {
|
||
const chatId = 1;
|
||
const targetDate = new Date(2026, 0, 5, 12, 0);
|
||
|
||
const result = await loader.loadAround(chatId, targetDate.getTime());
|
||
|
||
expect(result.messages.length).toBeGreaterThan(0);
|
||
expect(result.anchorId).toBeDefined();
|
||
expect(result.hasMoreBefore).toBe(true);
|
||
expect(result.hasMoreAfter).toBe(true);
|
||
|
||
// Проверяем что сообщения действительно за 5 января
|
||
const jan5Messages = result.messages.filter(m => m.content.includes('Jan 5'));
|
||
expect(jan5Messages.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('Test 4: hasMoreAfter корректно отслеживается', async () => {
|
||
const chatId = 1;
|
||
|
||
// Загружаем последние сообщения
|
||
await loader.loadInitial(chatId);
|
||
|
||
// Для последних сообщений hasMoreAfter должен быть false
|
||
expect(loader.hasMoreAfter(chatId)).toBe(false);
|
||
|
||
// Загружаем сообщения за 5 января
|
||
const jan5Timestamp = new Date(2026, 0, 5, 12, 0).getTime();
|
||
await loader.loadAround(chatId, jan5Timestamp);
|
||
|
||
// Теперь hasMoreAfter должен быть true (есть сообщения за 10 января)
|
||
expect(loader.hasMoreAfter(chatId)).toBe(true);
|
||
});
|
||
|
||
test('Test 5: boundaries обновляются после loadNewer', async () => {
|
||
const chatId = 1;
|
||
|
||
// Загружаем середину
|
||
const jan5Timestamp = new Date(2026, 0, 5, 12, 0).getTime();
|
||
await loader.loadAround(chatId, jan5Timestamp);
|
||
|
||
const newestBefore = store.getNewestMessage(chatId);
|
||
|
||
// Загружаем новые
|
||
await loader.loadNewer(chatId);
|
||
|
||
const newestAfter = store.getNewestMessage(chatId);
|
||
|
||
// newestId должен обновиться
|
||
expect(newestAfter.id).toBeGreaterThan(newestBefore.id);
|
||
});
|
||
});
|
||
|
||
// ==================== Test Suite: ScrollManagerV2 ====================
|
||
|
||
describe('ScrollManagerV2 - IntersectionObserver', () => {
|
||
let scrollManager;
|
||
let loader;
|
||
let renderer;
|
||
let store;
|
||
let scrollElement;
|
||
|
||
beforeEach(() => {
|
||
// Создаем DOM элементы
|
||
document.body.innerHTML = `
|
||
<div id="chatScroll" style="height: 400px; overflow-y: auto;">
|
||
</div>
|
||
`;
|
||
|
||
scrollElement = document.getElementById('chatScroll');
|
||
store = new MessageStoreV2({ currentUserId: 1 });
|
||
|
||
// Mock fetch
|
||
global.fetch = jest.fn((url) => {
|
||
const urlObj = new URL(url, 'http://localhost');
|
||
const params = Object.fromEntries(urlObj.searchParams);
|
||
const data = MockAPI.fetchMessages(params);
|
||
|
||
return Promise.resolve({
|
||
ok: true,
|
||
json: () => Promise.resolve(data)
|
||
});
|
||
});
|
||
|
||
loader = new MessageLoaderV2({ store });
|
||
renderer = new MessageRendererV2({
|
||
store,
|
||
containerId: 'chatScroll',
|
||
currentUserId: 1
|
||
});
|
||
|
||
scrollManager = new ScrollManagerV2({
|
||
scrollElement,
|
||
loader,
|
||
renderer,
|
||
store,
|
||
chatId: 1
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
scrollManager.destroy();
|
||
jest.restoreAllMocks();
|
||
});
|
||
|
||
test('Test 6: IntersectionObserver на первом сообщении (история)', async () => {
|
||
const chatId = 1;
|
||
|
||
// Загружаем начальные сообщения
|
||
await loader.loadInitial(chatId);
|
||
await renderer.render(chatId);
|
||
|
||
await scrollManager.init();
|
||
|
||
// Проверяем что observer создан
|
||
expect(scrollManager._historyObserver).toBeDefined();
|
||
|
||
// Проверяем что наблюдается первое сообщение
|
||
const firstMessage = scrollElement.querySelector('.msg[data-message-id]');
|
||
expect(firstMessage).toBeTruthy();
|
||
});
|
||
|
||
test('Test 7: IntersectionObserver на последнем сообщении (новые)', async () => {
|
||
const chatId = 1;
|
||
|
||
// Загружаем сообщения за 5 января (не последние)
|
||
const jan5Timestamp = new Date(2026, 0, 5, 12, 0).getTime();
|
||
await loader.loadAround(chatId, jan5Timestamp);
|
||
await renderer.render(chatId);
|
||
|
||
await scrollManager.init();
|
||
|
||
// Проверяем что observer для новых сообщений создан
|
||
expect(scrollManager._newerObserver).toBeDefined();
|
||
|
||
// Проверяем что hasMoreAfter = true
|
||
expect(loader.hasMoreAfter(chatId)).toBe(true);
|
||
});
|
||
|
||
test('Test 8: loadMoreNewer вызывается при скролле вниз', async () => {
|
||
const chatId = 1;
|
||
|
||
// Spy на loadMoreNewer
|
||
const loadNewerSpy = jest.spyOn(scrollManager, 'loadMoreNewer');
|
||
|
||
// Загружаем сообщения за 5 января
|
||
const jan5Timestamp = new Date(2026, 0, 5, 12, 0).getTime();
|
||
await loader.loadAround(chatId, jan5Timestamp);
|
||
await renderer.render(chatId);
|
||
|
||
await scrollManager.init();
|
||
|
||
// Симулируем скролл вниз до последнего сообщения
|
||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||
|
||
// Триггерим IntersectionObserver (в реальности срабатывает автоматически)
|
||
// В тестах нужно вызвать вручную
|
||
const lastMessage = scrollElement.querySelector('.msg[data-message-id]:last-child');
|
||
|
||
if (lastMessage && scrollManager._newerObserver) {
|
||
// Simulate intersection
|
||
await scrollManager.loadMoreNewer();
|
||
|
||
expect(loadNewerSpy).toHaveBeenCalled();
|
||
}
|
||
});
|
||
|
||
test('Test 9: pauseScrollEvents приостанавливает обработку', async () => {
|
||
const chatId = 1;
|
||
|
||
await loader.loadInitial(chatId);
|
||
await renderer.render(chatId);
|
||
await scrollManager.init();
|
||
|
||
// Приостанавливаем на 500мс
|
||
scrollManager.pauseScrollEvents(500);
|
||
|
||
// События скролла должны игнорироваться
|
||
const scrollHandler = jest.spyOn(scrollManager, '_onScroll');
|
||
|
||
scrollElement.scrollTop = 100;
|
||
scrollElement.dispatchEvent(new Event('scroll'));
|
||
|
||
// Проверяем что обработчик НЕ вызвался (или вернулся рано)
|
||
// В реальной реализации pauseScrollEvents ставит флаг
|
||
});
|
||
});
|
||
|
||
// ==================== Test Suite: MessageRendererV2 ====================
|
||
|
||
describe('MessageRendererV2 - Append/Prepend', () => {
|
||
let renderer;
|
||
let store;
|
||
|
||
beforeEach(() => {
|
||
document.body.innerHTML = `<div id="chatScroll"></div>`;
|
||
|
||
store = new MessageStoreV2({ currentUserId: 1 });
|
||
renderer = new MessageRendererV2({
|
||
store,
|
||
containerId: 'chatScroll',
|
||
currentUserId: 1
|
||
});
|
||
});
|
||
|
||
test('Test 10: prependMessages добавляет в начало', () => {
|
||
const chatId = 1;
|
||
const container = document.getElementById('chatScroll');
|
||
|
||
// Добавляем начальные сообщения
|
||
store.addMessages(MOCK_MESSAGES.jan5);
|
||
renderer.render(chatId);
|
||
|
||
const countBefore = container.querySelectorAll('.msg').length;
|
||
|
||
// Prepend старые сообщения
|
||
store.addMessages(MOCK_MESSAGES.jan1);
|
||
const fragment = renderer.prependMessages(MOCK_MESSAGES.jan1, chatId);
|
||
container.insertBefore(fragment, container.firstChild);
|
||
|
||
const countAfter = container.querySelectorAll('.msg').length;
|
||
|
||
expect(countAfter).toBeGreaterThan(countBefore);
|
||
|
||
// Проверяем что первое сообщение теперь за 1 января
|
||
const firstMessage = container.querySelector('.msg[data-message-id]');
|
||
expect(firstMessage.dataset.messageId).toBe('1');
|
||
});
|
||
|
||
test('Test 11: appendMessages добавляет в конец', () => {
|
||
const chatId = 1;
|
||
const container = document.getElementById('chatScroll');
|
||
|
||
// Добавляем начальные сообщения
|
||
store.addMessages(MOCK_MESSAGES.jan5);
|
||
renderer.render(chatId);
|
||
|
||
const countBefore = container.querySelectorAll('.msg').length;
|
||
|
||
// Append новые сообщения
|
||
store.addMessages(MOCK_MESSAGES.jan10);
|
||
const fragment = renderer.appendMessages(MOCK_MESSAGES.jan10, chatId);
|
||
container.appendChild(fragment);
|
||
|
||
const countAfter = container.querySelectorAll('.msg').length;
|
||
|
||
expect(countAfter).toBeGreaterThan(countBefore);
|
||
|
||
// Проверяем что последнее сообщение теперь за 10 января
|
||
const messages = container.querySelectorAll('.msg[data-message-id]');
|
||
const lastMessage = messages[messages.length - 1];
|
||
expect(lastMessage.dataset.messageId).toBe('100');
|
||
});
|
||
|
||
test('Test 12: Date dividers создаются корректно', () => {
|
||
const chatId = 1;
|
||
const container = document.getElementById('chatScroll');
|
||
|
||
// Добавляем сообщения за разные даты
|
||
store.addMessages([...MOCK_MESSAGES.jan1, ...MOCK_MESSAGES.jan5]);
|
||
renderer.render(chatId);
|
||
|
||
// Должны быть 2 date dividers (1 января и 5 января)
|
||
const dividers = container.querySelectorAll('.day-divider, .sticky-date');
|
||
expect(dividers.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
// ==================== Test Suite: Integration ====================
|
||
|
||
describe('Integration - Full Bidirectional Flow', () => {
|
||
let loader;
|
||
let renderer;
|
||
let scrollManager;
|
||
let store;
|
||
let scrollElement;
|
||
|
||
beforeEach(() => {
|
||
document.body.innerHTML = `<div id="chatScroll" style="height: 400px; overflow-y: auto;"></div>`;
|
||
|
||
scrollElement = document.getElementById('chatScroll');
|
||
store = new MessageStoreV2({ currentUserId: 1 });
|
||
|
||
global.fetch = jest.fn((url) => {
|
||
const urlObj = new URL(url, 'http://localhost');
|
||
const params = Object.fromEntries(urlObj.searchParams);
|
||
const data = MockAPI.fetchMessages(params);
|
||
|
||
return Promise.resolve({
|
||
ok: true,
|
||
json: () => Promise.resolve(data)
|
||
});
|
||
});
|
||
|
||
loader = new MessageLoaderV2({ store });
|
||
renderer = new MessageRendererV2({
|
||
store,
|
||
containerId: 'chatScroll',
|
||
currentUserId: 1
|
||
});
|
||
scrollManager = new ScrollManagerV2({
|
||
scrollElement,
|
||
loader,
|
||
renderer,
|
||
store,
|
||
chatId: 1
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
scrollManager.destroy();
|
||
jest.restoreAllMocks();
|
||
});
|
||
|
||
test('Test 13: Полный сценарий - прыжок → история → новые', async () => {
|
||
const chatId = 1;
|
||
|
||
// Шаг 1: Прыгаем на 5 января
|
||
const jan5Timestamp = new Date(2026, 0, 5, 12, 0).getTime();
|
||
await loader.loadAround(chatId, jan5Timestamp);
|
||
await renderer.render(chatId);
|
||
|
||
const messagesAfterJump = store.getMessageCount(chatId);
|
||
expect(messagesAfterJump).toBeGreaterThan(0);
|
||
|
||
// Шаг 2: Загружаем историю (старые)
|
||
await scrollManager.loadMoreHistory();
|
||
|
||
const messagesAfterHistory = store.getMessageCount(chatId);
|
||
expect(messagesAfterHistory).toBeGreaterThan(messagesAfterJump);
|
||
|
||
// Шаг 3: Загружаем новые сообщения
|
||
await scrollManager.loadMoreNewer();
|
||
|
||
const messagesAfterNewer = store.getMessageCount(chatId);
|
||
expect(messagesAfterNewer).toBeGreaterThan(messagesAfterHistory);
|
||
|
||
// Проверяем границы
|
||
const oldest = store.getOldestMessage(chatId);
|
||
const newest = store.getNewestMessage(chatId);
|
||
|
||
expect(oldest.id).toBeLessThan(newest.id);
|
||
});
|
||
});
|
||
|
||
// ==================== Test Runner ====================
|
||
|
||
// Для запуска в браузере (без Jest)
|
||
if (typeof jest === 'undefined') {
|
||
console.log('Running tests in browser mode...');
|
||
|
||
// Простой тестовый раннер
|
||
const runTests = async () => {
|
||
console.group('MessageLoaderV2 Tests');
|
||
// Здесь можно добавить ручные тесты
|
||
console.groupEnd();
|
||
};
|
||
|
||
runTests();
|
||
}
|
||
|
||
export {
|
||
MockAPI,
|
||
MOCK_MESSAGES,
|
||
ALL_MESSAGES
|
||
};
|