import React, { useState, useEffect, useRef } from 'react' import { useAuth } from './auth/AuthContext' import LoadingError from './LoadingError' import './TestWords.css' import './Integrations.css' const API_URL = '/api' const DEFAULT_TEST_WORD_COUNT = 10 function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) { const { authFetch } = useAuth() const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT const configId = initialConfigId || null const maxCards = initialMaxCards || null const taskId = initialTaskId || null const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики) const [testWords, setTestWords] = useState([]) // Пул слов для показа const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула) const [flippedCards, setFlippedCards] = useState(new Set()) const [wordStats, setWordStats] = useState({}) // Локальная статистика const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов const isFinishingRef = useRef(false) const wordStatsRef = useRef({}) const processingRef = useRef(false) const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition // Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами // excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка) const redistributeWordsEvenly = (currentPool, allWords, excludeFirstWordId = null) => { if (currentPool.length === 0 || allWords.length === 0) { return currentPool } // Подсчитываем, сколько раз каждое слово встречается в текущем пуле const wordCounts = {} currentPool.forEach(word => { wordCounts[word.id] = (wordCounts[word.id] || 0) + 1 }) // Получаем список уникальных слов, которые есть в пуле const uniqueWordIds = Object.keys(wordCounts).map(id => parseInt(id)) const uniqueWords = allWords.filter(word => uniqueWordIds.includes(word.id)) if (uniqueWords.length === 0) { return currentPool } // Проверяем, есть ли в пуле слова, отличные от исключаемого const hasOtherWords = uniqueWords.some(w => w.id !== excludeFirstWordId) const effectiveExcludeId = hasOtherWords ? excludeFirstWordId : null // Создаём массив всех экземпляров слов для распределения const allInstances = [] for (const word of uniqueWords) { const count = wordCounts[word.id] for (let i = 0; i < count; i++) { allInstances.push({ ...word }) } } // Перемешиваем экземпляры for (let i = allInstances.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[allInstances[i], allInstances[j]] = [allInstances[j], allInstances[i]] } // Используем жадный алгоритм: на каждую позицию выбираем слово, // которое максимально далеко от своего последнего появления const totalSlots = currentPool.length const newPool = new Array(totalSlots).fill(null) const lastPosition = {} // Последняя позиция каждого слова for (let pos = 0; pos < totalSlots; pos++) { let bestWord = null let bestWordIndex = -1 let bestDistance = -1 for (let i = 0; i < allInstances.length; i++) { const word = allInstances[i] // Для позиции 0: не выбираем исключаемое слово, если есть альтернативы if (pos === 0 && word.id === effectiveExcludeId) { // Проверяем, есть ли другие слова const hasAlternative = allInstances.some(w => w.id !== effectiveExcludeId) if (hasAlternative) { continue } } // Вычисляем расстояние от последнего появления этого слова const lastPos = lastPosition[word.id] const distance = lastPos === undefined ? totalSlots : (pos - lastPos) // Выбираем слово с максимальным расстоянием if (distance > bestDistance) { bestDistance = distance bestWord = word bestWordIndex = i } } if (bestWord !== null) { newPool[pos] = bestWord lastPosition[bestWord.id] = pos allInstances.splice(bestWordIndex, 1) } } // Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с ближайшим другим if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) { for (let i = 1; i < newPool.length; i++) { if (newPool[i] && newPool[i].id !== effectiveExcludeId) { ;[newPool[0], newPool[i]] = [newPool[i], newPool[0]] break } } } // Пост-обработка: исправляем последовательные дубликаты (одинаковые слова подряд) let iterations = 0 const maxIterations = totalSlots * 2 // Предотвращаем бесконечный цикл let hasConsecutiveDuplicates = true while (hasConsecutiveDuplicates && iterations < maxIterations) { hasConsecutiveDuplicates = false iterations++ for (let i = 0; i < newPool.length - 1; i++) { if (newPool[i] && newPool[i + 1] && newPool[i].id === newPool[i + 1].id) { // Нашли последовательные дубликаты на позициях i и i+1 // Ищем слово для обмена (не то же самое и не соседнее с дубликатом после обмена) let swapped = false for (let j = i + 2; j < newPool.length && !swapped; j++) { if (!newPool[j]) continue // Проверяем, что слово на позиции j отличается от дубликата if (newPool[j].id === newPool[i].id) continue // Проверяем, что после обмена не создадим новые дубликаты // Позиция j-1 (если существует) не должна иметь тот же id, что и newPool[i+1] // Позиция j+1 (если существует) не должна иметь тот же id, что и newPool[i+1] const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) { // Меняем местами ;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]] swapped = true hasConsecutiveDuplicates = true // Нужна ещё одна итерация для проверки } } // Если не нашли подходящую позицию справа, ищем слева if (!swapped) { for (let j = 0; j < i && !swapped; j++) { if (!newPool[j]) continue if (newPool[j].id === newPool[i].id) continue // Для позиции 0: не меняем на исключаемое слово if (j === 0 && newPool[i + 1].id === effectiveExcludeId) continue const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) { ;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]] swapped = true hasConsecutiveDuplicates = true } } } } } } // Ещё раз проверяем позицию 0 после всех обменов if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) { for (let i = 1; i < newPool.length; i++) { if (newPool[i] && newPool[i].id !== effectiveExcludeId) { // Проверяем, не создаст ли обмен дубликат на позиции 1 if (i === 1 || (newPool[1] && newPool[1].id !== newPool[i].id)) { ;[newPool[0], newPool[i]] = [newPool[i], newPool[0]] break } } } } // Заполняем null-позиции (не должно происходить, но на всякий случай) for (let i = 0; i < newPool.length; i++) { if (newPool[i] === null && currentPool[i]) { newPool[i] = currentPool[i] } } return newPool } // Загрузка слов при монтировании useEffect(() => { setWords([]) setTestWords([]) setCurrentWord(null) setFlippedCards(new Set()) setWordStats({}) wordStatsRef.current = {} setCardsShown(0) cardsShownRef.current = 0 // Сбрасываем синхронный счётчик setTotalAnswers(0) setError('') setShowPreview(false) // Сбрасываем экран предпросмотра setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста isFinishingRef.current = false processingRef.current = false setLoading(true) const loadWords = async () => { try { if (configId === null) { throw new Error('config_id обязателен для запуска теста') } const url = `${API_URL}/test/words?config_id=${configId}` const response = await authFetch(url) if (!response.ok) { throw new Error('Ошибка при загрузке слов') } const data = await response.json() if (!Array.isArray(data) || data.length === 0) { throw new Error('Недостаточно слов для теста') } // Инициализируем статистику из данных бэкенда const stats = {} data.forEach(word => { stats[word.id] = { success: word.success || 0, failure: word.failure || 0, lastSuccessAt: word.last_success_at || null, lastFailureAt: word.last_failure_at || null } }) setWords(data) // Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается // n = max(1, floor(0.7 * maxCards / количество_слов)) const wordsCount = data.length const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount)) // Создаем пул, где каждое слово повторяется n раз let wordPool = [] for (let i = 0; i < n; i++) { wordPool.push(...data) } // Равномерно распределяем слова в пуле wordPool = redistributeWordsEvenly(wordPool, data) setTestWords(wordPool) setWordStats(stats) wordStatsRef.current = stats // Показываем экран предпросмотра setShowPreview(true) // Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста) setCardsShown(0) } catch (err) { setError(err.message) } finally { setLoading(false) } } loadWords() }, [wordCount, configId]) // Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards const getRightCounter = () => { const total = testWords.length + cardsShown if (maxCards !== null && maxCards > 0) { return Math.min(total, maxCards) } return total } const handleCardFlip = (wordId) => { setFlippedCards(prev => { const newSet = new Set(prev) if (newSet.has(wordId)) { newSet.delete(wordId) } else { newSet.add(wordId) } return newSet }) } // Завершение теста const finishTest = async () => { if (isFinishingRef.current) return isFinishingRef.current = true // Сразу показываем экран результатов, чтобы предотвратить показ новых карточек setShowResults(true) // Отправляем статистику на бэкенд try { // Получаем актуальные данные из состояния const currentStats = wordStatsRef.current // Отправляем все слова, которые были в тесте, с их текущими значениями // Бэкенд сам обновит только измененные поля const updates = words.map(word => { const stats = currentStats[word.id] || { success: word.success || 0, failure: word.failure || 0, lastSuccessAt: word.last_success_at || null, lastFailureAt: word.last_failure_at || null } return { id: word.id, success: stats.success || 0, failure: stats.failure || 0, last_success_at: stats.lastSuccessAt || null, last_failure_at: stats.lastFailureAt || null } }) if (updates.length === 0) { console.log('No words to send - empty test') return } const requestBody = { words: updates } if (configId !== null) { requestBody.config_id = configId } console.log('Sending test progress to backend:', { wordsCount: updates.length, configId: configId, requestBody }) const response = await authFetch(`${API_URL}/test/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }) if (!response.ok) { const errorText = await response.text() throw new Error(`Server responded with status ${response.status}: ${errorText}`) } const responseData = await response.json().catch(() => ({})) console.log('Test progress saved successfully:', responseData) // Если есть taskId, выполняем задачу if (taskId) { try { const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) if (completeResponse.ok) { console.log('Task completed successfully') } else { console.error('Failed to complete task:', await completeResponse.text()) } } catch (taskErr) { console.error('Failed to complete task:', taskErr) } } } catch (err) { console.error('Failed to save progress:', err) // Можно показать уведомление пользователю, но не блокируем показ результатов } } // Берём карточку из пула (getAndDelete) и показываем её const showNextCard = () => { // Проверяем, не завершился ли тест if (isFinishingRef.current) { return } // Используем функциональное обновление для получения актуального состояния пула setTestWords(prevPool => { // Повторная проверка внутри callback (на случай если состояние изменилось) if (isFinishingRef.current) { return prevPool } // Используем ref для синхронного доступа к счётчику const nextCardsShown = cardsShownRef.current + 1 // Условие 1: Достигли максимума карточек if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) { finishTest() return prevPool } // Условие 2: Пул слов пуст if (prevPool.length === 0) { finishTest() return prevPool } // getAndDelete: берём слово из пула и удаляем его const nextWord = prevPool[0] // Условие 3: Первое слово в пуле null/undefined (не должно происходить, но на всякий случай) if (!nextWord) { // Ищем первое не-null слово в пуле const validWordIndex = prevPool.findIndex(w => w !== null && w !== undefined) if (validWordIndex === -1) { // Нет валидных слов - завершаем тест finishTest() return prevPool } // Берём валидное слово const validWord = prevPool[validWordIndex] const updatedPool = [...prevPool.slice(0, validWordIndex), ...prevPool.slice(validWordIndex + 1)] // Синхронно обновляем ref cardsShownRef.current = nextCardsShown setCurrentWord(validWord) setCardsShown(nextCardsShown) setFlippedCards(new Set()) return updatedPool } const updatedPool = prevPool.slice(1) // Синхронно обновляем ref ПЕРЕД установкой state cardsShownRef.current = nextCardsShown // showCard: показываем карточку setCurrentWord(nextWord) setCardsShown(nextCardsShown) setFlippedCards(new Set()) return updatedPool }) } const handleSuccess = (wordId) => { if (processingRef.current || isFinishingRef.current || showResults) return processingRef.current = true const word = words.find(w => w.id === wordId) if (!word) { processingRef.current = false return } const now = new Date().toISOString() // Обновляем статистику: success + 1, lastSuccessAt = now const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 } const updatedStats = { ...wordStatsRef.current, [wordId]: { success: (currentWordStats.success || 0) + 1, failure: currentWordStats.failure || 0, lastSuccessAt: now, lastFailureAt: currentWordStats.lastFailureAt || null } } wordStatsRef.current = updatedStats setWordStats(updatedStats) // Увеличиваем счётчик ответов const newTotalAnswers = totalAnswers + 1 setTotalAnswers(newTotalAnswers) // onSuccess: просто повторяем (showNextCard) // Карточка уже удалена из пула при показе, просто показываем следующую showNextCard() // Если тест завершился, не сбрасываем processingRef if (isFinishingRef.current) { return } processingRef.current = false } const handleFailure = (wordId) => { if (processingRef.current || isFinishingRef.current || showResults) return processingRef.current = true const word = words.find(w => w.id === wordId) if (!word) { processingRef.current = false return } const now = new Date().toISOString() // Обновляем статистику: failure + 1, lastFailureAt = now const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 } const updatedStats = { ...wordStatsRef.current, [wordId]: { success: currentWordStats.success || 0, failure: (currentWordStats.failure || 0) + 1, lastSuccessAt: currentWordStats.lastSuccessAt || null, lastFailureAt: now } } wordStatsRef.current = updatedStats setWordStats(updatedStats) // Увеличиваем счётчик ответов const newTotalAnswers = totalAnswers + 1 setTotalAnswers(newTotalAnswers) // onFailure: возвращаем карточку в пул, сортируем, повторяем setTestWords(prevPool => { // cards.add(currentCard): возвращаем слово обратно в пул let newTestWords = [...prevPool, word] // cards.sort(): равномерно перераспределяем слова в пуле // Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа) newTestWords = redistributeWordsEvenly(newTestWords, words, wordId) return newTestWords }) // repeat(): показываем следующую карточку showNextCard() // Если тест завершился, не сбрасываем processingRef if (isFinishingRef.current) { return } processingRef.current = false } const handleClose = () => { onNavigate?.('tasks') } const handleStartTest = () => { setShowPreview(false) // Показываем первую карточку (берём из пула) showNextCard() } const handleFinish = () => { onNavigate?.('tasks') } const getRandomSide = (word) => { return word.id % 2 === 0 ? 'word' : 'translation' } return (
{showPreview ? (
{words.map((word) => { const stats = wordStats[word.id] || { success: 0, failure: 0 } return (

{word.name}

{word.translation}
{stats.success} | {stats.failure}
) })}
) : showResults ? (
{words.map((word) => { const stats = wordStats[word.id] || { success: 0, failure: 0 } return (

{word.name}

{word.translation}
{stats.success} | {stats.failure}
) })}
) : (
{loading && (
Загрузка...
)} {error && ( { setError('') setLoading(true) // Перезагружаем слова const loadWords = async () => { try { if (configId === null) { throw new Error('config_id обязателен для запуска теста') } const url = `${API_URL}/test/words?config_id=${configId}` const response = await authFetch(url) if (!response.ok) { throw new Error('Ошибка при загрузке слов') } const data = await response.json() if (!Array.isArray(data) || data.length === 0) { throw new Error('Недостаточно слов для теста') } const stats = {} data.forEach(word => { stats[word.id] = { success: word.success || 0, failure: word.failure || 0, lastSuccessAt: word.last_success_at || null, lastFailureAt: word.last_failure_at || null } }) setWords(data) const wordsCount = data.length const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount)) let wordPool = [] for (let i = 0; i < n; i++) { wordPool.push(...data) } wordPool = redistributeWordsEvenly(wordPool, data) setTestWords(wordPool) setWordStats(stats) wordStatsRef.current = stats setShowPreview(true) setCardsShown(0) } catch (err) { setError(err.message) } finally { setLoading(false) } } loadWords() }} /> )} {!loading && !error && !isFinishingRef.current && currentWord && (() => { const word = currentWord const isFlipped = flippedCards.has(word.id) const showSide = getRandomSide(word) return (
handleCardFlip(word.id)} >
{showSide === 'word' ? (
{word.name}
) : (
{word.translation}
)}
{showSide === 'word' ? (
{word.translation}
) : (
{word.name}
)}
) })()} {!loading && !error && (
{cardsShown} / {getRightCounter()}
)}
)}
) } export default TestWords