feat(exam-prep F3): интерактивный тренажёр — task-card + автопроверка ответа + retry + auto-open решения
This commit is contained in:
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user