Files
play-life/play-life-web/src/components/TestWords.jsx
poignatov db3b2640a8
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
Рефакторинг тестов: интеграция с задачами
2026-01-13 18:22:02 +03:00

773 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="test-container test-container-fullscreen">
<button className="close-x-button" onClick={handleClose}>
</button>
{showPreview ? (
<div className="test-preview">
<div className="preview-stats">
{words.map((word) => {
const stats = wordStats[word.id] || { success: 0, failure: 0 }
return (
<div key={word.id} className="preview-item">
<div className="preview-word-content">
<div className="preview-word-header">
<h3 className="preview-word">{word.name}</h3>
</div>
<div className="preview-translation">{word.translation}</div>
</div>
<div className="preview-stats-numbers">
<span className="preview-stat-success">{stats.success}</span>
<span className="preview-stat-separator"> | </span>
<span className="preview-stat-failure">{stats.failure}</span>
</div>
</div>
)
})}
</div>
<div className="preview-actions">
<button className="test-start-button" onClick={handleStartTest}>
Начать
</button>
</div>
</div>
) : showResults ? (
<div className="test-results">
<div className="results-stats">
{words.map((word) => {
const stats = wordStats[word.id] || { success: 0, failure: 0 }
return (
<div key={word.id} className="result-item">
<div className="result-word-content">
<div className="result-word-header">
<h3 className="result-word">{word.name}</h3>
</div>
<div className="result-translation">{word.translation}</div>
</div>
<div className="result-stats">
<span className="result-stat-success">{stats.success}</span>
<span className="result-stat-separator"> | </span>
<span className="result-stat-failure">{stats.failure}</span>
</div>
</div>
)
})}
</div>
<div className="results-actions">
<button className="test-finish-button" onClick={handleFinish}>
Закончить
</button>
</div>
</div>
) : (
<div className="test-screen">
{loading && (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
)}
{error && (
<LoadingError onRetry={() => {
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 (
<div className="test-card-container" key={word.id}>
<div
className={`test-card ${isFlipped ? 'flipped' : ''}`}
onClick={() => handleCardFlip(word.id)}
>
<div className="test-card-front">
<div className="test-card-content">
{showSide === 'word' ? (
<div className="test-word">{word.name}</div>
) : (
<div className="test-translation">{word.translation}</div>
)}
</div>
</div>
<div className="test-card-back">
<div className="test-card-content">
{showSide === 'word' ? (
<div className="test-translation">{word.translation}</div>
) : (
<div className="test-word">{word.name}</div>
)}
<div className="test-card-actions">
<button
className="test-action-button success-button"
onClick={(e) => {
e.stopPropagation()
handleSuccess(word.id)
}}
>
Знаю
</button>
<button
className="test-action-button failure-button"
onClick={(e) => {
e.stopPropagation()
handleFailure(word.id)
}}
>
Не знаю
</button>
</div>
</div>
</div>
</div>
</div>
)
})()}
{!loading && !error && (
<div className="test-progress">
<div className="progress-text">
{cardsShown} / {getRightCounter()}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default TestWords