fix(api): shuffle generated exam answers each time (#61352)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
Shaun Hamilton
2025-07-31 15:50:59 +02:00
committed by GitHub
parent bc0b47047f
commit 500342b24f
4 changed files with 62 additions and 36 deletions

View File

@@ -492,28 +492,28 @@ type SurveyResponse {
// ----------------------
model DailyCodingChallenges {
id String @id @default(auto()) @map("_id") @db.ObjectId
challengeNumber Int
date DateTime
title String
description String
javascript DailyCodingChallengeApiLanguage
python DailyCodingChallengeApiLanguage
id String @id @default(auto()) @map("_id") @db.ObjectId
challengeNumber Int
date DateTime
title String
description String
javascript DailyCodingChallengeApiLanguage
python DailyCodingChallengeApiLanguage
}
type DailyCodingChallengeApiLanguage {
tests DailyCodingChallengeApiLanguageTests[]
challengeFiles DailyCodingChallengeApiLanguageChallengeFiles[]
tests DailyCodingChallengeApiLanguageTests[]
challengeFiles DailyCodingChallengeApiLanguageChallengeFiles[]
}
type DailyCodingChallengeApiLanguageTests {
text String
testString String
text String
testString String
}
type DailyCodingChallengeApiLanguageChallengeFiles {
contents String
fileKey String
contents String
fileKey String
}
// ----------------------

View File

@@ -546,6 +546,8 @@ describe('/exam-environment/', () => {
});
it('should return the user exam with the exam attempt', async () => {
// Mock Math.random for `shuffleArray` to be equivalent between `/generated-exam` and `constructUserExam`
jest.spyOn(Math, 'random').mockReturnValue(0.123456789);
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
examId: mock.examId
};
@@ -576,12 +578,9 @@ describe('/exam-environment/', () => {
const userExam = constructUserExam(generatedExam!, mock.exam);
expect(res).toMatchObject({
status: 200,
body: {
examAttempt,
exam: userExam
}
expect(res.body).toMatchObject({
examAttempt,
exam: userExam
});
});
});

View File

@@ -13,7 +13,8 @@ import {
generateExam,
userAttemptToDatabaseAttemptQuestionSets,
validateAttempt,
compareAnswers
compareAnswers,
shuffleArray
} from './exam-environment';
// NOTE: Whilst the tests could be run against a single generation of exam,
@@ -21,9 +22,13 @@ import {
// This helps ensure the config/logic is _reasonably_ likely to be able to
// generate a valid exam.
// Another option is to call `generateExam` hundreds of times in a loop test :shrug:
describe('Exam Environment', () => {
describe('Exam Environment mocked Math.random', () => {
let spy: jest.SpyInstance;
beforeAll(() => {
jest.spyOn(Math, 'random').mockReturnValue(0.123456789);
spy = jest.spyOn(Math, 'random').mockReturnValue(0.123456789);
});
afterAll(() => {
spy.mockRestore();
});
describe('checkAttemptAgainstGeneratedExam()', () => {
it('should return true if all questions are answered', () => {
@@ -84,13 +89,6 @@ describe('Exam Environment', () => {
});
});
describe('constructUserExam()', () => {
it('should not provide the answers', () => {
const userExam = constructUserExam(generatedExam, exam);
expect(userExam).not.toHaveProperty('answers.isCorrect');
});
});
describe('generateExam()', () => {
it('should generate a randomized exam without throwing', () => {
const _randomizedExam = generateExam(exam);
@@ -385,3 +383,28 @@ describe('Exam Environment', () => {
});
});
});
describe('Exam Environment', () => {
describe('constructUserExam()', () => {
it('should not provide the answers', () => {
const userExam = constructUserExam(generatedExam, exam);
expect(userExam).not.toHaveProperty('answers.isCorrect');
});
});
describe('shuffleArray()', () => {
it('reasonably shuffles an array', () => {
const unshuff = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const shuff = shuffleArray(unshuff);
expect(shuff).not.toEqual(unshuff);
});
it('does not mutate the input', () => {
const unshuff = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
shuffleArray(unshuff);
expect(unshuff).toEqual(unshuff);
});
});
});

View File

@@ -82,11 +82,14 @@ export function constructUserExam(
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
answers: shuffledAnswers
};
});
@@ -731,8 +734,9 @@ function getRandomAnswers(
*
* https://bost.ocks.org/mike/shuffle/
*/
function shuffleArray<T>(array: Array<T>) {
let m = array.length;
export function shuffleArray<T>(array: Array<T>) {
const arr = structuredClone(array);
let m = arr.length;
let t;
let i;
@@ -742,12 +746,12 @@ function shuffleArray<T>(array: Array<T>) {
i = Math.floor(Math.random() * m--);
// And swap it with the current element.
t = array[m]!;
array[m] = array[i]!;
array[i] = t;
t = arr[m]!;
arr[m] = arr[i]!;
arr[i] = t;
}
return array;
return arr;
}
/* eslint-enable jsdoc/require-description-complete-sentence */