/** * Прохождение теста: один вопрос на экран, прогресс сверху. */ (() => { const root = document.getElementById('attempt-root'); if (!root) return; const testId = root.dataset.testId; const attemptId = root.dataset.attemptId; const flowEl = document.getElementById('attempt-flow'); const titleEl = document.getElementById('attempt-title'); const subEl = document.getElementById('attempt-subtitle'); const timerEl = document.getElementById('attempt-timer'); const errEl = document.getElementById('attempt-error'); const stageEl = document.getElementById('attempt-stage'); const progressLabel = document.getElementById('attempt-progress-label'); const progressFill = document.getElementById('attempt-progress-fill'); const progressTrack = document.querySelector('.attempt-progress-track'); const btnPrev = document.getElementById('attempt-prev'); const btnNext = document.getElementById('attempt-next'); const btnFinish = document.getElementById('attempt-finish'); const resultEl = document.getElementById('attempt-result'); let playData = null; const selections = {}; const checked = {}; let timerHandle = null; let deadlineMs = null; let currentIdx = 0; function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[m])); } /** Доля круга 0..1 от верха по часовой; путь для stroke с round caps (viewBox 120). */ function attemptResultArcD(cx, cy, r, t0, t1) { if (t1 <= t0 + 1e-9) return ''; if (t1 - t0 > 0.5 + 1e-6) { const a = attemptResultArcD(cx, cy, r, t0, t0 + 0.5); const b = attemptResultArcD(cx, cy, r, t0 + 0.5, t1); return `${a} ${b.replace(/^M [\d.]+\s+[\d.]+\s+/, '')}`; } const angle = (t) => (-Math.PI / 2) + 2 * Math.PI * t; const x0 = cx + r * Math.cos(angle(t0)); const y0 = cy + r * Math.sin(angle(t0)); const x1 = cx + r * Math.cos(angle(t1)); const y1 = cy + r * Math.sin(angle(t1)); const spanDeg = (t1 - t0) * 360; const largeArc = spanDeg > 180 ? 1 : 0; return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${largeArc} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; } function setErr(msg) { errEl.textContent = msg || 'Ошибка.'; errEl.style.display = msg ? 'block' : 'none'; } function isSelected(qid, oid) { return (selections[String(qid)] || []).includes(String(oid)); } function toggle(qid, oid, multi) { const k = String(qid); const cur = selections[k] || []; const id = String(oid); if (multi) { selections[k] = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; return; } selections[k] = [id]; } function isImmediate() { return playData && playData.resultMode === 'immediate'; } function formatAttemptMeta(d) { const qs = d.questions || []; const n = qs.length; const tl = d.timeLimit; const timestr = !tl || Number(tl) <= 0 ? 'без ограничения' : `${tl} мин`; const res = d.resultMode === 'immediate' ? 'сразу' : 'в конце'; const hint = d.resultMode !== 'immediate' ? 'недоступны' : (d.hintsEnabled ? 'вкл' : 'выкл'); const th = d.passingThreshold ?? 0; return `Порог: ${th}% · Вопросов: ${n} · Время: ${timestr} · Результат: ${res} · Подсказки: ${hint}`; } function renderFeedbackPanel(data) { const wrap = document.createElement('div'); wrap.className = 'attempt-feedback-panel'; const ok = data.isCorrect; const verdict = document.createElement('p'); verdict.className = `attempt-feedback-verdict ${ok ? 'attempt-feedback-verdict--ok' : 'attempt-feedback-verdict--bad'}`; verdict.textContent = ok ? 'Верно!' : 'Неверно.'; wrap.appendChild(verdict); const correct = (data.correctOptionTexts || []).join('; '); if (correct) { const p = document.createElement('p'); p.className = 'attempt-feedback-correct'; p.textContent = `Правильный ответ: ${correct}`; wrap.appendChild(p); } const exp = document.createElement('p'); exp.className = 'attempt-feedback-explanation'; exp.textContent = data.explanation || 'Объяснение недоступно.'; wrap.appendChild(exp); return wrap; } function stepAnswered(q) { const k = String(q.id); if (isImmediate()) return !!checked[k]; return (selections[k] || []).length > 0; } function allAnswered() { return (playData.questions || []).every((q) => stepAnswered(q)); } function updateProgress() { const qs = playData.questions || []; const total = qs.length; const n = Math.min(currentIdx + 1, Math.max(total, 1)); if (progressLabel) progressLabel.textContent = `${n} из ${total}`; const pct = total <= 1 ? 100 : ((currentIdx + 1) / total) * 100; if (progressFill) progressFill.style.width = `${pct}%`; if (progressTrack) { progressTrack.setAttribute('aria-valuenow', String(n)); progressTrack.setAttribute('aria-valuemax', String(total)); } } function renderStep() { const qs = playData.questions || []; const total = qs.length; const q = qs[currentIdx]; stageEl.innerHTML = ''; if (!q) return; updateProgress(); const card = document.createElement('article'); card.className = 'attempt-q-card surface-card'; card.dataset.qid = String(q.id); const qid = String(q.id); const isChk = !!checked[qid]; const meta = document.createElement('div'); meta.className = 'attempt-q-card__meta'; meta.innerHTML = `Вопрос ${currentIdx + 1}`; card.appendChild(meta); const textP = document.createElement('div'); textP.className = 'attempt-q-text'; let badge = ''; if (isChk) { const ok = checked[qid].isCorrect; badge = `${ok ? '✓ верно' : '✗ неверно'}`; } textP.innerHTML = esc(q.text) + badge; card.appendChild(textP); const ul = document.createElement('ul'); ul.className = 'attempt-q-options'; const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []); for (const o of (q.options || [])) { const oid = String(o.id); const row = document.createElement('li'); row.className = 'attempt-opt-row'; const type = q.hasMultipleAnswers ? 'checkbox' : 'radio'; const name = `q-${q.id}`; let mark = ''; if (isChk) { if (correctSet.has(oid)) mark = ' верно'; else if (isSelected(q.id, o.id)) mark = ' ваш ответ'; } row.innerHTML = `'; const input = row.querySelector('input'); if (isSelected(q.id, o.id)) input.checked = true; if (isChk) input.disabled = true; input.addEventListener('change', () => { if (checked[qid]) return; toggle(q.id, o.id, q.hasMultipleAnswers); renderStep(); }); ul.appendChild(row); } card.appendChild(ul); if (isChk && isImmediate() && playData.hintsEnabled) { card.appendChild(renderFeedbackPanel(checked[qid])); } if (isImmediate() && !isChk) { const wrap = document.createElement('div'); wrap.className = 'attempt-answer-actions'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-primary attempt-reply-btn'; btn.textContent = 'Ответить'; const sel = selections[qid] || []; btn.disabled = sel.length === 0; btn.addEventListener('click', () => checkOne(q.id)); wrap.appendChild(btn); card.appendChild(wrap); } stageEl.appendChild(card); updateNav(); } function updateNav() { const qs = playData.questions || []; const total = qs.length; const q = qs[currentIdx]; if (!q) return; const ok = stepAnswered(q); btnPrev.disabled = currentIdx <= 0; btnPrev.toggleAttribute('hidden', currentIdx <= 0); const last = currentIdx >= total - 1; btnNext.style.display = last ? 'none' : ''; btnFinish.style.display = last ? '' : 'none'; if (last) { btnFinish.disabled = !allAnswered(); } else { btnNext.disabled = !ok; } } async function checkOne(qid) { const k = String(qid); const sel = selections[k] || []; if (!sel.length) return; try { const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }), }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.'); checked[k] = data; renderStep(); } catch (e) { setErr(e.message); } } btnPrev.addEventListener('click', () => { if (currentIdx <= 0) return; currentIdx -= 1; renderStep(); }); btnNext.addEventListener('click', () => { const total = (playData.questions || []).length; if (currentIdx >= total - 1) return; currentIdx += 1; renderStep(); }); btnFinish.addEventListener('click', () => submit(false)); function startTimer(minutes) { if (!timerEl) return; timerEl.style.display = ''; if (!minutes || minutes <= 0) { deadlineMs = null; timerEl.textContent = 'без ограничения'; return; } deadlineMs = Date.now() + minutes * 60 * 1000; const tick = () => { const left = Math.max(0, deadlineMs - Date.now()); const m = Math.floor(left / 60000); const s = Math.floor((left % 60000) / 1000); timerEl.textContent = `Осталось ${m}:${String(s).padStart(2, '0')}`; if (left <= 0) { clearInterval(timerHandle); submit(true); } }; tick(); timerHandle = setInterval(tick, 500); } async function load() { try { const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/play`); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.'); playData = data; titleEl.textContent = data.testTitle || 'Прохождение теста'; subEl.textContent = formatAttemptMeta(data); if (!Array.isArray(data.questions) || !data.questions.length) { setErr('В активной версии нет вопросов.'); btnNext.disabled = true; btnFinish.disabled = true; return; } currentIdx = 0; renderStep(); startTimer(Number(data.timeLimit)); } catch (e) { setErr(e.message); btnFinish.disabled = true; } } async function submit(auto) { btnFinish.disabled = true; btnNext.disabled = true; btnPrev.disabled = true; const label = auto ? 'Время вышло…' : 'Отправка…'; btnFinish.textContent = label; try { const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answers: selections }), }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.'); if (timerHandle) clearInterval(timerHandle); timerEl.style.display = 'none'; if (flowEl) flowEl.style.display = 'none'; resultEl.style.display = ''; const pct = Math.min(100, Math.max(0, Number(data.percent) || 0)); const thr = Math.min(100, Math.max(0, Number(data.passingThreshold) || 0)); const passed = !!data.passed; const thrArc = passed ? pct : Math.min(100, Math.max(pct, thr)); const centerIcon = passed ? 'check' : 'close'; const verdictLabel = passed ? 'Зачёт' : 'Незачёт'; const ariaRing = passed ? `Зачёт: ${pct} процентов верных ответов` : `Незачёт: ${pct} процентов, порог ${thr} процентов`; const CX = 60; const CY = 60; const RAD = 48; const tScore = pct / 100; const tThr = thrArc / 100; const trackCircle = ``; let arcPaths = ''; if (passed) { const dPass = attemptResultArcD(CX, CY, RAD, 0, tScore); if (dPass) { arcPaths += ``; } } else { const dGap = tThr > tScore + 1e-9 ? attemptResultArcD(CX, CY, RAD, tScore, tThr) : ''; const dScore = tScore > 1e-9 ? attemptResultArcD(CX, CY, RAD, 0, tScore) : ''; if (dGap) arcPaths += ``; if (dScore) arcPaths += ``; } const donutSvg = ``; resultEl.innerHTML = `

Результат

${donutSvg}

${verdictLabel}

  • Верно ${data.correctCount} из ${data.totalQuestions} (${pct}%)
  • Порог зачёта ${thr}%

Разбор попытки

`; } catch (e) { setErr(e.message); btnFinish.disabled = false; btnFinish.textContent = 'Завершить тест'; btnPrev.disabled = false; btnNext.disabled = false; if (playData) updateNav(); } } load(); })();