Пробный экзамен
-В F9 здесь стартует таймер на 180 минут, 10 задач в реальных условиях (без проверки и решений), а в конце — балл по сетке и разбор каждого задания.
+ +diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index d849fb3..0b1c5ef 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -110,6 +110,60 @@ const SQL = { mode, session_id, hint_used, solution_viewed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), + + /* ── Mock sessions ──────────────────────────────────────────── */ + getTasksByVariant: db.prepare(` + SELECT id FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx + `), + getRandomTaskIds: db.prepare(` + SELECT id FROM exam_tasks + WHERE exam_key = ? AND task_type IN ('mc','open') + ORDER BY RANDOM() LIMIT ? + `), + insertMockSession: db.prepare(` + INSERT INTO exam_mock_sessions + (user_id, exam_key, variant, source, task_ids_json, + started_at, duration_planned_min, total_tasks, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active') + `), + getMockSession: db.prepare(` + SELECT id, user_id, exam_key, variant, source, task_ids_json, + started_at, finished_at, duration_planned_min, + score, total_correct, total_tasks, status + FROM exam_mock_sessions + WHERE id = ? + `), + getTracksScoring: db.prepare(`SELECT duration_min, scoring_json FROM exam_tracks WHERE exam_key = ?`), + getTasksByIds: (ids) => { + if (!ids.length) return []; + const ph = ids.map(() => '?').join(','); + return db.prepare(` + SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic + FROM exam_tasks + WHERE id IN (${ph}) + `).all(...ids); + }, + /* Latest user-answer per task in a mock session (one row per task). + We upsert by deleting prior rows for (user, task, session, mode='mock'). */ + deleteMockAnswer: db.prepare(` + DELETE FROM exam_attempts + WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock' + `), + getMockAnswers: db.prepare(` + SELECT exam_task_id, user_answer, is_correct + FROM exam_attempts + WHERE user_id = ? AND session_id = ? AND mode = 'mock' + `), + updateMockAttemptCorrectness: db.prepare(` + UPDATE exam_attempts SET is_correct = ? + WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock' + `), + finalizeMockSession: db.prepare(` + UPDATE exam_mock_sessions + SET status = 'finished', finished_at = ?, score = ?, total_correct = ? + WHERE id = ? + `), }; /* ── GET /api/exam-prep/tracks ── @@ -290,4 +344,246 @@ router.post('/attempts', (req, res) => { res.json({ id: Number(result.lastInsertRowid) }); }); +/* ────────────────────────────────────────────────────────────────── + Mock exam (F9) + ────────────────────────────────────────────────────────────────── */ + +/* Convert {correct} count into a final score per the track's scoring grid. + Grid is JSON like [{correct:30,score:10},{correct:27,score:9},...]. + Sorted descending; we return the score for the largest threshold met. */ +function scoreFromGrid(correctCount, scoringJson) { + if (!scoringJson) return null; + let grid; + try { grid = JSON.parse(scoringJson); } catch { return null; } + if (!Array.isArray(grid) || !grid.length) return null; + grid = grid.slice().sort((a, b) => b.correct - a.correct); + for (const entry of grid) { + if (correctCount >= entry.correct) return entry.score; + } + return 0; +} + +/* ── POST /api/exam-prep/:examKey/mock/start ── + Body: { source: 'variant'|'random', variant?: number, count?: number } + Creates an active mock session and returns its id. */ +router.post('/:examKey/mock/start', (req, res) => { + const { examKey } = req.params; + const track = SQL.getTrack.get(examKey); + if (!track) return res.status(404).json({ error: 'Unknown exam track' }); + + const source = req.body?.source; + let taskIds = []; + let variant = null; + + if (source === 'variant') { + variant = Number(req.body?.variant); + if (!Number.isInteger(variant) || variant < 1) { + return res.status(400).json({ error: 'Variant number required' }); + } + const rows = SQL.getTasksByVariant.all(examKey, variant); + if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' }); + taskIds = rows.map(r => r.id); + } else if (source === 'random') { + let count = Number(req.body?.count) || track.tasks_per_variant; + count = Math.max(5, Math.min(count, 30)); + const rows = SQL.getRandomTaskIds.all(examKey, count); + taskIds = rows.map(r => r.id); + } else { + return res.status(400).json({ error: 'source must be variant|random' }); + } + + const r = SQL.insertMockSession.run( + req.user.id, examKey, variant, source, JSON.stringify(taskIds), + Date.now(), track.duration_min, taskIds.length + ); + res.json({ + id: Number(r.lastInsertRowid), + task_count: taskIds.length, + duration_min: track.duration_min, + }); +}); + +/* ── GET /api/exam-prep/mock/:id ── + Returns mock-session state + tasks. + - status='active' : tasks WITHOUT answer/solution; includes user_answers map + - status='finished' : tasks WITH answer/solution; includes is_correct per task */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/mock/:id', (req, res) => { + const id = Number(req.params.id); + const sess = SQL.getMockSession.get(id); + if (!sess) return res.status(404).json({ error: 'Mock session not found' }); + if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + + let ids = []; + try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} + const tasksRaw = SQL.getTasksByIds(ids); + const taskMap = new Map(tasksRaw.map(t => [t.id, t])); + const tasks = ids.map(i => taskMap.get(i)).filter(Boolean); + + const answers = SQL.getMockAnswers.all(req.user.id, id); + const answerByTask = new Map(answers.map(a => [a.exam_task_id, a])); + + const isActive = sess.status === 'active'; + const out = tasks.map((t, idx) => { + const ua = answerByTask.get(t.id); + return { + id: t.id, + idx: idx + 1, + variant: t.variant, + type: t.task_type, + text: t.text_html, + figure: t.figure_html, + opts: t.opts_json ? safeJson(t.opts_json) : null, + // Hide answer/solution while active + answer: isActive ? null : t.answer, + solution: isActive ? null : t.solution_html, + // User's stored answer + correctness (post-finish only) + user_answer: ua?.user_answer ?? null, + is_correct: !isActive ? (ua?.is_correct ?? null) : null, + }; + }); + + res.json({ + session: { + id: sess.id, + exam_key: sess.exam_key, + variant: sess.variant, + source: sess.source, + status: sess.status, + started_at: sess.started_at, + finished_at: sess.finished_at, + duration_planned_min: sess.duration_planned_min, + score: sess.score, + total_correct: sess.total_correct, + total_tasks: sess.total_tasks, + }, + tasks: out, + }); +}); + +/* ── POST /api/exam-prep/mock/:id/answer ── + Body: { exam_task_id, user_answer } + Upserts the user's draft answer (no correctness check while active). */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.post('/mock/:id/answer', (req, res) => { + const id = Number(req.params.id); + const sess = SQL.getMockSession.get(id); + if (!sess) return res.status(404).json({ error: 'Mock session not found' }); + if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + if (sess.status !== 'active') return res.status(409).json({ error: 'Session finished' }); + + const taskId = Number(req.body?.exam_task_id); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' }); + + let ids = []; + try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} + if (!ids.includes(taskId)) return res.status(400).json({ error: 'Task not in this session' }); + + const userAnswer = req.body?.user_answer != null ? String(req.body.user_answer).slice(0, 500) : null; + const timeMs = Number.isFinite(req.body?.time_ms) ? Math.max(0, Math.min(req.body.time_ms, 24 * 3600 * 1000)) : null; + + /* Upsert: delete any prior mock-attempt for this task, then insert fresh */ + db.transaction(() => { + SQL.deleteMockAnswer.run(req.user.id, taskId, id); + SQL.insertAttempt.run( + req.user.id, taskId, userAnswer, null /* is_correct unknown */, + timeMs, 'mock', id, 0, 0, Date.now() + ); + })(); + + res.json({ ok: true }); +}); + +/* ── POST /api/exam-prep/mock/:id/finish ── + Computes correctness for every stored answer + final score. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.post('/mock/:id/finish', (req, res) => { + const id = Number(req.params.id); + const sess = SQL.getMockSession.get(id); + if (!sess) return res.status(404).json({ error: 'Mock session not found' }); + if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + if (sess.status === 'finished') return res.json({ ok: true, already: true }); + + // Pull tasks + answers, compute correctness per task using same logic as client. + let ids = []; + try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} + const tasks = SQL.getTasksByIds(ids); + const taskMap = new Map(tasks.map(t => [t.id, t])); + const answers = SQL.getMockAnswers.all(req.user.id, id); + + let totalCorrect = 0; + db.transaction(() => { + for (const a of answers) { + const t = taskMap.get(a.exam_task_id); + if (!t || !t.answer) continue; + const correct = checkAnswerServer(a.user_answer, t.answer) ? 1 : 0; + if (correct) totalCorrect++; + SQL.updateMockAttemptCorrectness.run(correct, req.user.id, a.exam_task_id, id); + } + })(); + + const track = SQL.getTracksScoring.get(sess.exam_key); + const score = track ? scoreFromGrid(totalCorrect, track.scoring_json) : null; + + SQL.finalizeMockSession.run(Date.now(), score, totalCorrect, id); + + res.json({ + ok: true, + total_correct: totalCorrect, + total_tasks: sess.total_tasks, + score, + }); +}); + +/* ────────────────────────────────────────────────────────────────── + Server-side answer checker — mirror of frontend EP.answer.check. + Kept here (intentional code duplication) so the server is the source + of truth for mock-exam scoring and can never be bypassed by a + tampered client. + ────────────────────────────────────────────────────────────────── */ +const EPS = 1e-6; + +function srvToNumber(s) { + if (s == null) return NaN; + let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.'); + const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/); + if (f) { + const num = Number(f[1]); + const den = Number(f[2]); + if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN; + return num / den; + } + const n = Number(t); + return Number.isFinite(n) ? n : NaN; +} + +function srvToPair(s) { + if (s == null) return null; + const t = String(s).trim().replace(/\$/g, '').replace(/\s+и\s+/g, ';'); + const parts = t.split(/[;,]/).map(p => p.trim()).filter(Boolean); + if (parts.length !== 2) return null; + const a = srvToNumber(parts[0]); + const b = srvToNumber(parts[1]); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return a <= b ? [a, b] : [b, a]; +} + +function checkAnswerServer(userInput, canonical) { + if (userInput == null || canonical == null) return false; + const c = String(canonical).trim(); + if (/^[а-д]$/.test(c)) { + return String(userInput).trim().toLowerCase() === c.toLowerCase(); + } + if (/^[^;]+;[^;]+$/.test(c)) { + const cp = srvToPair(c); + const up = srvToPair(userInput); + if (!cp || !up) return false; + return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS; + } + const cn = srvToNumber(c); + const un = srvToNumber(userInput); + if (Number.isNaN(cn) || Number.isNaN(un)) return false; + return Math.abs(cn - un) < EPS; +} + module.exports = router; diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 8b4c03c..30ab80f 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -612,6 +612,102 @@ border-radius: 10px; } +/* ═══════════════════════════════════════════════════════════════ + Mock exam view (`mk-*`) — used by exam-prep-mock.html + ═══════════════════════════════════════════════════════════════ */ + +/* Setup */ +.mk-setup p { font-size: .92rem; line-height: 1.7; } +.mk-source { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + margin: 18px 0 4px; +} +.mk-source-card { + border: 1.5px solid var(--border-h); + border-radius: 12px; + padding: 14px 16px; + cursor: pointer; + transition: all .15s; +} +.mk-source-card:hover { border-color: var(--violet); } +.mk-source-card.mk-source-active { + border-color: var(--violet); + background: rgba(155,93,229,.06); +} +.mk-source-head { + display: flex; align-items: center; gap: 10px; + font-family: 'Manrope', sans-serif; font-size: .95rem; font-weight: 700; + color: var(--text); margin-bottom: 10px; +} +.mk-source-head svg { width: 18px; height: 18px; color: var(--violet); } +.mk-source-body label { + display: flex; align-items: center; gap: 8px; + font-size: .85rem; color: var(--text-2); +} +.mk-input { + width: 80px; + padding: 6px 10px; + border: 1.5px solid var(--border-h); + border-radius: 7px; + background: var(--surface); color: var(--text); + font-family: 'Manrope', sans-serif; font-size: .9rem; font-weight: 600; +} +.mk-input:focus { outline: none; border-color: var(--violet); } +.mk-source-hint { + font-size: .78rem; color: var(--text-3); margin-top: 6px; +} +.mk-start-row { margin-top: 16px; } + +/* Active phase: sticky bar */ +.mk-bar { + position: sticky; top: 0; z-index: 50; + display: flex; align-items: center; gap: 14px; + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 12px; + padding: 12px 18px; + margin-bottom: 18px; + flex-wrap: wrap; +} +.mk-bar-info { + display: flex; flex-direction: column; gap: 2px; + flex: 1; min-width: 180px; +} +.mk-bar-source { + font-family: 'Unbounded', sans-serif; font-size: .9rem; font-weight: 800; + letter-spacing: -.01em; +} +.mk-bar-count { + font-size: .78rem; color: var(--text-2); +} +.mk-timer { + font-family: 'Unbounded', sans-serif; + font-size: 1.6rem; font-weight: 800; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -.01em; +} +.mk-timer.mk-timer-warn { color: #F8961E; } +.mk-timer.mk-timer-zero { color: #E63946; animation: mk-pulse .8s infinite; } +@keyframes mk-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .4; } +} +.mk-finish-btn { white-space: nowrap; } + +.mk-tasks { } + +/* Result phase */ +.mk-result-stats { margin-bottom: 18px; } +.mk-breakdown-title { + font-family: 'Unbounded', sans-serif; + font-size: 1.05rem; font-weight: 800; + margin: 26px 0 12px; + letter-spacing: -.01em; +} + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/exam-prep-mock.html b/frontend/exam-prep-mock.html index 0916f8e..f18ea2d 100644 --- a/frontend/exam-prep-mock.html +++ b/frontend/exam-prep-mock.html @@ -8,6 +8,9 @@ + + +
В F9 здесь стартует таймер на 180 минут, 10 задач в реальных условиях (без проверки и решений), а в конце — балл по сетке и разбор каждого задания.
+ +