You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

405 lines
15 KiB

/**
* Прохождение теста: один вопрос на экран, прогресс сверху.
*/
(() => {
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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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 = `<span class="attempt-q-num">Вопрос ${currentIdx + 1}</span>`;
card.appendChild(meta);
const textP = document.createElement('div');
textP.className = 'attempt-q-text';
let badge = '';
if (isChk) {
const ok = checked[qid].isCorrect;
badge = `<span class="attempt-q-badge ${ok ? 'attempt-q-badge--ok' : 'attempt-q-badge--bad'}">${ok ? '✓ верно' : '✗ неверно'}</span>`;
}
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 = ' <span class="attempt-mark attempt-mark--ok">верно</span>';
else if (isSelected(q.id, o.id)) mark = ' <span class="attempt-mark attempt-mark--bad">ваш ответ</span>';
}
row.innerHTML =
`<label class="attempt-opt-label ${isChk ? 'attempt-opt-label--locked' : ''}">`
+ `<input type="${type}" ${q.hasMultipleAnswers ? '' : `name="${esc(name)}"`} class="attempt-opt-input" />`
+ `<span class="attempt-opt-text">${esc(o.text)}${mark}</span>`
+ '</label>';
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 = `<circle class="attempt-result-svg__track" cx="${CX}" cy="${CY}" r="${RAD}" fill="none" />`;
let arcPaths = '';
if (passed) {
const dPass = attemptResultArcD(CX, CY, RAD, 0, tScore);
if (dPass) {
arcPaths += `<path class="attempt-result-svg__pass" d="${esc(dPass)}" fill="none" />`;
}
} 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 += `<path class="attempt-result-svg__fail-gap" d="${esc(dGap)}" fill="none" />`;
if (dScore) arcPaths += `<path class="attempt-result-svg__fail-score" d="${esc(dScore)}" fill="none" />`;
}
const donutSvg = `<svg class="attempt-result-donut__svg" viewBox="0 0 120 120" aria-hidden="true">${trackCircle}${arcPaths}</svg>`;
resultEl.innerHTML = `
<div class="attempt-result-card__inner">
<h3 class="attempt-result-title font-headline">Результат</h3>
<div class="attempt-result-visual" aria-label="${ariaRing.replace(/"/g, '&quot;')}">
<div class="attempt-result-donut">
${donutSvg}
<div class="attempt-result-donut__disk" aria-hidden="true"></div>
<div class="attempt-result-donut__center" data-passed="${passed ? '1' : '0'}">
<span class="material-symbols-outlined attempt-result-donut__icon" aria-hidden="true">${centerIcon}</span>
</div>
</div>
<p class="attempt-result-verdict" data-passed="${passed ? '1' : '0'}">
<span class="attempt-result-verdict__label">${verdictLabel}</span>
</p>
</div>
<ul class="attempt-result-stats">
<li><span class="attempt-result-stats__k">Верно</span> <strong>${data.correctCount}</strong> из ${data.totalQuestions} (${pct}%)</li>
<li><span class="attempt-result-stats__k">Порог зачёта</span> <strong>${thr}%</strong></li>
</ul>
<p class="attempt-result-actions">
<a class="btn btn-primary attempt-result-review-link"
href="/tests/${testId}/attempts/${data.attemptId}/review">Разбор попытки</a>
</p>
</div>`;
} catch (e) {
setErr(e.message);
btnFinish.disabled = false;
btnFinish.textContent = 'Завершить тест';
btnPrev.disabled = false;
btnNext.disabled = false;
if (playData) updateNav();
}
}
load();
})();