// ======================================================================= // CEFR Level Testing & Automated Gap Ingestion Module (Native ES Module) // Oxford 3000 / 5000 Benchmark Testing Engine // ======================================================================= import { api } from './api.js'; import { showToast, escapeHtml } from './ui.js'; let currentLevel = 'A1'; let currentMode = 'all'; let currentQuiz = []; let currentQuestionIdx = 0; let isAnswered = false; const sessionStats = { total: 0, correct: 0, mistakes: 0, ingested: 0, }; // ----------------------------------------------------------------------- // Initializer // ----------------------------------------------------------------------- export function initCefr() { const backBtn = document.getElementById('cefr-back-btn'); const nextBtn = document.getElementById('cefr-next-btn'); const sumRepeatBtn = document.getElementById('cefr-sum-repeat-btn'); const sumLevelsBtn = document.getElementById('cefr-sum-levels-btn'); const sumSrsBtn = document.getElementById('cefr-sum-srs-btn'); if (backBtn) { backBtn.addEventListener('click', () => { showCefrScreen('overview'); window.location.hash = '#cefr'; loadCefrOverview(); }); } if (nextBtn) { nextBtn.addEventListener('click', () => { advanceQuestion(); }); } if (sumRepeatBtn) { sumRepeatBtn.addEventListener('click', () => { startCefrQuiz(currentLevel, currentMode); }); } if (sumLevelsBtn) { sumLevelsBtn.addEventListener('click', () => { showCefrScreen('overview'); window.location.hash = '#cefr'; loadCefrOverview(); }); } if (sumSrsBtn) { sumSrsBtn.addEventListener('click', () => { const levelTag = `level-${currentLevel.toLowerCase()}`; window.location.hash = `#srs/${encodeURIComponent(levelTag)}`; }); } // Keyboard navigation (1-4 for options, Space/Enter for next) window.addEventListener('keydown', (e) => { const cefrView = document.getElementById('cefr-view'); const quizScreen = document.getElementById('cefr-quiz-screen'); if (!cefrView || cefrView.classList.contains('hidden')) return; if (!quizScreen || quizScreen.classList.contains('hidden')) return; // Ignore if user is inside an input/textarea if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return; if (!isAnswered) { if (['1', '2', '3', '4'].includes(e.key)) { e.preventDefault(); const idx = parseInt(e.key, 10) - 1; const btns = document.querySelectorAll('.cefr-opt-btn'); if (btns[idx]) btns[idx].click(); } } else { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); advanceQuestion(); } } }); } // ----------------------------------------------------------------------- // Screen Switcher inside #cefr-view // ----------------------------------------------------------------------- function showCefrScreen(screenName) { const overview = document.getElementById('cefr-overview-screen'); const quiz = document.getElementById('cefr-quiz-screen'); const summary = document.getElementById('cefr-summary-screen'); if (overview) overview.classList.toggle('hidden', screenName !== 'overview'); if (quiz) quiz.classList.toggle('hidden', screenName !== 'quiz'); if (summary) summary.classList.toggle('hidden', screenName !== 'summary'); } // ----------------------------------------------------------------------- // Load & Render Overview // ----------------------------------------------------------------------- export async function loadCefrOverview() { showCefrScreen('overview'); const grid = document.getElementById('cefr-levels-grid'); if (!grid) return; grid.innerHTML = '
Загрузка уровней...
'; try { const data = await api.cefr.getLevels(); const levels = data.levels || []; renderLevelsGrid(levels); } catch (err) { grid.innerHTML = `

Ошибка загрузки: ${escapeHtml(err.message)}

`; } } function renderLevelsGrid(levels) { const grid = document.getElementById('cefr-levels-grid'); if (!grid) return; if (!levels.length) { grid.innerHTML = '

Нет доступных уровней

'; return; } grid.innerHTML = levels.map(lvl => { const pct = lvl.progress_percent || 0; const hasMistakes = lvl.in_learning_count > 0; const colorClass = `level-badge-${lvl.level.toLowerCase()}`; return `
${escapeHtml(lvl.level)}

${escapeHtml(lvl.title)}

${lvl.total_questions} эталонных слов
${pct}% освоено ${lvl.mastered_count} / ${lvl.total_questions}
✓ ${lvl.mastered_count} Освоено
⚠️ ${lvl.in_learning_count} Ошибок
🎓 ${lvl.srs_cards_count} В словаре SRS
${hasMistakes ? ` ` : ''}
`; }).join(''); // Attach button listeners grid.querySelectorAll('.cefr-start-btn').forEach(btn => { btn.addEventListener('click', () => { const lvl = btn.dataset.level; const mode = btn.dataset.mode || 'all'; window.location.hash = `#cefr/${encodeURIComponent(lvl)}`; startCefrQuiz(lvl, mode); }); }); grid.querySelectorAll('.cefr-mistakes-btn').forEach(btn => { btn.addEventListener('click', () => { const lvl = btn.dataset.level; window.location.hash = `#cefr/${encodeURIComponent(lvl)}/mistakes`; startCefrQuiz(lvl, 'mistakes'); }); }); grid.querySelectorAll('.cefr-reset-btn').forEach(btn => { btn.addEventListener('click', async () => { const lvl = btn.dataset.level; if (confirm(`Вы действительно хотите сбросить прогресс прохождения уровня ${lvl}?`)) { try { await api.cefr.resetLevel(lvl); showToast(`Прогресс уровня ${lvl} сброшен`, 'info'); loadCefrOverview(); } catch (e) { showToast('Ошибка сброса прогресса', 'error'); } } }); }); } // ----------------------------------------------------------------------- // Start Quiz Session // ----------------------------------------------------------------------- export async function startCefrQuiz(level = 'A1', mode = 'all') { currentLevel = level.toUpperCase(); currentMode = mode; currentQuestionIdx = 0; isAnswered = false; sessionStats.total = 0; sessionStats.correct = 0; sessionStats.mistakes = 0; sessionStats.ingested = 0; showCefrScreen('quiz'); const cardContainer = document.getElementById('cefr-cloze-card'); if (cardContainer) { cardContainer.innerHTML = '
Загрузка теста...
'; } try { const resp = await api.cefr.getQuiz(currentLevel, 10, currentMode); currentQuiz = resp.quiz || []; sessionStats.total = currentQuiz.length; if (!currentQuiz.length) { if (currentMode === 'mistakes') { showToast(`На уровне ${currentLevel} нет неисправленных ошибок! 🏆`, 'success'); showCefrScreen('overview'); loadCefrOverview(); return; } showToast('Нет доступных вопросов для этого уровня', 'warning'); showCefrScreen('overview'); loadCefrOverview(); return; } renderCurrentQuestion(); } catch (err) { showToast(`Ошибка загрузки вопросов: ${err.message}`, 'error'); showCefrScreen('overview'); loadCefrOverview(); } } // ----------------------------------------------------------------------- // Render Active Question // ----------------------------------------------------------------------- function renderCurrentQuestion() { if (currentQuestionIdx >= currentQuiz.length) { renderQuizSummary(); return; } isAnswered = false; const q = currentQuiz[currentQuestionIdx]; // Header info const levelBadge = document.getElementById('cefr-quiz-level-badge'); const counterBadge = document.getElementById('cefr-quiz-counter'); const progressBar = document.getElementById('cefr-quiz-progress-bar'); const nextBar = document.getElementById('cefr-next-bar'); if (levelBadge) { levelBadge.textContent = `Уровень ${q.level}`; levelBadge.className = `level-pill level-badge-${q.level.toLowerCase()}`; } if (counterBadge) { counterBadge.textContent = `${currentQuestionIdx + 1} / ${currentQuiz.length}`; } if (progressBar) { const pct = ((currentQuestionIdx) / currentQuiz.length) * 100; progressBar.style.width = `${pct}%`; } if (nextBar) nextBar.classList.add('hidden'); const cardContainer = document.getElementById('cefr-cloze-card'); if (!cardContainer) return; // Stylize the sentence blank: replace ___ with a visible blank node const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '_____'); cardContainer.innerHTML = `
🧩 Cloze Tap (1-тап выбор) ${q.pos ? `${escapeHtml(q.pos)}` : ''}

${formattedSentence}

💡 Значение: ${escapeHtml(q.clue)}
${q.options.map((opt, idx) => ` `).join('')}
`; // Attach click listeners to option buttons cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => { btn.addEventListener('click', () => { const opt = btn.dataset.option; handleOptionTap(q.id, opt, btn); }); }); } // ----------------------------------------------------------------------- // Handle Option Selection (1-tap Cloze Tap) // ----------------------------------------------------------------------- async function handleOptionTap(questionId, selectedOption, clickedBtn) { if (isAnswered) return; isAnswered = true; const q = currentQuiz[currentQuestionIdx]; const sentence = q ? q.sentence : ''; // Disable all options document.querySelectorAll('.cefr-opt-btn').forEach(b => { b.disabled = true; }); const feedbackBox = document.getElementById('cefr-feedback-box'); const nextBar = document.getElementById('cefr-next-bar'); const blankTarget = document.getElementById('cefr-blank-target'); try { const res = await api.cefr.submitAnswer(questionId, selectedOption); if (res.correct) { sessionStats.correct++; clickedBtn.classList.add('opt-correct'); if (blankTarget) { blankTarget.textContent = res.correct_option; blankTarget.classList.add('blank-correct'); } if (feedbackBox) { feedbackBox.className = 'cefr-feedback-box feedback-correct'; feedbackBox.innerHTML = `
✓ Верно! Отличный выбор.
${escapeHtml(res.correct_option)} ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''}
${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''} ${renderOptionsBreakdown(res.options_breakdown)} `; feedbackBox.classList.remove('hidden'); attachBreakdownListeners(feedbackBox, sentence); } } else { sessionStats.mistakes++; // Two words were ingested: the correct target word and the clicked distractor sessionStats.ingested += 2; clickedBtn.classList.add('opt-wrong'); // Highlight the true correct option document.querySelectorAll('.cefr-opt-btn').forEach(b => { if (b.dataset.option.toLowerCase() === res.correct_option.toLowerCase()) { b.classList.add('opt-correct-revealed'); } }); if (blankTarget) { blankTarget.textContent = res.correct_option; blankTarget.classList.add('blank-wrong-revealed'); } if (feedbackBox) { feedbackBox.className = 'cefr-feedback-box feedback-wrong'; feedbackBox.innerHTML = `
✗ Неверно. Правильное слово: ${escapeHtml(res.correct_option)}
${escapeHtml(res.translation || '')} ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''}
${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''}
📥 В ваш словарь SRS («В процессе») добавлены оба слова:
Верный ответ: ${escapeHtml(res.correct_option)} — ${escapeHtml(res.translation || '')}
Ваш выбор: ${escapeHtml(res.selected_option)} — ${escapeHtml(res.selected_translation || '')}
${renderOptionsBreakdown(res.options_breakdown)} `; feedbackBox.classList.remove('hidden'); attachBreakdownListeners(feedbackBox, sentence); } } if (nextBar) nextBar.classList.remove('hidden'); } catch (err) { showToast(`Ошибка проверки ответа: ${err.message}`, 'error'); if (nextBar) nextBar.classList.remove('hidden'); } } // ----------------------------------------------------------------------- // Advance to Next Question // ----------------------------------------------------------------------- function advanceQuestion() { currentQuestionIdx++; renderCurrentQuestion(); } // ----------------------------------------------------------------------- // Render Quiz Summary // ----------------------------------------------------------------------- function renderQuizSummary() { showCefrScreen('summary'); const total = sessionStats.total || currentQuiz.length || 1; const correct = sessionStats.correct; const mistakes = sessionStats.mistakes; const ingested = sessionStats.ingested; const pct = Math.round((correct / total) * 100); const sumScore = document.getElementById('cefr-sum-score'); const sumLead = document.getElementById('cefr-sum-lead'); const sumCorrect = document.getElementById('cefr-sum-correct'); const sumMistakes = document.getElementById('cefr-sum-mistakes'); const sumIngested = document.getElementById('cefr-sum-ingested'); const sumSrsBtn = document.getElementById('cefr-sum-srs-btn'); if (sumScore) sumScore.textContent = `${pct}%`; if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`; if (sumMistakes) sumMistakes.textContent = mistakes; if (sumIngested) sumIngested.textContent = ingested; if (sumLead) { if (pct >= 90) { sumLead.textContent = `Великолепно! Уровень ${currentLevel} освоен на высочайшем уровне! 🌟`; } else if (pct >= 70) { sumLead.textContent = `Хороший результат! Большинство ключевых слов уровня ${currentLevel} вам знакомы. 👍`; } else { sumLead.textContent = `Тренировка завершена. Все ошибки бережно сохранены в ваш SRS для закрепления. 💪`; } } if (sumSrsBtn) { sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0); } } // ----------------------------------------------------------------------- // Render Options Breakdown (All 4 options with 1-tap SRS Ingestion) // ----------------------------------------------------------------------- function renderOptionsBreakdown(breakdown) { if (!breakdown || breakdown.length === 0) return ''; return `
Слова из этого вопроса: Добавьте в словарь слова, перевод которых хотите запомнить
${breakdown.map(b => { const isTarget = b.is_correct; const wasChosenMistake = b.was_selected && !b.is_correct; const inLib = b.in_library || b.auto_added; let actionHtml = ''; if (b.auto_added) { actionHtml = ` В словаре (SRS)`; } else if (inLib) { actionHtml = ` В библиотеке`; } else { actionHtml = ``; } return `
${escapeHtml(b.term)} ${b.pos ? `${escapeHtml(b.pos)}` : ''}
${escapeHtml(b.translation || '—')}
${actionHtml}
`; }).join('')}
`; } function attachBreakdownListeners(container, contextSentence) { if (!container) return; container.querySelectorAll('.btn-add-option-word').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const term = btn.dataset.term; if (!term) return; btn.disabled = true; btn.innerHTML = '...'; try { await api.cefr.addWord(currentLevel, term, contextSentence || ''); sessionStats.ingested++; btn.outerHTML = ' Добавлено!'; showToast(`Слово «${term}» добавлено в утренний тренажёр SRS!`, 'success'); } catch (err) { btn.disabled = false; btn.textContent = '+ В словарь'; showToast(`Ошибка: ${err.message}`, 'error'); } }); }); }