pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:29:07 +08:00
parent 463a8d3978
commit 89acdfc239
67 changed files with 2736 additions and 306 deletions
+36 -19
View File
@@ -1,21 +1,25 @@
// Assessment cooldown policy is now stored per-assessment in the DB (max_attempts / cooldown_hours).
// These fallbacks are used only if values are missing (e.g. legacy rows before the migration).
const ASSESSMENT_FAILS_BEFORE_COOLDOWN = 3;
const ASSESSMENT_COOLDOWN_HOURS = 24;
// Fisher-Yates in-place shuffle — shared by shuffleOptions and shuffleQuestions.
function fisherYates(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Fisher-Yates shuffle of each question's options. Pure — returns new
// Randomises the ORDER OF OPTIONS within each question. 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.
// from the DB and never trust the 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 };
});
return questions.map((q) => ({ ...q, options: fisherYates([...(q.options ?? [])]) }));
}
// Randomises the ORDER OF QUESTIONS. Pure — returns a new array.
// Safe: grading re-fetches questions from DB in stored order; client position
// has no effect on correctness checks.
function shuffleQuestions(questions) {
return fisherYates([...questions]);
}
// Single source of truth for both the GET-time info fields and the
@@ -23,7 +27,7 @@ function shuffleOptions(questions) {
//
// type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open
// type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles)
// maxFails / cooldownHours come from the assessment row; fallback to the constants above.
// maxFails / cooldownHours come from the assessment row; null = feature off (no limit/cooldown).
function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) {
const attempt_count = attempts.length;
const has_passed = attempts.some((a) => a.passed);
@@ -44,9 +48,22 @@ function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } =
};
}
// Assessment: simulate rolling cycles — N failed attempts → cooldown (from the assessment's own config)
const failLimit = maxFails ?? ASSESSMENT_FAILS_BEFORE_COOLDOWN;
const lockHours = cooldownHours ?? ASSESSMENT_COOLDOWN_HOURS;
// Assessment: simulate rolling cycles — N failed attempts → cooldown.
// null means the feature is off: no attempt cap / no cooldown.
const failLimit = maxFails ?? null;
const lockHours = cooldownHours ?? null;
if (failLimit === null || lockHours === null) {
return {
attempt_count,
has_passed,
best_attempt,
attempts_remaining: null,
cooldown_until: null,
window_reset_at: null,
can_attempt: true,
};
}
const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
@@ -79,4 +96,4 @@ function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } =
};
}
module.exports = { ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS, shuffleOptions, getAttemptStatus };
module.exports = { shuffleOptions, shuffleQuestions, getAttemptStatus };