feat(exam-prep F3): интерактивный тренажёр — task-card + автопроверка ответа + retry + auto-open решения

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:51:38 +03:00
parent 5f8fcbd964
commit da14b9cb68
5 changed files with 600 additions and 101 deletions
+88
View File
@@ -0,0 +1,88 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Answer normalization + correctness check.
Same algorithm runs on user input and stored canonical answer
(from /tasks endpoint), then compared.
Canonical answer forms (produced by backend/scripts/import-exam-tasks.js):
MC: single Cyrillic letter: "а" | "б" | "в" | "г" | "д"
open NUM: decimal: "-2" "7500" "1.5" "0.25"
open FRAC: fraction: "9/4" "-104/9" (sign on numerator)
open PAIR: two values, ";" sep: "-2;4"
The user can type the answer in many equivalent forms — the normalizer
accepts any form and decides equivalence numerically.
────────────────────────────────────────────────────────────────── */
(function () {
const EPS = 1e-6;
// Try to coerce a string to a number; returns NaN on failure.
function toNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
// Fraction "a/b" → a/b
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;
}
// Parse a "pair" answer "A;B" — accepts ";" or "," or " и " or whitespace
function toPair(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 = toNumber(parts[0]);
const b = toNumber(parts[1]);
if (Number.isNaN(a) || Number.isNaN(b)) return null;
// Order-insensitive comparison: return sorted pair
return a <= b ? [a, b] : [b, a];
}
/* Detect form of the canonical answer.
Returns: 'mc' | 'pair' | 'numeric' */
function detectForm(canonical) {
if (canonical == null) return 'numeric';
const t = String(canonical).trim();
if (/^[а-д]$/.test(t)) return 'mc';
if (/^[^;]+;[^;]+$/.test(t)) return 'pair';
return 'numeric';
}
/* Main check. Returns true iff user input matches canonical answer.
False if either side can't be normalized.
Both inputs are strings as returned by the form or stored in DB. */
function check(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const form = detectForm(canonical);
if (form === 'mc') {
const u = String(userInput).trim().toLowerCase();
return u === String(canonical).trim().toLowerCase();
}
if (form === 'pair') {
const cp = toPair(canonical);
const up = toPair(userInput);
if (!cp || !up) return false;
return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS;
}
// Numeric (integer / decimal / fraction)
const c = toNumber(canonical);
const u = toNumber(userInput);
if (Number.isNaN(c) || Number.isNaN(u)) return false;
return Math.abs(c - u) < EPS;
}
window.EP = window.EP || {};
window.EP.answer = { check, detectForm, toNumber, toPair };
})();