import React, { useState, useEffect, useRef, useCallback } 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 [isClosing, setIsClosing] = useState(false) // Ожидание завершения сохранения при нажатии «Закончить» const isFinishingRef = useRef(false) const wordStatsRef = useRef({}) const processingRef = useRef(false) const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition const saveProgressPromiseRef = useRef(null) // Промис сохранения прогресса (null = ещё не запущено) const savePayloadRef = useRef(null) // Данные для сохранения при нажатии «Закончить» (если сохранение ещё не запускали) const [currentSide, setCurrentSide] = useState(null) // Текущая случайная сторона карточки ('word' или 'translation') // Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами // 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) setCurrentSide(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 saveProgressPromiseRef.current = null savePayloadRef.current = null 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 }) } // Завершение теста: показываем результаты сразу, сохранение на бэкенде идёт в фоне (промис в ref для ожидания в handleFinish) const finishTest = () => { if (isFinishingRef.current) return isFinishingRef.current = true 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) { saveProgressPromiseRef.current = Promise.resolve() savePayloadRef.current = null setShowResults(true) return } const requestBody = { words: updates } if (configId !== null) { requestBody.config_id = configId } savePayloadRef.current = { requestBody, configId, taskId } const doSave = async () => { try { 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}`) } await response.json().catch(() => ({})) 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.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) } } const promise = doSave() saveProgressPromiseRef.current = promise setShowResults(true) } // Берём карточку из пула (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) setCurrentSide(Math.random() < 0.5 ? 'word' : 'translation') setCardsShown(nextCardsShown) setFlippedCards(new Set()) return updatedPool } const updatedPool = prevPool.slice(1) // Синхронно обновляем ref ПЕРЕД установкой state cardsShownRef.current = nextCardsShown // showCard: показываем карточку setCurrentWord(nextWord) setCurrentSide(Math.random() < 0.5 ? 'word' : 'translation') 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 = () => { window.history.back() } const handleStartTest = () => { setShowPreview(false) // Показываем первую карточку (берём из пула) showNextCard() } const runSaveFromPayload = useCallback(async (payload) => { const { requestBody, taskId: payloadTaskId } = payload const progressRes = await authFetch(`${API_URL}/test/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }) if (!progressRes.ok) { const text = await progressRes.text() throw new Error(`Progress save failed: ${progressRes.status} ${text}`) } if (payloadTaskId) { const completeRes = await authFetch(`${API_URL}/tasks/${payloadTaskId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) if (!completeRes.ok) { const text = await completeRes.text() throw new Error(`Task complete failed: ${completeRes.status} ${text}`) } } }, [authFetch]) const handleFinish = async () => { if (isClosing) return setIsClosing(true) try { let promise = saveProgressPromiseRef.current if (promise == null && savePayloadRef.current) { promise = runSaveFromPayload(savePayloadRef.current) saveProgressPromiseRef.current = promise } await (promise ?? Promise.resolve()) onNavigate?.('tasks') } catch (err) { console.error('Failed to save before close:', err) } finally { setIsClosing(false) } } return (