feat(exam-prep F9): пробный экзамен — setup/active/result + таймер + балл по сетке + серверный чекер

This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:06:57 +03:00
parent b07da5ee6d
commit cfcb233b6c
5 changed files with 825 additions and 39 deletions
+296
View File
@@ -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;