feat(exam-prep F9): пробный экзамен — setup/active/result + таймер + балл по сетке + серверный чекер
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user