Files
Maxim Dolgolyov 31a51956b6 feat: exam9 — назначение варианта как ДЗ + импорт нечётных в банк
Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов:
- 400 questions с allow_html=1, source_type='экзамен 9', year=2025
- 540 options (single-choice) + correct_text (short_answer)
- 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N"
- exam9_variant_tests маппинг для назначения

Назначение варианта как ДЗ на /exam9 (для учителей/админов):
- Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть)
- Модалка выбора классов + опциональный deadline
- POST /api/assignments/bulk с test_id из exam9_variant_tests

Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html:
- Миграция 003: ALTER TABLE questions ADD COLUMN allow_html
- sessionController: SELECT возвращают allow_html и image
- test-run.html: рендер q.text и opt.text как HTML при allow_html=1
- test-result.html: то же для explanation и opt.text
- KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах

Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт
class_ids (array). Существующий вызов из classes.html был сломан;
исправлено вместе.

Команда: node backend/scripts/import-exam9.js  (--all для всех 80)
2026-05-16 13:13:06 +03:00

838 lines
45 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Тест — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
onload="window._katexReady = true; if(window._katexReadyCb) window._katexReadyCb()"></script>
<style>
.nav-timer { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 700; color: var(--text); }
.nav-timer.warning { color: var(--pink); }
/* ── layout ── */
.layout { display: grid; grid-template-columns: 260px 1fr; gap: 24px; max-width: 1100px; margin: 0 auto; padding: 28px 20px 60px; }
@media (max-width: 768px) {
.layout { grid-template-columns: 1fr; padding: 16px 14px 60px; }
.sidebar { order: 2; }
.q-card { padding: 20px 18px; }
.q-text { font-size: 0.95rem; }
.confirm-box { padding: 28px 20px; }
.confirm-actions { flex-wrap: wrap; }
.confirm-cancel, .confirm-ok { flex: 1; }
}
@media (max-width: 480px) {
.layout { padding: 12px 10px 60px; gap: 14px; }
.q-card { padding: 16px 14px; }
.dot { width: 24px; height: 24px; font-size: 0.64rem; border-radius: 6px; }
.q-opt { padding: 10px 12px; gap: 10px; }
.q-text { font-size: 0.9rem; }
.q-match-row { flex-direction: column; gap: 8px; }
.q-match-sel { width: 100%; }
}
/* ── sidebar ── */
.sidebar { display: flex; flex-direction: column; gap: 16px; }
.card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 20px; box-shadow: var(--shadow); }
.card-label { font-size: 0.75rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
.progress-bar { height: 6px; background: var(--border); border-radius: 99px; overflow: hidden; margin-bottom: 8px; }
.progress-fill { height: 100%; background: var(--grad-1); border-radius: 99px; transition: width 0.4s ease; }
.progress-text { font-size: 0.82rem; color: var(--text-2); }
.dots { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.dot { width: 28px; height: 28px; border-radius: 8px; border: 1.5px solid var(--border-h); background: transparent; font-size: 0.7rem; font-weight: 700; color: var(--text-3); cursor: pointer; transition: all var(--tr); display: flex; align-items: center; justify-content: center; }
.dot.answered { background: rgba(6,214,224,0.12); border-color: var(--cyan); color: var(--text); }
.dot.current { background: var(--violet); border-color: var(--violet); color: #fff; }
.btn-finish { width: 100%; padding: 13px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 700; cursor: pointer; transition: transform var(--tr), box-shadow var(--tr); }
.btn-finish:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(6,214,224,0.4); }
.btn-finish:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* ── question ── */
.q-area { display: flex; flex-direction: column; gap: 0; }
.q-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px 32px; box-shadow: var(--shadow); display: none; }
.q-card.active { display: block; }
.q-num-badge { display: inline-flex; align-items: center; gap: 8px; font-size: 0.78rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 16px; }
.q-num-badge span { background: var(--grad-1); color: #fff; padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.72rem; }
.q-type-hint { display: inline-block; font-size: 0.72rem; color: var(--text-3); background: rgba(15,23,42,0.05); padding: 2px 8px; border-radius: var(--r-pill); margin-left: 8px; }
.q-text { font-size: 1rem; line-height: 1.7; color: var(--text); margin-bottom: 24px; }
.q-options { display: flex; flex-direction: column; gap: 10px; }
.q-opt { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border: 1.5px solid var(--border-h); border-radius: 14px; cursor: pointer; transition: all var(--tr); user-select: none; }
.q-opt:hover { border-color: var(--violet); background: rgba(155,93,229,0.04); }
.q-opt.selected { border-color: var(--cyan); background: rgba(6,214,224,0.06); }
.q-opt-key { width: 28px; height: 28px; border-radius: 8px; background: rgba(15,23,42,0.06); display: flex; align-items: center; justify-content: center; font-size: 0.78rem; font-weight: 700; color: var(--text-3); flex-shrink: 0; transition: all var(--tr); }
.q-opt.selected .q-opt-key { background: var(--cyan); color: #fff; }
.q-opt-text { font-size: 0.9rem; color: var(--text); }
/* ── short answer ── */
.q-short-wrap { padding: 4px 0 16px; }
.q-text-input { width: 100%; padding: 13px 16px; border: 1.5px solid var(--border-h); border-radius: 14px; font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: var(--text); background: #f8f9ff; outline: none; transition: border-color 0.2s, background 0.2s; }
.q-text-input:focus { border-color: var(--violet); background: #fff; }
.q-hint { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
.q-match-wrap { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 16px; }
.q-match-hint { font-size: 0.78rem; color: var(--text-3); margin-bottom: 4px; }
.q-match-row { display: flex; align-items: center; gap: 14px; padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 12px; transition: border-color var(--tr); }
.q-match-row:has(.q-match-sel:not([value=""])) { border-color: rgba(6,214,224,0.5); background: rgba(6,214,224,0.04); }
.q-match-left { flex: 1; font-size: 0.9rem; font-weight: 600; min-width: 100px; }
.q-match-sel { flex: 1; padding: 8px 12px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text); background: #f8f9ff; outline: none; transition: border-color 0.2s; cursor: pointer; }
.q-match-sel:focus { border-color: var(--cyan); }
/* ── navigation ── */
.q-nav { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; }
.btn-nav { padding: 10px 22px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; color: var(--text); cursor: pointer; transition: all var(--tr); }
.btn-nav:hover { border-color: var(--violet); color: var(--violet); }
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── loading/error ── */
.state-screen { min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; padding: 40px; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.state-title { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 700; }
.state-desc { font-size: 0.9rem; color: var(--text-2); }
.btn-primary { padding: 12px 28px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-block; }
/* ── auto-advance progress ring on selected option ── */
.q-opt.auto-advancing { border-color: var(--cyan); background: rgba(6,214,224,0.08); }
.q-opt.auto-advancing .q-opt-key { background: var(--cyan); color: #fff; }
@keyframes ring-fill { from { stroke-dashoffset: 56.5; } to { stroke-dashoffset: 0; } }
.advance-ring { position: absolute; top: 50%; right: 18px; transform: translateY(-50%); width: 22px; height: 22px; }
.advance-ring circle { fill: none; stroke: var(--cyan); stroke-width: 2.5; stroke-dasharray: 56.5; stroke-dashoffset: 56.5; stroke-linecap: round; animation: ring-fill 0.65s linear forwards; transform-origin: center; transform: rotate(-90deg); }
.q-opt { position: relative; }
/* ── confirm modal ── */
.confirm-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(6px); z-index: 300; display: none; align-items: center; justify-content: center; padding: 20px; }
.confirm-overlay.open { display: flex; }
.confirm-box { background: #fff; border-radius: 20px; padding: 36px 32px; width: 100%; max-width: 400px; box-shadow: 0 24px 80px rgba(15,23,42,0.2); text-align: center; }
.confirm-icon { font-size: 2.4rem; margin-bottom: 12px; }
.confirm-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 10px; }
.confirm-desc { font-size: 0.88rem; color: var(--text-2); line-height: 1.6; margin-bottom: 24px; }
.confirm-skipped { display: inline-block; margin: 8px 0 4px; padding: 6px 16px; border-radius: var(--r-pill); background: rgba(255,179,71,0.12); color: #c47f00; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
.confirm-actions { display: flex; gap: 10px; justify-content: center; }
.confirm-cancel { padding: 11px 26px; min-height: 44px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.confirm-cancel:hover { border-color: var(--violet); color: var(--violet); }
.confirm-ok { padding: 11px 26px; min-height: 44px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
.confirm-ok:hover { transform: translateY(-1px); }
/* ── btn-finish counter ── */
.btn-finish-count { font-size: 0.72rem; opacity: 0.75; font-weight: 600; display: block; margin-top: 2px; }
/* ── flag button ── */
.q-card { position: relative; }
.btn-flag { position: absolute; top: 16px; right: 20px; width: 34px; height: 34px; border-radius: 10px; border: 1.5px solid var(--border-h); background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--tr); color: var(--text-3); }
.btn-flag:hover { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.08); }
.btn-flag.flagged { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.12); }
.dot.flagged { border-color: #f59e0b !important; }
.dot.flagged:not(.answered) { color: #f59e0b; }
.dot.current.flagged { background: #f59e0b; border-color: #f59e0b; color: #fff; }
.dot.answered.flagged { background: rgba(245,158,11,0.18); }
/* ── Combo system ── */
.combo-toast {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%) scale(0.7);
z-index: 500; pointer-events: none;
display: flex; align-items: center; gap: 10px;
padding: 10px 22px; border-radius: 99px;
font-family: 'Unbounded', sans-serif; font-weight: 900;
opacity: 0; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1);
}
.combo-toast.show { opacity: 1; transform: translateX(-50%) scale(1); }
.combo-toast.hide { opacity: 0; transform: translateX(-50%) scale(0.7) translateY(-10px); }
.combo-toast.c3 { background: linear-gradient(135deg, rgba(6,214,224,0.15), rgba(6,214,100,0.1)); border: 2px solid rgba(6,214,224,0.3); color: #06B6D4; font-size: 0.82rem; }
.combo-toast.c5 { background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.1)); border: 2px solid rgba(155,93,229,0.35); color: #9B5DE5; font-size: 0.92rem; }
.combo-toast.c10 { background: linear-gradient(135deg, rgba(255,215,0,0.18), rgba(255,165,0,0.12)); border: 2px solid rgba(255,215,0,0.4); color: #D97706; font-size: 1.05rem; }
.combo-icon { font-size: 1.3em; }
.combo-break {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
z-index: 500; pointer-events: none;
padding: 6px 16px; border-radius: 99px;
background: rgba(241,91,181,0.1); border: 1.5px solid rgba(241,91,181,0.25);
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: #F15BB5;
opacity: 0; transition: all 0.3s ease;
}
.combo-break.show { opacity: 1; }
.combo-break.hide { opacity: 0; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<nav class="nav">
<a href="/dashboard" class="nav-logo">Learn<span>Space</span></a>
<div class="nav-right">
<span id="nav-subject">Тест</span>
<span id="nav-mode" style="font-size:0.68rem;padding:3px 10px;border-radius:99px;font-weight:700;display:none"></span>
<span class="nav-timer" id="nav-timer"></span>
</div>
</nav>
<!-- Загрузка -->
<div class="state-screen" id="screen-loading">
<div class="spinner"></div>
<div class="state-title">Загружаем тест...</div>
</div>
<!-- Ошибка -->
<div class="state-screen" id="screen-error" style="display:none">
<div class="state-title">Ошибка загрузки</div>
<div class="state-desc" id="error-msg"></div>
<a href="/dashboard" class="btn-primary">На главную</a>
</div>
<!-- Тест -->
<div class="layout" id="screen-test" style="display:none">
<aside class="sidebar">
<div class="card">
<div class="card-label" id="sidebar-label">Прогресс</div>
<div class="progress-bar"><div class="progress-fill" id="prog-fill" style="width:0%"></div></div>
<div class="progress-text"><span id="prog-answered">0</span> / <span id="prog-total">0</span> отвечено</div>
<div class="dots" id="dots"></div>
</div>
<div class="card">
<button class="btn-finish" id="btn-finish">Завершить тест</button>
</div>
<label style="display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:0.74rem;color:var(--text-2);cursor:pointer;user-select:none">
<input type="checkbox" id="auto-adv-toggle" style="accent-color:var(--violet)" /> Автопереход
</label>
</aside>
<div class="q-area" id="q-area"></div>
</div>
<!-- Confirm finish modal -->
<div class="confirm-overlay" id="confirm-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-dlg-title" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-box">
<div class="confirm-icon" id="confirm-icon"></div>
<div class="confirm-title" id="confirm-dlg-title">Завершить тест?</div>
<div class="confirm-desc" id="confirm-desc"></div>
<div class="confirm-actions">
<button class="confirm-cancel" id="confirm-cancel-btn" onclick="closeConfirm()">Вернуться</button>
<button class="confirm-ok" onclick="doFinish()">Завершить</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
if (!LS.requireAuth()) throw new Error('Not logged in');
// Set confirm modal icon via lsIcon
document.getElementById('confirm-icon').innerHTML = lsIcon('clipboard', 36);
const params = new URLSearchParams(location.search);
const subject = params.get('subject') || 'bio';
const mode = params.get('mode') || 'exam';
const count = Number(params.get('count') || 25);
const topic_id = params.get('topic') || null;
const test_id = params.get('test') || null;
const assignmentMode = params.get('assignment_mode') || null; // 'exam' | 'repeat' | 'ct'
// Whether timer should run: exam mode or ct mode
const useTimer = !assignmentMode || assignmentMode === 'exam' || assignmentMode === 'ct';
// ct mode: split questions into Part A (single/true_false) and Part B (multi/short_answer)
const isCTMode = assignmentMode === 'ct';
let session_id, questions, currentIdx = 0;
let sessionMode = mode;
// answers[q.id] = option_id (single/true_false) | [opt_id,...] (multi) | "text" (short_answer)
let answers = {};
let flags = {}; // flags[q.id] = true → marked for review
let startTimes = {};
let timerInterval, secondsLeft;
let ctPartBStart = 0; // index where Part B begins in ct mode
const _matchShuffles = {}; // cache shuffled right-side values per question id
/* ── SessionStorage persistence ──────────────────────────────────── */
function _ssKey() { return `ls_test_${session_id}`; }
function saveToSession() {
if (!session_id) return;
try { sessionStorage.setItem(_ssKey(), JSON.stringify({ answers, flags, currentIdx })); } catch {}
}
function restoreFromSession() {
try {
const raw = sessionStorage.getItem(_ssKey());
if (!raw) return;
const d = JSON.parse(raw);
if (d.answers) answers = d.answers;
if (d.flags) flags = d.flags;
if (typeof d.currentIdx === 'number') currentIdx = d.currentIdx;
} catch {}
}
function clearSession() {
try { sessionStorage.removeItem(_ssKey()); } catch {}
}
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика' };
/* ── Инициализация ──────────────────────────────────────────────── */
async function init() {
try {
const existingSession = params.get('session');
const data = existingSession
? await LS.getSessionQuestions(existingSession)
: await LS.startSession(subject, mode, count, topic_id, test_id);
sessionMode = data.mode || mode;
document.getElementById('nav-subject').textContent =
SUBJECT_NAMES[data.subject_slug] || SUBJECT_NAMES[subject] || 'Тест';
// Show mode indicator
const modeBadge = document.getElementById('nav-mode');
const modeInfo = { practice: { label: 'Практика', bg: 'rgba(6,214,100,0.15)', color: '#059652' }, exam: { label: 'Экзамен', bg: 'rgba(241,91,181,0.12)', color: '#F15BB5' }, ct: { label: 'ЦТ/ЦЭ', bg: 'rgba(155,93,229,0.15)', color: '#9B5DE5' }, repeat: { label: 'Повтор', bg: 'rgba(6,214,224,0.15)', color: '#06B6D4' } };
const mi = modeInfo[sessionMode] || modeInfo[assignmentMode];
if (mi && modeBadge) { modeBadge.textContent = mi.label; modeBadge.style.background = mi.bg; modeBadge.style.color = mi.color; modeBadge.style.display = ''; }
session_id = data.session_id;
questions = data.questions;
restoreFromSession();
document.getElementById('prog-total').textContent = questions.length;
// For ct mode: count Part A / Part B boundary
if (isCTMode) {
ctPartBStart = questions.findIndex(q => !['single','true_false'].includes(q.type || 'single'));
if (ctPartBStart === -1) ctPartBStart = questions.length; // all Part A
const lbl = document.getElementById('sidebar-label');
if (lbl) lbl.innerHTML = `ЦТ/ЦЭ &nbsp;<span style="font-size:0.65rem;color:var(--violet)">А: ${ctPartBStart} / Б: ${questions.length - ctPartBStart}</span>`;
}
renderDots();
renderQuestion(currentIdx);
if (useTimer) {
let timerSec = data.time_limit_sec || questions.length * 90;
if (data.started_at && data.time_limit_sec) {
const elapsed = Math.round((Date.now() - new Date(data.started_at).getTime()) / 1000);
timerSec = Math.max(1, data.time_limit_sec - elapsed);
}
startTimer(timerSec);
}
show('screen-test');
} catch (err) {
document.getElementById('error-msg').textContent = err.message;
show('screen-error');
}
hide('screen-loading');
}
/* ── Таймер ─────────────────────────────────────────────────────── */
function startTimer(seconds) {
secondsLeft = seconds;
updateTimerDisplay();
timerInterval = setInterval(() => {
secondsLeft--;
updateTimerDisplay();
if (secondsLeft <= 0) { clearInterval(timerInterval); doFinish(); }
if (secondsLeft <= 120) document.getElementById('nav-timer').classList.add('warning');
}, 1000);
}
function updateTimerDisplay() {
const m = String(Math.floor(secondsLeft / 60)).padStart(2, '0');
const s = String(secondsLeft % 60).padStart(2, '0');
document.getElementById('nav-timer').textContent = `${m}:${s}`;
}
/* ── KaTeX ──────────────────────────────────────────────────────── */
function renderMath(el) {
if (!el) return;
const run = () => {
if (window.renderMathInElement) {
renderMathInElement(el, {
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
],
throwOnError: false,
});
}
};
if (window._katexReady) run(); else window._katexReadyCb = run;
}
/* ── Проверка: есть ли ответ на вопрос ─────────────────────────── */
function isAnswered(q) {
const a = answers[q.id];
if (q.type === 'short_answer') return !!(a && String(a).trim());
if (q.type === 'multi') return !!(Array.isArray(a) && a.length > 0);
if (q.type === 'matching') {
if (!a || typeof a !== 'object') return false;
return q.options.every(opt => a[opt.id] && a[opt.id] !== '');
}
return !!a;
}
/* ── Рендер вопроса ─────────────────────────────────────────────── */
function renderQuestion(idx) {
currentIdx = idx;
const q = questions[idx];
startTimes[q.id] = startTimes[q.id] || Date.now();
const type = q.type || 'single';
// Build options HTML
let bodyHtml = '';
if (type === 'matching') {
// Shuffle right-side values once per question (cached)
if (!_matchShuffles[q.id]) _matchShuffles[q.id] = q.options.map(o => o.match_pair).sort(() => Math.random() - 0.5);
const rightVals = _matchShuffles[q.id];
const curPairs = answers[q.id] || {};
bodyHtml = `
<div class="q-match-wrap">
<div class="q-match-hint">Выберите пару для каждого элемента</div>
${q.options.map(opt => `
<div class="q-match-row">
<div class="q-match-left">${esc(opt.text)}</div>
<select class="q-match-sel" data-opt-id="${opt.id}" onchange="onMatchChange(${q.id},${opt.id},this.value)">
<option value="">— выбрать —</option>
${rightVals.map(v => `<option value="${esc(v)}"${curPairs[opt.id]===v?' selected':''}>${esc(v)}</option>`).join('')}
</select>
</div>`).join('')}
</div>`;
} else if (type === 'short_answer') {
const curVal = typeof answers[q.id] === 'string' ? esc(answers[q.id]) : '';
bodyHtml = `
<div class="q-short-wrap">
<input type="text" class="q-text-input" id="q-input-${q.id}"
placeholder="Введите ваш ответ…" value="${curVal}" autocomplete="off" />
<div class="q-hint">Сравнение без учёта регистра и пробелов по краям</div>
</div>`;
} else {
const isMulti = type === 'multi';
const optHtml = q.options.map((opt, i) => {
const sel = isMulti
? (Array.isArray(answers[q.id]) && answers[q.id].includes(opt.id))
: answers[q.id] === opt.id;
const keyLabel = isMulti ? (sel ? lsIcon('check', 14) : lsIcon('square', 14)) : String.fromCharCode(65 + i);
return `<div class="q-opt${sel ? ' selected' : ''}"
role="${isMulti ? 'checkbox' : 'radio'}"
aria-checked="${sel}"
tabindex="0"
data-opt-id="${opt.id}" data-i="${i}">
<div class="q-opt-key" aria-hidden="true">${keyLabel}</div>
<div class="q-opt-text">${q.allow_html ? opt.text : esc(opt.text)}</div>
</div>`;
}).join('');
bodyHtml = `<div class="q-options" id="opts" role="${isMulti ? 'group' : 'radiogroup'}" aria-label="Варианты ответа">${optHtml}</div>`;
if (isMulti) {
bodyHtml += `<div class="q-hint" style="margin-top:10px">Можно выбрать несколько вариантов</div>`;
}
}
const typeLabel = { single:'Один ответ', multi:'Несколько ответов', true_false:'Верно / Неверно', short_answer:'Краткий ответ', matching:'Сопоставление' }[type] || '';
const ctSection = isCTMode
? (idx < ctPartBStart ? '<span style="font-size:0.75rem;font-weight:800;color:var(--violet);margin-right:6px">Часть А</span>' : '<span style="font-size:0.75rem;font-weight:800;color:var(--amber);margin-right:6px">Часть Б</span>')
: '';
document.getElementById('q-area').innerHTML = `
<div class="q-card active">
<button class="btn-flag${flags[q.id] ? ' flagged' : ''}" id="btn-flag-${q.id}" aria-label="${flags[q.id] ? 'Снять отметку' : 'Отметить для проверки'}" aria-pressed="${flags[q.id] ? 'true' : 'false'}" onclick="toggleFlag(${q.id})">
<svg viewBox="0 0 24 24" width="16" height="16" fill="${flags[q.id] ? '#f59e0b' : 'none'}" stroke="${flags[q.id] ? '#f59e0b' : 'currentColor'}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
</button>
<div class="q-num-badge">
${ctSection}Вопрос <span>${idx + 1} / ${questions.length}</span>
${typeLabel ? `<span class="q-type-hint">${typeLabel}</span>` : ''}
${flags[q.id] ? '<span style="font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px">отмечен</span>' : ''}
</div>
${q.image ? `<img src="${esc(q.image)}" alt="" style="max-width:100%;max-height:260px;border-radius:10px;margin-bottom:12px;display:block" />` : ''}
${q.allow_html ? `<div class="q-text">${q.text}</div>` : `<p class="q-text">${esc(q.text)}</p>`}
${bodyHtml}
<div class="q-nav">
<button class="btn-nav" id="btn-prev" ${idx === 0 ? 'disabled' : ''}><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Назад</button>
<button class="btn-nav" id="btn-next">${idx === questions.length - 1 ? 'Завершить' : 'Вперёд <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>'}</button>
</div>
</div>`;
// Wire events
if (type === 'short_answer') {
const inp = document.getElementById(`q-input-${q.id}`);
inp.addEventListener('input', () => {
answers[q.id] = inp.value;
updateDots();
updateProgress();
});
inp.addEventListener('blur', () => {
if (String(answers[q.id] || '').trim()) sendAnswerAsync(q);
});
inp.focus();
} else if (type === 'matching') {
// matching: handled via onMatchChange inline (see function below)
} else {
const isMulti = type === 'multi';
document.querySelectorAll('.q-opt').forEach(el => {
const handleSelect = () => {
const optId = Number(el.dataset.optId);
if (isMulti) toggleMultiOpt(q, optId);
else selectSingleOpt(q, optId);
};
el.addEventListener('click', handleSelect);
el.addEventListener('keydown', e => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handleSelect(); }
});
});
}
document.getElementById('btn-prev').addEventListener('click', () => {
cancelAutoAdvance();
flushShortAnswer(q);
renderQuestion(idx - 1);
});
document.getElementById('btn-next').addEventListener('click', () => {
cancelAutoAdvance();
flushShortAnswer(q);
if (idx === questions.length - 1) tryFinish();
else renderQuestion(idx + 1);
});
updateDots();
renderMath(document.getElementById('q-area'));
saveToSession();
}
/* ── Matching ── */
function onMatchChange(qid, optId, val) {
const q = questions.find(q => q.id === qid);
if (!q) return;
if (!answers[qid] || typeof answers[qid] !== 'object') answers[qid] = {};
answers[qid][optId] = val;
updateDots();
updateProgress();
if (isAnswered(q)) sendAnswerAsync(q);
}
/* send short_answer to server if not yet sent */
const _sentAnswers = new Set();
function flushShortAnswer(q) {
if ((q.type || 'single') !== 'short_answer') return;
const text = String(answers[q.id] || '').trim();
if (text && !_sentAnswers.has(q.id)) sendAnswerAsync(q);
}
/* ── Авто-переход ────────────────────────────────────────────────── */
let _autoAdvanceTimer = null;
function cancelAutoAdvance() {
if (_autoAdvanceTimer) { clearTimeout(_autoAdvanceTimer); _autoAdvanceTimer = null; }
// remove ring from all options
document.querySelectorAll('.advance-ring').forEach(r => r.remove());
document.querySelectorAll('.q-opt.auto-advancing').forEach(el => el.classList.remove('auto-advancing'));
}
/* ── Выбор ответа — одиночный ───────────────────────────────────── */
function selectSingleOpt(q, option_id) {
cancelAutoAdvance();
const timeSpent = Math.round((Date.now() - (startTimes[q.id] || Date.now())) / 1000);
answers[q.id] = option_id;
document.querySelectorAll('.q-opt').forEach((el, i) => {
const sel = q.options[i].id === option_id;
el.classList.toggle('selected', sel);
el.setAttribute('aria-checked', sel ? 'true' : 'false');
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : String.fromCharCode(65 + i);
if (sel) {
// attach progress ring svg
el.classList.add('auto-advancing');
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('class','advance-ring');
svg.setAttribute('viewBox','0 0 22 22');
const circle = document.createElementNS('http://www.w3.org/2000/svg','circle');
circle.setAttribute('cx','11'); circle.setAttribute('cy','11'); circle.setAttribute('r','9');
svg.appendChild(circle);
el.appendChild(svg);
}
});
updateDots();
updateProgress();
LS.sendAnswer(session_id, q.id, option_id, timeSpent)
.then(r => { if (r && typeof r.is_correct === 'boolean' && sessionMode === 'practice') _handleAnswerResult(r.is_correct); })
.catch(console.error);
// auto-advance to next question (not on last question, can be disabled)
if (currentIdx < questions.length - 1 && localStorage.getItem('ls_auto_advance') !== '0') {
_autoAdvanceTimer = setTimeout(() => {
_autoAdvanceTimer = null;
if (answers[q.id] === option_id) renderQuestion(currentIdx + 1);
}, 1200);
}
}
/* ── Выбор ответа — множественный ───────────────────────────────── */
function toggleMultiOpt(q, option_id) {
if (!Array.isArray(answers[q.id])) answers[q.id] = [];
const arr = answers[q.id];
const idx = arr.indexOf(option_id);
if (idx === -1) arr.push(option_id); else arr.splice(idx, 1);
document.querySelectorAll('.q-opt').forEach(el => {
const id = Number(el.dataset.optId);
const sel = arr.includes(id);
el.classList.toggle('selected', sel);
el.setAttribute('aria-checked', sel ? 'true' : 'false');
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : lsIcon('square', 14);
});
updateDots();
updateProgress();
// debounce multi send (avoid hammering on rapid toggles)
clearTimeout(toggleMultiOpt._t);
toggleMultiOpt._t = setTimeout(() => sendAnswerAsync(q), 500);
}
/* ── Отправка ответа ─────────────────────────────────────────────── */
function sendAnswerAsync(q) {
const timeSpent = Math.round((Date.now() - (startTimes[q.id] || Date.now())) / 1000);
const type = q.type || 'single';
let option_id = null, answer_text = null, chosen_options = null;
if (type === 'short_answer') {
answer_text = String(answers[q.id] || '').trim();
_sentAnswers.add(q.id);
} else if (type === 'multi') {
chosen_options = answers[q.id] || [];
} else if (type === 'matching') {
answer_text = JSON.stringify(answers[q.id] || {});
} else {
option_id = answers[q.id];
}
LS.sendAnswer(session_id, q.id, option_id, timeSpent, answer_text, chosen_options)
.then(r => { if (r && typeof r.is_correct === 'boolean' && sessionMode === 'practice') _handleAnswerResult(r.is_correct); })
.catch(() => { LS.toast('Ответ не сохранён — нет связи с сервером', 'error'); });
}
/* ── Завершить ──────────────────────────────────────────────────── */
function tryFinish() {
cancelAutoAdvance();
if (questions[currentIdx]) flushShortAnswer(questions[currentIdx]);
const answered = questions.filter(isAnswered).length;
const total = questions.length;
const skipped = total - answered;
if (skipped > 0) {
const desc = document.getElementById('confirm-desc');
desc.innerHTML = skipped === total
? `Вы не ответили ни на один вопрос.<br>Результат будет нулевым.`
: `<span class="confirm-skipped">${lsIcon('warning', 16)} ${skipped} ${skipped === 1 ? 'вопрос без ответа' : skipped < 5 ? 'вопроса без ответа' : 'вопросов без ответа'}</span><br>Пропущенные вопросы будут засчитаны как неверные.`;
const ov = document.getElementById('confirm-overlay');
ov.classList.add('open');
setTimeout(() => document.getElementById('confirm-cancel-btn')?.focus(), 50);
return;
}
doFinish();
}
function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
async function doFinish() {
closeConfirm();
clearInterval(timerInterval);
clearSession();
_finishing = true;
const btn = document.getElementById('btn-finish');
btn.disabled = true;
btn.innerHTML = 'Завершаем…';
try {
await LS.finishSession(session_id);
window.location.href = `/test-result?session=${session_id}`;
} catch (err) {
LS.toast('Ошибка: ' + err.message, 'error');
btn.disabled = false;
updateFinishBtn();
}
}
function updateFinishBtn() {
const answered = questions.filter(isAnswered).length;
const total = questions.length;
const btn = document.getElementById('btn-finish');
if (!btn) return;
if (answered === total) {
btn.innerHTML = 'Завершить тест ' + lsIcon('check', 14);
} else {
btn.innerHTML = `Завершить тест<span class="btn-finish-count">${answered} / ${total} отвечено</span>`;
}
}
/* ── Combo system ────────────────────────────────────────────────── */
let _combo = 0, _maxCombo = 0, _comboEl = null, _comboTimer = null;
function _showCombo(count) {
_clearComboEl();
const el = document.createElement('div');
let cls = '', icon = '', text = '';
if (count >= 10) { cls = 'c10'; icon = lsIcon('flame', 20); text = `COMBO x${count}! +бонус XP`; }
else if (count >= 5) { cls = 'c5'; icon = lsIcon('zap', 20); text = `Combo x${count}!`; }
else if (count >= 3) { cls = 'c3'; icon = lsIcon('sparkles', 20); text = `Combo x${count}`; }
else return;
el.className = `combo-toast ${cls}`;
el.innerHTML = `<span class="combo-icon">${icon}</span>${text}`;
document.body.appendChild(el);
_comboEl = el;
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
_comboTimer = setTimeout(() => {
el.classList.remove('show');
el.classList.add('hide');
setTimeout(() => el.remove(), 350);
if (_comboEl === el) _comboEl = null;
}, 1800);
}
function _showComboBreak(was) {
if (was < 3) return;
_clearComboEl();
const el = document.createElement('div');
el.className = 'combo-break';
el.textContent = `Комбо x${was} прервано`;
document.body.appendChild(el);
_comboEl = el;
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
setTimeout(() => {
el.classList.remove('show');
el.classList.add('hide');
setTimeout(() => el.remove(), 300);
if (_comboEl === el) _comboEl = null;
}, 1200);
}
function _clearComboEl() {
if (_comboTimer) { clearTimeout(_comboTimer); _comboTimer = null; }
if (_comboEl) { _comboEl.remove(); _comboEl = null; }
}
function _handleAnswerResult(isCorrect) {
if (isCorrect) {
_combo++;
if (_combo > _maxCombo) _maxCombo = _combo;
_showCombo(_combo);
} else {
const was = _combo;
_combo = 0;
_showComboBreak(was);
}
}
/* ── Навигационные точки ────────────────────────────────────────── */
function renderDots() {
const el = document.getElementById('dots');
el.innerHTML = '';
questions.forEach((q, i) => {
if (isCTMode && i === 0 && ctPartBStart > 0) {
const lbl = document.createElement('div');
lbl.style.cssText = 'width:100%;font-size:0.65rem;font-weight:800;color:var(--text-3);text-transform:uppercase;letter-spacing:0.07em;margin-top:4px';
lbl.textContent = 'Часть А';
el.appendChild(lbl);
}
if (isCTMode && i === ctPartBStart && ctPartBStart > 0 && ctPartBStart < questions.length) {
const lbl = document.createElement('div');
lbl.style.cssText = 'width:100%;font-size:0.65rem;font-weight:800;color:var(--amber);text-transform:uppercase;letter-spacing:0.07em;margin-top:8px';
lbl.textContent = 'Часть Б';
el.appendChild(lbl);
}
const dot = document.createElement('button');
dot.className = 'dot';
dot.textContent = i + 1;
dot.setAttribute('aria-label', `Вопрос ${i + 1}`);
dot.addEventListener('click', () => { cancelAutoAdvance(); renderQuestion(i); });
el.appendChild(dot);
});
}
function updateDots() {
document.querySelectorAll('.dot').forEach((dot, i) => {
const isCurrent = i === currentIdx;
dot.classList.toggle('current', isCurrent);
dot.classList.toggle('answered', isAnswered(questions[i]));
dot.classList.toggle('flagged', !!flags[questions[i].id]);
dot.setAttribute('aria-current', isCurrent ? 'step' : 'false');
});
}
/* ── Flag toggle ─────────────────────────────────────────────────── */
function toggleFlag(qid) {
flags[qid] = !flags[qid];
// Update flag button
const btn = document.getElementById(`btn-flag-${qid}`);
if (btn) {
btn.classList.toggle('flagged', flags[qid]);
btn.setAttribute('aria-pressed', flags[qid] ? 'true' : 'false');
btn.setAttribute('aria-label', flags[qid] ? 'Снять отметку' : 'Отметить для проверки');
btn.querySelector('svg').setAttribute('fill', flags[qid] ? '#f59e0b' : 'none');
btn.querySelector('svg').setAttribute('stroke', flags[qid] ? '#f59e0b' : 'currentColor');
}
// Update badge label
const badge = document.querySelector('.q-num-badge');
if (badge) {
const existing = badge.querySelector('.flag-label');
if (existing) existing.remove();
if (flags[qid]) {
const lbl = document.createElement('span');
lbl.className = 'flag-label';
lbl.style.cssText = 'font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px';
lbl.textContent = 'отмечен';
badge.appendChild(lbl);
}
}
updateDots();
saveToSession();
}
function updateProgress() {
const n = questions.filter(isAnswered).length;
document.getElementById('prog-answered').textContent = n;
document.getElementById('prog-fill').style.width = `${(n / questions.length) * 100}%`;
updateFinishBtn();
saveToSession();
}
/* ── utils ──────────────────────────────────────────────────────── */
function show(id) { document.getElementById(id).style.display = ''; }
function hide(id) { document.getElementById(id).style.display = 'none'; }
document.getElementById('btn-finish')?.addEventListener('click', tryFinish);
// Auto-advance toggle
const _aaToggle = document.getElementById('auto-adv-toggle');
_aaToggle.checked = localStorage.getItem('ls_auto_advance') !== '0';
_aaToggle.addEventListener('change', () => {
localStorage.setItem('ls_auto_advance', _aaToggle.checked ? '1' : '0');
if (!_aaToggle.checked) cancelAutoAdvance();
});
/* ── Leave-page warning ──────────────────────────────────────────── */
let _finishing = false;
window.addEventListener('beforeunload', e => {
if (_finishing) return;
if (questions && questions.some(isAnswered)) {
e.preventDefault();
e.returnValue = 'Тест не завершён. Ваши ответы сохранены. Выйти?';
}
});
// Keyboard shortcuts: 1-4 / A-D to select answer options
document.addEventListener('keydown', e => {
if (!questions.length || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const q = questions[currentIdx];
if (!q || (q.type && q.type !== 'single' && q.type !== 'true_false')) return;
let idx = -1;
if (e.key >= '1' && e.key <= '4') idx = parseInt(e.key) - 1;
else if (e.key.toLowerCase() >= 'a' && e.key.toLowerCase() <= 'd') idx = e.key.toLowerCase().charCodeAt(0) - 97;
if (idx === -1) return;
const opts = document.querySelectorAll('#q-area .q-opt');
if (opts[idx]) opts[idx].click();
});
// Prevent bfcache from restoring a completed test when user presses Back
window.addEventListener('pageshow', e => {
if (e.persisted) window.location.replace('/dashboard');
});
init();
</script>
</body>
</html>