Files
freecodecamp--freecodecamp/api/src/exam-environment/utils/exam-environment.ts
T
wehub-resource-sync dde272c4b8
CD - Docker - GHCR Images / Build and Push Images (push) Waiting to run
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:55:53 +08:00

628 lines
18 KiB
TypeScript

/* eslint-disable jsdoc/require-description-complete-sentence */
// TODO: enable this, since strings don't make good errors.
import {
ExamEnvironmentAnswer,
ExamEnvironmentExam,
ExamEnvironmentExamAttempt,
ExamEnvironmentExamModerationStatus,
ExamEnvironmentGeneratedExam,
ExamEnvironmentGeneratedMultipleChoiceQuestion,
ExamEnvironmentMultipleChoiceQuestion,
ExamEnvironmentMultipleChoiceQuestionAttempt,
ExamEnvironmentQuestionSet,
ExamEnvironmentQuestionSetAttempt
} from '@prisma/client';
import type { FastifyBaseLogger, FastifyInstance } from 'fastify';
import { type Static } from '@fastify/type-provider-typebox';
import { omit } from 'lodash-es';
import * as schemas from '../schemas/index.js';
import { mapErr } from '../../utils/index.js';
import { ExamAttemptStatus } from '../schemas/exam-environment-exam-attempt.js';
import { ERRORS } from './errors.js';
interface PartialUser {
completedChallenges: { id: string }[];
isHonest: boolean | null;
}
/**
* Checks if all exam prerequisites have been met by the user:
* - completed challenges linked to exam
* - user is required to have accepted the academic honesty policy
*/
export function checkPrerequisites(
user: PartialUser,
prerequisites: ExamEnvironmentExam['prerequisites']
) {
return (
user.isHonest &&
prerequisites.every(p => user.completedChallenges.some(c => c.id === p))
);
}
export type UserExam = Omit<
ExamEnvironmentExam,
'questionSets' | 'config' | 'id' | 'prerequisites' | 'deprecated' | 'version'
> & {
config: Omit<ExamEnvironmentExam['config'], 'tags' | 'questionSets'>;
questionSets: (Omit<ExamEnvironmentQuestionSet, 'questions'> & {
questions: (Omit<
ExamEnvironmentMultipleChoiceQuestion,
'answers' | 'tags' | 'deprecated'
> & {
answers: Omit<ExamEnvironmentAnswer, 'isCorrect'>[];
})[];
})[];
} & { generatedExamId: string; examId: string };
/**
* Takes the generated exam and the original exam, and creates the user-facing exam.
*/
export function constructUserExam(
generatedExam: ExamEnvironmentGeneratedExam,
exam: ExamEnvironmentExam
): UserExam {
// Map generated exam to user exam (a.k.a. public exam information for user)
const userQuestionSets = generatedExam.questionSets.map(gqs => {
// Get matching question from `exam`, but remove `is_correct` from `exam.questions[].answers[]`
const examQuestionSet = exam.questionSets.find(eqs => eqs.id === gqs.id);
if (!examQuestionSet) {
throw new Error(
`Unreachable. Generated question set id ${gqs.id} not found in exam ${exam.id}.`
);
}
const { questions } = examQuestionSet;
const userQuestions = gqs.questions.map(gq => {
const examQuestion = questions.find(eq => eq.id === gq.id);
if (!examQuestion) {
throw new Error(
`Unreachable. Generated question id ${gq.id} not found in exam question set ${examQuestionSet.id}.`
);
}
// Remove `isCorrect` from question answers
const answers = gq.answers.map(generatedAnswerId => {
const examAnswer = examQuestion.answers.find(
ea => ea.id === generatedAnswerId
);
if (!examAnswer) {
throw new Error(
`Unreachable. Generated answer id ${generatedAnswerId} not found in exam question ${examQuestion.id}.`
);
}
const { isCorrect: _, ...answer } = examAnswer;
return answer;
});
// NOTE: Shuffling here means when saved attempt is re-fetched, answers will be in different order.
const shuffledAnswers = shuffleArray(answers);
return {
id: examQuestion.id,
audio: examQuestion.audio,
text: examQuestion.text,
answers: shuffledAnswers
};
});
const userQuestionSet = {
type: examQuestionSet.type,
questions: userQuestions,
id: examQuestionSet.id,
context: examQuestionSet.context
};
return userQuestionSet;
});
// Order questionSets in same order as original exam
const orderedUserQuestionSets = userQuestionSets.sort((a, b) => {
return (
exam.questionSets.findIndex(qs => qs.id === a.id) -
exam.questionSets.findIndex(qs => qs.id === b.id)
);
});
const config = {
totalTimeInS: exam.config.totalTimeInS,
name: exam.config.name,
note: exam.config.note,
retakeTimeInS: exam.config.retakeTimeInS,
passingPercent: exam.config.passingPercent
};
const userExam: UserExam = {
examId: exam.id,
generatedExamId: generatedExam.id,
config,
questionSets: orderedUserQuestionSets
};
return userExam;
}
/**
* Ensures all questions and answers in the attempt are from the generated exam.
*/
export function validateAttempt(
generatedExam: ExamEnvironmentGeneratedExam,
questionSets: ExamEnvironmentExamAttempt['questionSets']
) {
for (const attemptQuestionSet of questionSets) {
const generatedQuestionSet = generatedExam.questionSets.find(
qt => qt.id === attemptQuestionSet.id
);
if (!generatedQuestionSet) {
throw new Error(
`Question type ${attemptQuestionSet.id} not found in generated exam.`
);
}
for (const attemptQuestion of attemptQuestionSet.questions) {
const generatedQuestion = generatedQuestionSet.questions.find(
q => q.id === attemptQuestion.id
);
if (!generatedQuestion) {
throw new Error(
`Question ${attemptQuestion.id} not found in generated exam.`
);
}
for (const attemptAnswer of attemptQuestion.answers) {
const generatedAnswer = generatedQuestion.answers.find(
a => a === attemptAnswer
);
if (!generatedAnswer) {
throw new Error(
`Answer ${attemptAnswer} not found in generated exam.`
);
}
}
}
}
return true;
}
/**
* Checks all question sets and questions in the generated exam are in the attempt.
*
* TODO: Consider throwing with specific issue.
*
* @param questionSets An exam attempt.
* @param generatedExam The corresponding generated exam.
* @returns Whether or not the attempt can be considered finished.
*/
export function checkAttemptAgainstGeneratedExam(
questionSets: ExamEnvironmentQuestionSetAttempt[],
generatedExam: Pick<ExamEnvironmentGeneratedExam, 'questionSets'>
): boolean {
// Check all question sets and questions are in generated exam
for (const generatedQuestionSet of generatedExam.questionSets) {
const attemptQuestionSet = questionSets.find(
q => q.id === generatedQuestionSet.id
);
if (!attemptQuestionSet) {
return false;
}
for (const generatedQuestion of generatedQuestionSet.questions) {
const attemptQuestion = attemptQuestionSet.questions.find(
q => q.id === generatedQuestion.id
);
if (!attemptQuestion) {
return false;
}
const atLeastOneAnswer = attemptQuestion.answers.length > 0;
if (!atLeastOneAnswer) {
return false;
}
// All answers in attempt must be in generated exam
const allAnswersInGeneratedExam = attemptQuestion.answers.every(a =>
generatedQuestion.answers.includes(a)
);
if (!allAnswersInGeneratedExam) {
return false;
}
}
}
return true;
}
/**
* Adds the current time submission time to all questions in the attempt if the question answer has changed.
*/
export function userAttemptToDatabaseAttemptQuestionSets(
userAttempt: Static<
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
>,
latestAttempt: ExamEnvironmentExamAttempt
): ExamEnvironmentExamAttempt['questionSets'] {
const databaseAttemptQuestionSets: ExamEnvironmentExamAttempt['questionSets'] =
[];
for (const questionSet of userAttempt.questionSets) {
const latestQuestionSet = latestAttempt.questionSets.find(
qs => qs.id === questionSet.id
);
// If no latest attempt, add submission time to all questions
if (!latestQuestionSet) {
databaseAttemptQuestionSets.push({
...questionSet,
questions: questionSet.questions.map(q => {
return {
...q,
submissionTime: new Date()
};
})
});
} else {
const databaseAttemptQuestionSet = {
...questionSet,
questions: questionSet.questions.map(q => {
const latestQuestion = latestQuestionSet.questions.find(
lq => lq.id === q.id
);
// If no latest question, add submission time
if (!latestQuestion) {
return {
...q,
submissionTime: new Date()
};
}
// If answers have changed, add submission time
if (
JSON.stringify(q.answers) !== JSON.stringify(latestQuestion.answers)
) {
return {
...q,
submissionTime: new Date()
};
}
return latestQuestion;
})
};
databaseAttemptQuestionSets.push(databaseAttemptQuestionSet);
}
}
return databaseAttemptQuestionSets;
}
/**
* Calculates the number of correct questions over the number of the total questions given for an attempt.
* @returns The score of the exam attempt as a percentage.
*/
export function calculateScore(
exam: ExamEnvironmentExam,
generatedExam: ExamEnvironmentGeneratedExam,
attempt: ExamEnvironmentExamAttempt
) {
const attemptQuestionSets = attempt.questionSets;
const generatedQuestionSets = generatedExam.questionSets;
const totalQuestions = generatedQuestionSets.reduce(
(total, attemptQuestionSet) => total + attemptQuestionSet.questions.length,
0
);
let correctQuestions = 0;
for (const attemptQuestionSet of attemptQuestionSets) {
const examQuestionSet = exam.questionSets.find(
({ id }) => id === attemptQuestionSet.id
);
if (!examQuestionSet) {
throw new Error(
`Attempt question set ${attemptQuestionSet.id} must exist in exam ${exam.id}`
);
}
const generatedQuestionSet = generatedQuestionSets.find(
({ id }) => id === attemptQuestionSet.id
);
if (!generatedQuestionSet) {
throw new Error(
`Generated question set ${attemptQuestionSet.id} must exist in generated exam ${generatedExam.id}`
);
}
const attemptQuestions = attemptQuestionSet.questions;
const examQuestions = examQuestionSet.questions;
const generatedQuestions = generatedQuestionSet.questions;
for (const attemptQuestion of attemptQuestions) {
const examQuestion = examQuestions.find(
({ id }) => id === attemptQuestion.id
);
if (!examQuestion) {
throw new Error(
`Attempt question ${attemptQuestion.id} must exist in exam ${exam.id}`
);
}
const generatedQuestion = generatedQuestions.find(
({ id }) => id === attemptQuestion.id
);
if (!generatedQuestion) {
throw new Error(
`Generated question ${attemptQuestion.id} must exist in generated exam ${generatedExam.id}`
);
}
const isQuestionCorrect = compareAnswers(
examQuestion.answers,
generatedQuestion.answers,
attemptQuestion.answers
);
if (isQuestionCorrect) {
correctQuestions += 1;
}
}
}
return (correctQuestions / totalQuestions) * 100;
}
/**
* NOTE: The answers of an attempt is an array for future-proofing when
* checkbox questions are needed.
*
* This calculation takes x / y , x < y as wholey incorrect.
*/
export function compareAnswers(
examAnswers: ExamEnvironmentAnswer[],
generatedAnswers: ExamEnvironmentGeneratedMultipleChoiceQuestion['answers'],
attemptAnswers: ExamEnvironmentMultipleChoiceQuestionAttempt['answers']
): boolean {
const correctGeneratedAnswers = generatedAnswers.filter(generatedAnswer => {
return examAnswers.some(
examAnswer => examAnswer.isCorrect && examAnswer.id === generatedAnswer
);
});
// Check every attempt question answer == every generated question answer
const isQuestionCorrect =
correctGeneratedAnswers.every(correctAnswer =>
attemptAnswers.includes(correctAnswer)
) && correctGeneratedAnswers.length == attemptAnswers.length;
return isQuestionCorrect;
}
/* eslint-disable jsdoc/require-description-complete-sentence */
/**
* Shuffles an array using the Fisher-Yates algorithm.
*
* https://bost.ocks.org/mike/shuffle/
*/
export function shuffleArray<T>(array: Array<T>) {
const arr = structuredClone(array);
let m = arr.length;
let t;
let i;
// While there remain elements to shuffle…
while (m) {
// Pick a remaining element…
i = Math.floor(Math.random() * m--);
// And swap it with the current element.
t = arr[m]!;
arr[m] = arr[i]!;
arr[i] = t;
}
return arr;
}
/* eslint-enable jsdoc/require-description-complete-sentence */
/**
* From an exam attempt, construct the attempt with result (if ready).
*
* @param fastify - Fastify instance.
* @param attempt - The exam attempt.
* @param logger - Logger instance.
* @returns The exam attempt with result or an error.
*/
export async function constructEnvExamAttempt(
fastify: FastifyInstance,
attempt: ExamEnvironmentExamAttempt,
logger: FastifyBaseLogger
) {
const maybeExam = await mapErr(
fastify.prisma.examEnvironmentExam.findUnique({
where: {
id: attempt.examId
}
})
);
if (maybeExam.hasError) {
fastify.Sentry?.captureException(maybeExam.error);
logger.error(
{ err: maybeExam.error, attemptId: attempt.id, examId: attempt.examId },
'Unable to query exam.'
);
return {
error: {
code: 500,
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
}
};
}
const exam = maybeExam.data;
if (exam === null) {
fastify.Sentry?.captureException({
data: { examId: attempt.examId, attemptId: attempt.id },
message: 'Unreachable. Invalid exam id in attempt.'
});
logger.error(
{ examId: attempt.examId, attemptId: attempt.id },
'Unreachable. Invalid exam id in attempt.'
);
return {
error: {
code: 500,
data: ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(
'Unreachable. Invalid exam id in attempt.'
)
}
};
}
// If attempt is still in progress, return without result
const attemptStartTimeInMS = attempt.startTime.getTime();
const examTotalTimeInMS = exam.config.totalTimeInS * 1000;
const isAttemptExpired =
attemptStartTimeInMS + examTotalTimeInMS < Date.now();
if (!isAttemptExpired) {
return {
examEnvironmentExamAttempt: {
...omitAttemptReferenceIds(attempt),
result: null,
status: ExamAttemptStatus.InProgress
},
error: null
};
}
const maybeMod = await mapErr(
fastify.prisma.examEnvironmentExamModeration.findFirst({
where: {
examAttemptId: attempt.id
}
})
);
if (maybeMod.hasError) {
fastify.Sentry?.captureException(maybeMod.error);
logger.error(
{ err: maybeMod.error, attemptId: attempt.id },
'Unable to query exam moderation.'
);
return {
error: {
code: 500,
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeMod.error))
}
};
}
const moderation = maybeMod.data;
// Attempt has expired, but moderation record does not exist
if (moderation === null) {
return {
examEnvironmentExamAttempt: {
...omitAttemptReferenceIds(attempt),
result: null,
status: ExamAttemptStatus.Expired
},
error: null
};
}
// If attempt is completed, but has not been graded, return without result
if (moderation.status === ExamEnvironmentExamModerationStatus.Pending) {
return {
examEnvironmentExamAttempt: {
...omitAttemptReferenceIds(attempt),
result: null,
status: ExamAttemptStatus.PendingModeration
},
error: null
};
}
// If attempt is completed, but has been determined to need a retake
// TODO: Send moderation.feedback?
if (moderation.status === ExamEnvironmentExamModerationStatus.Denied) {
return {
examEnvironmentExamAttempt: {
...omitAttemptReferenceIds(attempt),
result: null,
status: ExamAttemptStatus.Denied
},
error: null
};
}
const maybeGeneratedExam = await mapErr(
fastify.prisma.examEnvironmentGeneratedExam.findUnique({
where: {
id: attempt.generatedExamId
}
})
);
if (maybeGeneratedExam.hasError) {
fastify.Sentry?.captureException(maybeGeneratedExam.error);
logger.error(
{ err: maybeGeneratedExam.error, attemptId: attempt.id },
'Unable to query generated exam.'
);
return {
error: {
code: 500,
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
JSON.stringify(maybeGeneratedExam.error)
)
}
};
}
const generatedExam = maybeGeneratedExam.data;
if (!generatedExam) {
fastify.Sentry?.captureException({
data: {
attemptId: attempt.id,
generatedExamId: attempt.generatedExamId
},
message:
'Unreachable. Unable to find generated exam associated with exam attempt'
});
logger.error(
{ attemptId: attempt.id, generatedExamId: attempt.generatedExamId },
'Unreachable. Unable to find generated exam associated with exam attempt.'
);
return {
error: {
code: 500,
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
'Unreachable. Unable to find generated exam associated with exam attempt'
)
}
};
}
const score = calculateScore(exam, generatedExam, attempt);
const result = {
score,
passingPercent: exam.config.passingPercent
};
const examEnvironmentExamAttempt = {
...omitAttemptReferenceIds(attempt),
result,
status: ExamAttemptStatus.Approved
};
return { error: null, examEnvironmentExamAttempt };
}
function omitAttemptReferenceIds(attempt: ExamEnvironmentExamAttempt) {
return omit(attempt, ['generatedExamId', 'userId']);
}