feat(api): create endpoints for exams (#51062)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2023-08-03 09:34:47 -05:00
committed by GitHub
parent 8b9ca4c3ab
commit 80dba8fd30
17 changed files with 909 additions and 29 deletions

View File

@@ -0,0 +1,91 @@
export const examJson = {
id: 1,
numberOfQuestionsInExam: 1,
passingPercent: 70,
questions: [
{
id: '3bbl2mx2mq',
question: 'Question 1?',
wrongAnswers: [
{ id: 'ex7hii9zup', answer: 'Q1: Wrong Answer 1' },
{ id: 'lmr1ew7m67', answer: 'Q1: Wrong Answer 2' },
{ id: 'qh5sz9qdiq', answer: 'Q1: Wrong Answer 3' },
{ id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' },
{ id: '7vu84wl4lc', answer: 'Q1: Wrong Answer 5' },
{ id: 'em59kw6avu', answer: 'Q1: Wrong Answer 6' }
],
correctAnswers: [
{ id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' },
{ id: 'f5gk39ske9', answer: 'Q1: Correct Answer 2' }
]
},
{
id: 'oqis5gzs0h',
question: 'Question 2?',
wrongAnswers: [
{ id: 'ojhnoxh5r5', answer: 'Q2: Wrong Answer 1' },
{ id: 'onx06if0uh', answer: 'Q2: Wrong Answer 2' },
{ id: 'zbxnsko712', answer: 'Q2: Wrong Answer 3' },
{ id: 'bqv5y68jyp', answer: 'Q2: Wrong Answer 4' },
{ id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' },
{ id: 'wycrnloajd', answer: 'Q2: Wrong Answer 6' }
],
correctAnswers: [
{ id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' },
{ id: 'agert35dk0', answer: 'Q1: Correct Answer 2' }
]
}
]
};
// failed
export const userExam1 = {
userExamQuestions: [
{
id: '3bbl2mx2mq',
question: 'Question 1?',
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
},
{
id: 'oqis5gzs0h',
question: 'Question 2?',
answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }
}
],
examTimeInSeconds: 20
};
// passed
export const userExam2 = {
userExamQuestions: [
{
id: '3bbl2mx2mq',
question: 'Question 1?',
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
},
{
id: 'oqis5gzs0h',
question: 'Question 2?',
answer: { id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' }
}
],
examTimeInSeconds: 20
};
export const mockResults1 = {
numberOfCorrectAnswers: 1,
numberOfQuestionsInExam: 2,
percentCorrect: 50,
passingPercent: 70,
passed: false,
examTimeInSeconds: 20
};
export const mockResults2 = {
numberOfCorrectAnswers: 2,
numberOfQuestionsInExam: 2,
percentCorrect: 100,
passingPercent: 70,
passed: true,
examTimeInSeconds: 20
};

View File

@@ -0,0 +1,160 @@
import Joi from 'joi';
import JoiObjectId from 'joi-objectid';
Joi.objectId = JoiObjectId(Joi);
const nanoIdRE = new RegExp('[a-z0-9]{10}');
// Exam from database schema
const DbPrerequisitesJoi = Joi.object().keys({
id: Joi.objectId().required(),
title: Joi.string()
});
const DbAnswerJoi = Joi.object().keys({
id: Joi.string().regex(nanoIdRE).required(),
deprecated: Joi.bool(),
answer: Joi.string().required()
});
const DbQuestionJoi = Joi.object().keys({
id: Joi.string().regex(nanoIdRE).required(),
question: Joi.string().required(),
deprecated: Joi.bool(),
wrongAnswers: Joi.array()
.items(DbAnswerJoi)
.required()
.custom((value, helpers) => {
const nonDeprecatedCount = value.reduce(
(count, answer) => (answer.deprecated ? count : count + 1),
0
);
const minimumAnswers = 4;
if (nonDeprecatedCount < minimumAnswers) {
return helpers.message(
`'wrongAnswers' must have at least ${minimumAnswers} non-deprecated answers.`
);
}
return value;
}),
correctAnswers: Joi.array()
.items(DbAnswerJoi)
.required()
.custom((value, helpers) => {
const nonDeprecatedCount = value.reduce(
(count, answer) => (answer.deprecated ? count : count + 1),
0
);
const minimumAnswers = 1;
if (nonDeprecatedCount < minimumAnswers) {
return helpers.message(
`'correctAnswers' must have at least ${minimumAnswers} non-deprecated answer.`
);
}
return value;
})
});
const examFromDbSchema = Joi.object().keys({
// TODO: make sure _id and title match what's in the challenge markdown file
id: Joi.objectId().required(),
title: Joi.string().required(),
numberOfQuestionsInExam: Joi.number()
.min(1)
.max(
Joi.ref('questions', {
adjust: questions => {
const nonDeprecatedCount = questions.reduce(
(count, question) => (question.deprecated ? count : count + 1),
0
);
return nonDeprecatedCount;
}
})
)
.required(),
passingPercent: Joi.number().min(0).max(100).required(),
prerequisites: Joi.array().items(DbPrerequisitesJoi),
questions: Joi.array().items(DbQuestionJoi).min(1).required()
});
export const validateExamFromDbSchema = exam => {
return examFromDbSchema.validate(exam);
};
// Generated Exam Schema
const GeneratedAnswerJoi = Joi.object().keys({
id: Joi.string().regex(nanoIdRE).required(),
answer: Joi.string().required()
});
const GeneratedQuestionJoi = Joi.object().keys({
id: Joi.string().regex(nanoIdRE).required(),
question: Joi.string().required(),
answers: Joi.array().items(GeneratedAnswerJoi).min(5).required()
});
const generatedExamSchema = Joi.array()
.items(GeneratedQuestionJoi)
.min(1)
.required();
export const validateGeneratedExamSchema = (exam, numberOfQuestionsInExam) => {
if (!exam.length === numberOfQuestionsInExam) {
throw new Error(
'The number of exam questions generated does not match the number of questions required.'
);
}
return generatedExamSchema.validate(exam);
};
// User Completed Exam Schema
const UserCompletedQuestionJoi = Joi.object().keys({
id: Joi.string().regex(nanoIdRE).required(),
question: Joi.string().required(),
answer: Joi.object().keys({
id: Joi.string().regex(nanoIdRE).required(),
answer: Joi.string().required()
})
});
const userCompletedExamSchema = Joi.object().keys({
userExamQuestions: Joi.array()
.items(UserCompletedQuestionJoi)
.min(1)
.required(),
examTimeInSeconds: Joi.number().min(0)
});
export const validateUserCompletedExamSchema = (
exam,
numberOfQuestionsInExam
) => {
// TODO: Validate that the properties exist
if (!exam.length === numberOfQuestionsInExam) {
throw new Error(
'The number of exam questions answered does not match the number of questions required.'
);
}
return userCompletedExamSchema.validate(exam);
};
// Exam Results Schema
const examResultsSchema = Joi.object().keys({
numberOfCorrectAnswers: Joi.number().min(0),
numberOfQuestionsInExam: Joi.number().min(0),
percentCorrect: Joi.number().min(0),
passingPercent: Joi.number().min(0).max(100),
passed: Joi.bool(),
examTimeInSeconds: Joi.number().min(0)
});
export const validateExamResultsSchema = examResults => {
return examResultsSchema.validate(examResults);
};

View File

@@ -0,0 +1,103 @@
function shuffleArray(arr) {
let currentIndex = arr.length,
randomIndex;
while (currentIndex != 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[arr[currentIndex], arr[randomIndex]] = [
arr[randomIndex],
arr[currentIndex]
];
}
return arr;
}
function filterDeprecated(arr) {
return arr.filter(i => !i.deprecated);
}
function getRandomElement(arr) {
const id = Math.floor(Math.random() * arr.length);
return arr[id];
}
// Used to generate a random exam
export function generateRandomExam(examJson) {
const { numberOfQuestionsInExam, questions } = examJson;
const numberOfAnswersPerQuestion = 5;
const availableQuestions = shuffleArray(filterDeprecated(questions));
const examQuestions = availableQuestions.slice(0, numberOfQuestionsInExam);
const randomizedExam = examQuestions.map(question => {
const { correctAnswers, wrongAnswers } = question;
const availableCorrectAnswers = filterDeprecated(correctAnswers);
const availableWrongAnswers = shuffleArray(filterDeprecated(wrongAnswers));
const correctAnswer = getRandomElement(availableCorrectAnswers);
const answers = shuffleArray([
correctAnswer,
...availableWrongAnswers.slice(0, numberOfAnswersPerQuestion - 1)
]);
return {
id: question.id,
question: question.question,
answers
};
});
return randomizedExam;
}
// Used to evaluate user completed exams
export function createExamResults(userExam, originalExam) {
const { userExamQuestions, examTimeInSeconds } = userExam;
/**
* Potential Bug:
* numberOfQuestionsInExam and passingPercent come from the exam in the database.
* If either changes between the time a camper starts and submits, it could skew
* the scores. The alternative is to send those to the client and then get them
* back from the client - but then they could be manipulated to cheat. So I think
* this is the way to go. They are unlikely to change, as that would be unfair. We
* could get numberOfQuestionsInExam from userExamQuestions.length - so only the
* passingPercent would come from the database. Maybe that would be better.
*/
const {
questions: originalQuestions,
numberOfQuestionsInExam,
passingPercent
} = originalExam;
const numberOfCorrectAnswers = userExamQuestions.reduce(
(count, userQuestion) => {
const originalQuestion = originalQuestions.find(
examQuestion => examQuestion.id === userQuestion.id
);
if (!originalQuestion) {
throw new Error('An error occurred. Could not find exam question.');
}
const isCorrect = originalQuestion.correctAnswers.find(
examAnswer => examAnswer.id === userQuestion.answer.id
);
return isCorrect ? count + 1 : count;
},
0
);
// Percent rounded to one decimal place
const percent = (numberOfCorrectAnswers / numberOfQuestionsInExam) * 100;
const percentCorrect = Math.round(percent * 10) / 10;
const passed = percentCorrect >= passingPercent;
return {
numberOfCorrectAnswers,
numberOfQuestionsInExam,
percentCorrect,
passingPercent,
passed,
examTimeInSeconds
};
}

View File

@@ -0,0 +1,54 @@
import { generateRandomExam, createExamResults } from './exam';
import {
examJson,
userExam1,
userExam2,
mockResults1,
mockResults2
} from './__mocks__/exam';
describe('Exam helpers', () => {
describe('generateRandomExam()', () => {
const randomizedExam = generateRandomExam(examJson);
it('should have one question', () => {
expect(randomizedExam.length).toBe(1);
});
it('should have five answers', () => {
const firstQuestion = randomizedExam[0];
expect(firstQuestion.answers.length).toBe(5);
});
it('should have exactly one correct answer', () => {
const question = randomizedExam[0];
const questionId = question.id;
const originalQuestion = examJson.questions.find(
q => q.id === questionId
);
const originalCorrectAnswer = originalQuestion.correctAnswers;
const correctIds = originalCorrectAnswer.map(a => a.id);
const numberOfCorrectAnswers = question.answers.filter(a =>
correctIds.includes(a.id)
);
expect(numberOfCorrectAnswers).toHaveLength(1);
});
});
describe('createExamResults()', () => {
examJson.numberOfQuestionsInExam = 2;
const examResults1 = createExamResults(userExam1, examJson);
const examResults2 = createExamResults(userExam2, examJson);
it('failing exam should return correct results', () => {
expect(examResults1).toEqual(mockResults1);
});
it('passing exam should return correct results', () => {
expect(examResults2).toEqual(mockResults2);
});
});
});

View File

@@ -4,6 +4,7 @@ export const publicUserProps = [
'about',
'calendar',
'completedChallenges',
'completedExams',
'githubProfile',
'isApisMicroservicesCert',
'isBackEndCert',