refactor: удалены .old файлы

- Deleted chatTestsV2.old.js
- Deleted postDetailModal.old.js

Финальная очистка deprecated кода завершена
This commit is contained in:
Игорь Надеин 2026-01-13 10:21:48 +03:00
parent f003958a5a
commit 8fa7cb2eae
2 changed files with 0 additions and 829 deletions

View File

@ -1,401 +0,0 @@
/**
* postDetailModal.js
* Модуль для открытия деталей поста в модальном окне
* Полностью переработанная версия с правильной логикой определения автора
*/
(function() {
'use strict';
// Константы
const INITIAL_COMMENTS_COUNT = 10;
const DEBUG = true; // Включаем отладку для диагностики
// Состояние модуля
let modal = null;
let modalElement = null;
let currentPost = null; // Сохраняем текущий пост
/**
* Инициализация модального окна
*/
function initPostDetailModal() {
modalElement = document.getElementById('postDetailModal');
if (!modalElement) {
console.warn('postDetailModal: элемент #postDetailModal не найден');
return;
}
modal = new bootstrap.Modal(modalElement);
// Очистка при закрытии
modalElement.addEventListener('hidden.bs.modal', function() {
const contentDiv = document.getElementById('postDetailContent');
if (contentDiv) {
contentDiv.innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
`;
}
currentPost = null;
});
// Добавляем обработчики на ссылки постов
attachPostLinkHandlers();
}
/**
* Получение CSRF токена из cookie
*/
function getCsrfToken() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrftoken') {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Получение JWT токена из cookie (для загрузки данных)
*/
function getAccessToken() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Загрузка деталей поста через API
*/
async function loadPostDetail(postId) {
const accessToken = getAccessToken();
const headers = {
'Accept': 'application/json'
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
try {
const response = await fetch(`/api/v1/posts/${postId}/`, {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json();
// Отладочная информация
if (DEBUG) {
console.log('=== POST DETAIL DEBUG ===');
console.log('Post author:', post.author);
console.log('Post author.id:', post.author?.id, 'type:', typeof post.author?.id);
console.log('Post author_id:', post.author_id, 'type:', typeof post.author_id);
if (post.comments && post.comments.length > 0) {
console.log('First comment:', post.comments[0]);
console.log('First comment author:', post.comments[0].author);
console.log('First comment author.id:', post.comments[0].author?.id, 'type:', typeof post.comments[0].author?.id);
console.log('First comment author_id:', post.comments[0].author_id, 'type:', typeof post.comments[0].author_id);
}
console.log('========================');
}
currentPost = post;
renderPostDetail(post);
} catch (error) {
console.error('Ошибка загрузки поста:', error);
showError('Не удалось загрузить пост');
}
}
/**
* Отображение деталей поста в модальном окне
*/
function renderPostDetail(post) {
const contentDiv = document.getElementById('postDetailContent');
// Определяем ID автора поста (пробуем разные поля)
const postAuthorId = getAuthorId(post.author) || post.author_id;
if (DEBUG) {
console.log('Rendering post with author ID:', postAuthorId);
}
// Формируем HTML с кнопкой закрытия
let html = '<button type="button" class="btn-close position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Закрыть" style="z-index: 1050;"></button>';
html += '<article class="card">';
// Заголовок с автором
html += renderPostHeader(post);
// Изображение
if (post.image) {
html += `<div class="feed-img"><img src="${post.image}" alt="${escapeHtml(post.title || 'Публикация')}"></div>`;
}
// Тело
html += '<div class="card-body">';
html += `<h3 class="feed-title mb-3">${escapeHtml(post.title || 'Без названия')}</h3>`;
if (post.body) {
html += `<div class="feed-text mb-3">${formatBody(post.body)}</div>`;
}
html += '</div>';
// Футер с лайками
html += renderPostFooter(post);
// Секция комментариев
html += renderCommentsSection(post, postAuthorId);
html += '</article>';
contentDiv.innerHTML = html;
// Сохраняем данные для динамической подгрузки
if (post.comments && post.comments.length > 0) {
contentDiv.dataset.allComments = JSON.stringify(post.comments);
contentDiv.dataset.postAuthorId = postAuthorId;
}
// Прокручиваем к последнему комментарию
scrollToBottom();
// Добавляем обработчики
attachLikeHandlers();
attachLoadMoreCommentsHandler();
attachCommentFormHandler();
}
/**
* Отображение комментария
* @param {Object} comment - Объект комментария
* @param {number} postAuthorId - ID автора поста
*/
function renderComment(comment, postAuthorId) {
// Приводим оба ID к числам для корректного сравнения
const commentAuthorId = comment.author?.id ? parseInt(comment.author.id) : null;
const postAuthorIdNum = postAuthorId ? parseInt(postAuthorId) : null;
const isAuthor = commentAuthorId !== null && postAuthorIdNum !== null && commentAuthorId === postAuthorIdNum;
const commentClass = isAuthor ? 'comment-item comment-item--author' : 'comment-item';
let html = `<div class="${commentClass}">`;
html += '<div class="comment-header">';
// Автор и дата
const authorName = comment.author?.full_name || 'Аноним';
const authorId = comment.author?.id;
if (authorId) {
html += `<a href="/employees/${authorId}/" class="comment-author">${authorName}</a>`;
} else {
html += `<span class="comment-author">${authorName}</span>`;
}
html += '<span class="comment-separator">·</span>';
html += `<time class="comment-date">${formatDate(comment.created_at)}</time>`;
html += '</div>';
// Текст комментария
html += `<div class="comment-body">${formatBody(comment.text)}</div>`;
html += '</div>';
return html;
}
/**
* Форматирование даты
*/
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return 'Сегодня, ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Вчера, ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} else if (days < 7) {
return days + ' дн. назад';
} else {
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
}
/**
* Форматирование текста (простая обработка переносов строк)
*/
function formatBody(text) {
return text.replace(/\n/g, '<br>');
}
/**
* Показать ошибку
*/
function showError(message) {
const contentDiv = document.getElementById('postDetailContent');
contentDiv.innerHTML = `
<div class="alert alert-danger" role="alert">
<i class="bi-exclamation-triangle"></i> ${message}
</div>
`;
}
/**
* Открыть модальное окно с постом
*/
function openPostModal(postId) {
if (!modal) {
console.error('Modal не инициализирован');
return;
}
modal.show();
loadPostDetail(postId);
}
/**
* Добавление обработчиков на ссылки постов
*/
function attachPostLinkHandlers() {
// Обработка кликов по ссылкам на посты
document.addEventListener('click', function(e) {
const link = e.target.closest('a[data-post-link]');
if (link) {
e.preventDefault();
const postId = link.dataset.postId;
if (postId) {
openPostModal(postId);
}
}
});
}
/**
* Обработчик для кнопки "Показать ещё комментарии"
*/
function attachLoadMoreCommentsHandler() {
const loadMoreBtn = document.querySelector('.load-more-comments');
if (!loadMoreBtn) return;
loadMoreBtn.addEventListener('click', function() {
const contentDiv = document.getElementById('postDetailContent');
const allComments = JSON.parse(contentDiv.dataset.allComments || '[]');
const postAuthorId = parseInt(contentDiv.dataset.postAuthorId);
const commentsContainer = document.getElementById('commentsListContainer');
if (!commentsContainer || allComments.length === 0) return;
// Сохраняем текущую позицию скролла и высоту
const scrollHeight = commentsContainer.scrollHeight;
const scrollTop = commentsContainer.scrollTop;
// Показываем все комментарии
let html = '';
allComments.forEach(comment => {
html += renderComment(comment, postAuthorId);
});
commentsContainer.innerHTML = html;
// Восстанавливаем позицию скролла (с учётом добавленного контента)
const newScrollHeight = commentsContainer.scrollHeight;
const heightDiff = newScrollHeight - scrollHeight;
commentsContainer.scrollTop = scrollTop + heightDiff;
// Удаляем кнопку после загрузки
this.remove();
});
}
/**
* Обработчики лайков в модальном окне
*/
function attachLikeHandlers() {
const likeButtons = document.querySelectorAll('#postDetailContent .like-btn');
likeButtons.forEach(btn => {
btn.addEventListener('click', async function(e) {
e.preventDefault();
const postId = this.dataset.postId;
await toggleLike(postId, this);
});
});
}
/**
* Переключение лайка
*/
async function toggleLike(postId, button) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
alert('Необходимо авторизоваться');
return;
}
const icon = button.querySelector('i');
const isLiked = icon.classList.contains('bi-heart-fill');
const endpoint = isLiked ? 'unlike' : 'like';
try {
const response = await fetch(`/api/v1/posts/${postId}/${endpoint}/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
const data = await response.json();
// Обновляем иконку и счетчик
if (isLiked) {
icon.classList.remove('bi-heart-fill', 'text-danger');
icon.classList.add('bi-heart');
} else {
icon.classList.remove('bi-heart');
icon.classList.add('bi-heart-fill', 'text-danger');
}
// Обновляем счетчик
const countText = button.childNodes[1];
if (countText) {
countText.textContent = ' ' + (data.likes_count || 0);
}
} else {
alert('Ошибка при обработке лайка');
}
} catch (error) {
console.error('Ошибка:', error);
alert('Ошибка сети');
}
}
// Инициализация при загрузке DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPostDetailModal);
} else {
initPostDetailModal();
}
// Экспорт для использования извне
window.openPostModal = openPostModal;
})();

View File

@ -1,428 +0,0 @@
/**
* Тесты для ChatControllerV2 - умный автоскролл
* Запуск:
* .venv/Scripts/python manage.py runserver
* Открыть в браузере: http://localhost:8000/static/js/tests/chatTestsV2.html
*/
import { ChatControllerV2 } from '../controllers/chatControllerV2.js';
import { MessageStoreV2 } from '../stores/messageStoreV2.js';
class ChatTestSuiteV2 {
constructor() {
this.results = {
passed: 0,
failed: 0,
total: 0,
details: []
};
}
// ==================== Утилиты ====================
assert(condition, message) {
this.results.total++;
if (condition) {
this.results.passed++;
console.log(`✅ PASS: ${message}`);
this.results.details.push({ status: 'PASS', message });
} else {
this.results.failed++;
console.error(`❌ FAIL: ${message}`);
this.results.details.push({ status: 'FAIL', message });
}
}
assertEqual(actual, expected, message) {
const condition = actual === expected;
const fullMessage = `${message} (expected: ${expected}, got: ${actual})`;
this.assert(condition, fullMessage);
}
assertNotNull(value, message) {
this.assert(value !== null && value !== undefined, message);
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
resetResults() {
this.results = {
passed: 0,
failed: 0,
total: 0,
details: []
};
}
printResults() {
const { passed, failed, total } = this.results;
const passRate = total > 0 ? parseFloat(((passed / total) * 100).toFixed(1)) : 0;
console.log('\n' + '='.repeat(60));
console.log(`📊 Результаты тестов V2:`);
console.log(` ✅ Успешных: ${passed}`);
console.log(` ❌ Провалено: ${failed}`);
console.log(` 📈 Процент: ${passRate}%`);
console.log('='.repeat(60) + '\n');
return { passed, failed, total, passRate };
}
// ==================== Тесты базовой функциональности V2 ====================
async testBasicFunctionalityV2() {
console.log('\n🧪 Тестирование базовой функциональности V2...\n');
this.resetResults();
const store = new MessageStoreV2();
const chatId = 1;
// Тест 1: Добавление сообщения
const msg1 = {
id: 1,
chat_id: chatId,
author_id: 1,
author_name: 'User 1',
content: 'Test message',
created_ts: Date.now(),
created: new Date().toISOString()
};
store.addMessage(msg1);
this.assert(store.hasMessage(1), 'Сообщение должно быть добавлено');
// Тест 2: Batch добавление (V2 фича)
const messages = [
{ id: 2, chat_id: chatId, author_id: 1, content: 'Msg 2', created_ts: Date.now() + 1000, created: new Date().toISOString() },
{ id: 3, chat_id: chatId, author_id: 1, content: 'Msg 3', created_ts: Date.now() + 2000, created: new Date().toISOString() },
{ id: 4, chat_id: chatId, author_id: 1, content: 'Msg 4', created_ts: Date.now() + 3000, created: new Date().toISOString() }
];
store.addMessages(messages);
this.assertEqual(store.messages.size, 4, 'Должно быть 4 сообщения после batch');
// Тест 3: Обновление сообщения
store.updateMessage(1, { content: 'Updated', is_edited: true });
const updated = store.getMessage(1);
this.assertEqual(updated.content, 'Updated', 'Сообщение должно быть обновлено');
// Тест 4: Удаление сообщения
store.removeMessage(1);
this.assert(!store.hasMessage(1), 'Сообщение должно быть удалено');
this.printResults();
return this.results;
}
// ==================== Тесты умного автоскролла V2 ====================
async testSmartAutoscrollV2() {
console.log('\n🧪 Тестирование умного автоскролла V2 (8 сценариев, ~18 проверок)...\n');
this.resetResults();
// Создаем реальный контейнер
const container = document.createElement('div');
container.id = 'test-chat-container-v2';
container.style.cssText = 'height: 400px; overflow-y: auto; position: relative;';
document.body.appendChild(container);
const currentUserId = 1;
const controller = new ChatControllerV2({
chatId: 1,
scrollElement: container,
containerId: 'test-chat-container-v2',
currentUserId: currentUserId
});
// Инициализируем контроллер
try {
await controller.init();
} catch (e) {
console.log('[Test V2] Init failed (expected without backend):', e.message);
controller._initializing = false;
controller._initialized = false;
// ВАЖНО: Инициализируем scroll watcher вручную для тестов
// т.к. init() упал, но watcher нужен для Теста 5
controller._initScrollWatcher();
}
// Тест 1: Своё сообщение - всегда скроллим вниз
console.log('\n--- Тест 1: Своё сообщение скроллит вниз ---');
// Добавляем несколько сообщений для высоты
for (let i = 0; i < 20; i++) {
controller.store.addMessage({
id: i + 1,
chat_id: 1,
author_id: 2,
author_name: 'Other User',
content: `Message ${i + 1}`,
created_ts: Date.now() + i * 1000,
created: new Date(Date.now() + i * 1000).toISOString()
});
}
controller.renderer.render(1);
await this.sleep(100);
// Прокручиваем вверх (читаем историю)
container.scrollTop = 50;
await this.sleep(50);
const wasAtBottom = controller.scrollManager.isNearBottom();
this.assert(!wasAtBottom, 'До отправки своего сообщения должны быть НЕ внизу');
// Добавляем своё сообщение
controller.store.addMessage({
id: 100,
chat_id: 1,
author_id: currentUserId,
author_name: 'Me',
content: 'My message',
created_ts: Date.now() + 30000,
created: new Date(Date.now() + 30000).toISOString()
});
await this.sleep(500);
const isNowAtBottom = controller.scrollManager.isNearBottom();
this.assert(isNowAtBottom, 'После своего сообщения должны быть внизу (автоскролл)');
// Тест 2: Чужое сообщение + внизу - скроллим
console.log('\n--- Тест 2: Чужое сообщение + внизу = скролл ---');
// Убеждаемся что внизу
controller.scrollManager.scrollToBottom({ instant: true, force: true });
await this.sleep(100);
const wasAtBottomBefore = controller.scrollManager.isNearBottom();
this.assert(wasAtBottomBefore, 'Должны быть внизу перед получением чужого сообщения');
// Добавляем чужое сообщение
controller.store.addMessage({
id: 101,
chat_id: 1,
author_id: 2,
author_name: 'Other User',
content: 'Other message',
created_ts: Date.now() + 31000,
created: new Date(Date.now() + 31000).toISOString()
});
await this.sleep(500);
const stillAtBottom = controller.scrollManager.isNearBottom();
this.assert(stillAtBottom, 'Должны остаться внизу после чужого сообщения (автоскролл)');
// Тест 3: Чужое сообщение + читаем историю - показываем индикатор
console.log('\n--- Тест 3: Чужое сообщение + история = индикатор ---');
// Прокручиваем вверх (читаем историю)
container.scrollTop = 100;
await this.sleep(100);
const readingHistory = !controller.scrollManager.isNearBottom();
this.assert(readingHistory, 'Должны читать историю (не внизу)');
// Сбрасываем счётчик индикатора
controller._newMessagesCount = 0;
// Добавляем чужое сообщение
controller.store.addMessage({
id: 102,
chat_id: 1,
author_id: 2,
author_name: 'Other User',
content: 'Another message',
created_ts: Date.now() + 32000,
created: new Date(Date.now() + 32000).toISOString()
});
await this.sleep(500);
// Проверяем что индикатор показан
this.assert(controller._newMessagesCount > 0, 'Счётчик новых сообщений должен увеличиться');
const indicator = document.getElementById('new-messages-btn');
if (indicator) {
const isVisible = indicator.style.display !== 'none';
this.assert(isVisible, 'Индикатор новых сообщений должен быть видимым');
} else {
console.warn('[Test V2] Индикатор не найден (может создаваться при первом показе)');
}
// Тест 4: Клик по индикатору - скролл вниз и скрытие
console.log('\n--- Тест 4: Клик по индикатору ---');
if (indicator && controller._newMessagesCount > 0) {
// Кликаем по индикатору
indicator.click();
// Ждём завершения анимации скролла
await this.sleep(500);
// Проверяем что прокрутили вниз
const isAtBottomNow = controller.scrollManager.isNearBottom();
this.assert(isAtBottomNow, 'После клика по индикатору должны быть внизу');
// Проверяем что счётчик сброшен
this.assertEqual(controller._newMessagesCount, 0, 'Счётчик должен быть сброшен');
// Проверяем что индикатор скрыт
const isHiddenNow = indicator.style.display === 'none';
this.assert(isHiddenNow, 'Индикатор должен быть скрыт');
}
// Тест 5: Ручной скролл вниз - скрытие индикатора
console.log('\n--- Тест 5: Ручной скролл вниз ---');
// Прокручиваем вверх
container.scrollTop = 50;
controller._newMessagesCount = 5; // Устанавливаем счётчик вручную
if (indicator) {
indicator.style.display = 'flex';
const badge = indicator.querySelector('.badge');
if (badge) badge.textContent = '5';
}
await this.sleep(100);
const countBefore = controller._newMessagesCount;
this.assertEqual(countBefore, 5, 'Счётчик установлен корректно');
// Прокручиваем вниз вручную (programmatic scroll)
controller.scrollManager.scrollToBottom({ instant: false, force: true });
// Ждем скролл event + обработку
await this.sleep(800);
// Проверяем что индикатор скрылся
this.assertEqual(controller._newMessagesCount, 0, 'Счётчик должен сброситься при ручном скролле');
// Тест 6: Оптимистичное сообщение - скроллим
console.log('\n--- Тест 6: Оптимистичное сообщение ---');
// Прокручиваем вверх
container.scrollTop = 80;
await this.sleep(100);
const notAtBottomBefore = !controller.scrollManager.isNearBottom();
this.assert(notAtBottomBefore, 'Должны быть не внизу перед оптимистичным сообщением');
// Добавляем оптимистичное сообщение (своё)
controller.store.addMessage({
id: 'temp_123',
chat_id: 1,
author_id: currentUserId,
author_name: 'Me',
content: 'Optimistic message',
created_ts: Date.now() + 33000,
created: new Date(Date.now() + 33000).toISOString(),
is_optimistic: true
}, true); // true = optimistic
await this.sleep(500);
const atBottomAfter = controller.scrollManager.isNearBottom();
this.assert(atBottomAfter, 'Оптимистичное сообщение должно скроллить вниз');
// Тест 7: Методы индикатора существуют
console.log('\n--- Тест 7: API индикатора ---');
this.assert(typeof controller._showNewMessagesIndicator === 'function', '_showNewMessagesIndicator должен быть функцией');
this.assert(typeof controller._hideNewMessagesIndicator === 'function', '_hideNewMessagesIndicator должен быть функцией');
this.assert(typeof controller._findOrCreateNewMessagesButton === 'function', '_findOrCreateNewMessagesButton должен быть функцией');
this.assert(typeof controller._initScrollWatcher === 'function', '_initScrollWatcher должен быть функцией');
// Тест 8: Счётчик новых сообщений инкрементируется
console.log('\n--- Тест 8: Счётчик сообщений ---');
container.scrollTop = 100;
controller._newMessagesCount = 0;
await this.sleep(100);
// Добавляем 3 чужих сообщения подряд
for (let i = 0; i < 3; i++) {
controller.store.addMessage({
id: 200 + i,
chat_id: 1,
author_id: 2,
author_name: 'Other User',
content: `Batch message ${i}`,
created_ts: Date.now() + 40000 + i * 100,
created: new Date(Date.now() + 40000 + i * 100).toISOString()
});
await this.sleep(50);
}
await this.sleep(500);
this.assertEqual(controller._newMessagesCount, 3, 'Счётчик должен показывать 3 новых сообщения');
// Cleanup
controller.destroy();
if (indicator && indicator.parentElement) {
indicator.parentElement.removeChild(indicator);
}
document.body.removeChild(container);
this.printResults();
return this.results;
}
// ==================== Публичное API ====================
async runAll() {
console.log('\n' + '='.repeat(60));
console.log('🚀 Запуск всех тестов ChatControllerV2');
console.log('='.repeat(60));
const basicResults = await this.testBasicFunctionalityV2();
const smartAutoscrollResults = await this.testSmartAutoscrollV2();
const totalPassed = basicResults.passed + smartAutoscrollResults.passed;
const totalFailed = basicResults.failed + smartAutoscrollResults.failed;
const totalTests = basicResults.total + smartAutoscrollResults.total;
const totalRate = totalTests > 0 ? parseFloat(((totalPassed / totalTests) * 100).toFixed(1)) : 0;
console.log('\n' + '='.repeat(60));
console.log('📊 ИТОГОВЫЕ РЕЗУЛЬТАТЫ V2:');
console.log(` ✅ Успешных: ${totalPassed}`);
console.log(` ❌ Провалено: ${totalFailed}`);
console.log(` 📈 Процент: ${totalRate}%`);
console.log('='.repeat(60) + '\n');
return {
basic: basicResults,
smartAutoscroll: smartAutoscrollResults,
total: { passed: totalPassed, failed: totalFailed, total: totalTests, passRate: totalRate }
};
}
}
// Создаем глобальный экземпляр
const chatTestsV2 = new ChatTestSuiteV2();
// Экспортируем для использования в модулях
export default chatTestsV2;
// Добавляем в window для вызова из консоли
window.ChatTestsV2 = {
runAll: () => chatTestsV2.runAll(),
testBasicFunctionalityV2: () => chatTestsV2.testBasicFunctionalityV2(),
testSmartAutoscrollV2: () => chatTestsV2.testSmartAutoscrollV2()
};
console.log('✅ ChatTestsV2 loaded!');
console.log('💡 Запуск из консоли:');
console.log(' window.ChatTestsV2.runAll() - Все тесты V2');
console.log(' window.ChatTestsV2.testBasicFunctionalityV2() - Базовая функциональность');
console.log(' window.ChatTestsV2.testSmartAutoscrollV2() - Умный автоскролл');
console.log('');
console.log('⚠️ ПРИМЕЧАНИЕ: Это сокращенная версия тестов для V2.');
console.log(' Полная версия (11 тестов, 1732 строки) доступна в chatTests.js');
console.log(' V2 тесты фокусируются на новых фичах: smart autoscroll, batch ops, retry logic');