/** * 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 = `
`; 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 = `
`; 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 = `
`; 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 };