ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
+57
View File
@@ -0,0 +1,57 @@
"use strict";
/**
* Grades a submission against the stored correct answers.
*
* The breakdown returned here is what gets sent back to the client, so it
* deliberately never includes which option(s) were correct — only whether
* the user's own answer for each question was right or wrong. Explanation
* text is included only when the question was answered correctly, since
* showing it for a wrong answer would effectively reveal the correct one.
*
* @param {Array} questions - QuizQuestion rows w/ .options (incl. is_correct), already fetched
* @param {Object} answers - { [question_id]: optionId | optionId[] } submitted by the client
*/
function gradeSubmission(questions, answers = {}) {
let totalPoints = 0;
let earnedPoints = 0;
const breakdown = questions.map((q) => {
const points = q.points ?? 1;
totalPoints += points;
const correctIds = (q.options ?? [])
.filter((o) => o.is_correct)
.map((o) => o.option_id);
const submitted = answers[q.question_id];
const submittedIds = Array.isArray(submitted)
? submitted
: (submitted !== undefined && submitted !== null ? [submitted] : []);
const isCorrect =
submittedIds.length === correctIds.length &&
correctIds.every((id) => submittedIds.includes(id));
if (isCorrect) earnedPoints += points;
return {
question_id: q.question_id,
type: q.type,
question: q.question,
points,
is_correct: isCorrect,
selected_option_ids: submittedIds,
explanation: isCorrect ? (q.explanation ?? null) : null,
options: (q.options ?? []).map((o) => ({
option_id: o.option_id,
text: o.text,
})),
};
});
const score = totalPoints > 0 ? Math.round((earnedPoints / totalPoints) * 100) : 0;
return { totalPoints, earnedPoints, score, breakdown };
}
module.exports = { gradeSubmission };
+67
View File
@@ -0,0 +1,67 @@
// This will do mandatory call
const MAX_ATTEMPTS = 10;
const ATTEMPT_WINDOW_HOURS = 24;
const COOLDOWN_MINUTES = 60;
// Fisher-Yates shuffle of each question's options. Pure — returns new
// arrays/objects, never mutates input. Grading is unaffected since
// submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh
// from the DB and never trust shuffled client-facing order.
function shuffleOptions(questions) {
return questions.map((q) => {
const options = [...(q.options ?? [])];
for (let i = options.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[options[i], options[j]] = [options[j], options[i]];
}
return { ...q, options };
});
}
// Single source of truth for both the GET-time info fields and the
// submit-time enforcement check. Doesn't care about input order —
// derives best/most-recent itself, so callers can just fetch attempts
// with no ORDER BY.
function getAttemptStatus(attempts) {
const now = new Date();
const windowStart = new Date(now.getTime() - ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000);
const attemptsInWindow = attempts.filter((a) => new Date(a.createdAt) >= windowStart);
const attempt_count = attempts.length; // lifetime — still used for has_passed/best_attempt
const has_passed = attempts.some((a) => a.passed);
const best_attempt = attempts.reduce(
(best, a) => (!best || a.score > best.score ? a : best),
null
);
const most_recent = attempts.reduce(
(latest, a) => (!latest || new Date(a.createdAt) > new Date(latest.createdAt) ? a : latest),
null
);
let cooldown_until = null;
if (most_recent) {
const unlockAt = new Date(new Date(most_recent.createdAt).getTime() + COOLDOWN_MINUTES * 60000);
if (unlockAt > now) cooldown_until = unlockAt.toISOString();
}
const attempts_remaining = Math.max(0, MAX_ATTEMPTS - attemptsInWindow.length);
let window_reset_at = null;
if (attempts_remaining === 0 && attemptsInWindow.length > 0) {
const oldestInWindow = attemptsInWindow.reduce(
(oldest, a) => (!oldest || new Date(a.createdAt) < new Date(oldest.createdAt) ? a : oldest),
null
);
window_reset_at = new Date(
new Date(oldestInWindow.createdAt).getTime() + ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000
).toISOString();
}
const can_attempt = attempts_remaining > 0 && !cooldown_until;
return { attempt_count, has_passed, best_attempt, attempts_remaining, cooldown_until, window_reset_at, can_attempt };
}
module.exports = { MAX_ATTEMPTS, ATTEMPT_WINDOW_HOURS, COOLDOWN_MINUTES, shuffleOptions, getAttemptStatus };