Compare commits

...

6 commits

Author SHA1 Message Date
Edwin S Lopez
7afc81942a
Merge 81bedf0672 into b92f81cc0e 2025-03-21 10:36:16 -04:00
C. Fuhrman
b92f81cc0e tests passing
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 19s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 18s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m5s
Tests / lint-and-tests (server) (push) Failing after 59s
2025-03-21 09:31:36 -04:00
C. Fuhrman
42e3041830 first cut, with tests
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 1m1s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 59s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m27s
Tests / lint-and-tests (server) (push) Failing after 1m3s
2025-03-21 00:25:25 -04:00
Christopher (Cris) Fuhrman
112062c0b2
Merge pull request #284 from ets-cfuhrman-pfe/fuhrmanator/issue283
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 0s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 0s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 0s
Tests / lint-and-tests (client) (push) Failing after 0s
Tests / lint-and-tests (server) (push) Failing after 0s
[BUG] étudiant qui se joint à une salle après le démarrage du quiz es…
2025-03-11 02:52:58 -04:00
C. Fuhrman
29de2a7671 Correction de bogue trouvé par test! 2025-03-09 01:19:31 -05:00
C. Fuhrman
fe67f020eb [BUG] étudiant qui se joint à une salle après le démarrage du quiz est bloqué
Fixes #283
Valeurs de l'état de la page (quizStarted) n'ont pas leur valeur actuelle dans un on(). Alors, on déplace la logique du traitement du nouvel étudiant dans un useEffect et on provoque le useEffect dans le on()
2025-03-09 00:54:21 -05:00
22 changed files with 827 additions and 273 deletions

View file

@ -19,8 +19,8 @@ const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) =>
});
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: 'Answer 1', isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: 'Answer 2', isCorrect: false }] },
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockShowSelectedQuestion = jest.fn();
@ -92,4 +92,4 @@ describe('LiveResults', () => {
expect(mockShowSelectedQuestion).toHaveBeenCalled();
});
});
});

View file

@ -20,8 +20,8 @@ const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) =>
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: 'Answer 1', isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: 'Answer 2', isCorrect: false }] },
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockShowSelectedQuestion = jest.fn();

View file

@ -20,8 +20,8 @@ const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) =>
});
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: 'Answer 1', isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: 'Answer 2', isCorrect: false }] },
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockGetStudentGrade = jest.fn((student: StudentType) => {

View file

@ -6,8 +6,8 @@ import LiveResultsTableFooter from 'src/components/LiveResults/LiveResultsTable/
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: 'Answer 1', isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: 'Answer 2', isCorrect: false }] },
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockGetStudentGrade = jest.fn((student: StudentType) => {
@ -52,4 +52,4 @@ describe('LiveResultsTableFooter', () => {
expect(screen.getByText('50 %')).toBeInTheDocument();
});
});
});

View file

@ -5,7 +5,7 @@ import LiveResults from 'src/components/LiveResults/LiveResults';
import { QuestionType } from 'src/Types/QuestionType';
import { StudentType } from 'src/Types/StudentType';
import { Socket } from 'socket.io-client';
import { BaseQuestion,parse } from 'gift-pegjs';
import { BaseQuestion, parse } from 'gift-pegjs';
const mockSocket: Socket = {
on: jest.fn(),
@ -19,19 +19,28 @@ const mockGiftQuestions = parse(
`::Sample Question 1:: Question stem
{
=Choice 1
~Choice 2
}`);
=Choice 2
~Choice 3
~Choice 4
}
::Sample Question 2:: Question stem {TRUE}
`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
return { question: newMockQuestion as BaseQuestion };
});
console.log(`mockQuestions: ${JSON.stringify(mockQuestions)}`);
// each student should have a different score for the tests to pass
const mockStudents: StudentType[] = [
{ id: '1', name: 'Student 1', answers: [{ idQuestion: 1, answer: 'Choice 1', isCorrect: true }] },
{ id: '2', name: 'Student 2', answers: [{ idQuestion: 1, answer: 'Choice 2', isCorrect: false }] },
{ id: '1', name: 'Student 1', answers: [] },
{ id: '2', name: 'Student 2', answers: [{ idQuestion: 1, answer: ['Choice 3'], isCorrect: false }, { idQuestion: 2, answer: [true], isCorrect: true}] },
{ id: '3', name: 'Student 3', answers: [{ idQuestion: 1, answer: ['Choice 1', 'Choice 2'], isCorrect: true }, { idQuestion: 2, answer: [true], isCorrect: true}] },
];
describe('LiveResults', () => {
@ -52,7 +61,7 @@ describe('LiveResults', () => {
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the component renders the students
mockStudents.forEach((student) => {
expect(screen.getByText(student.name)).toBeInTheDocument();
@ -82,82 +91,88 @@ describe('LiveResults', () => {
});
});
});
test('calculates and displays the correct student grades', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
test('calculates and displays the correct student grades', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the student grades are calculated and displayed correctly
mockStudents.forEach((student) => {
const grade = student.answers.filter(answer => answer.isCorrect).length / mockQuestions.length * 100;
expect(screen.getByText(`${grade.toFixed()} %`)).toBeInTheDocument();
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the student grades are calculated and displayed correctly
const getByTextInTableCellBody = (text: string) => {
const elements = screen.getAllByText(text); // Get all elements with the specified text
return elements.find((element) => element.closest('.MuiTableCell-body')); // don't get the footer element(s)
};
mockStudents.forEach((student) => {
const grade = student.answers.filter(answer => answer.isCorrect).length / mockQuestions.length * 100;
const element = getByTextInTableCellBody(`${grade.toFixed()} %`);
expect(element).toBeInTheDocument();
});
});
});
test('calculates and displays the class average', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
test('calculates and displays the class average', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Calculate the class average
const totalGrades = mockStudents.reduce((total, student) => {
return total + (student.answers.filter(answer => answer.isCorrect).length / mockQuestions.length * 100);
}, 0);
const classAverage = totalGrades / mockStudents.length;
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the class average is displayed correctly
const classAverageElements = screen.getAllByText(`${classAverage.toFixed()} %`);
const classAverageElement = classAverageElements.find((element) => {
return element.closest('td')?.classList.contains('MuiTableCell-footer');
});
expect(classAverageElement).toBeInTheDocument();
});
// Calculate the class average
const totalGrades = mockStudents.reduce((total, student) => {
return total + (student.answers.filter(answer => answer.isCorrect).length / mockQuestions.length * 100);
}, 0);
const classAverage = totalGrades / mockStudents.length;
test('displays the correct answers per question', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Check if the correct answers per question are displayed correctly
mockQuestions.forEach((_, index) => {
const correctAnswers = mockStudents.filter(student => student.answers.some(answer => answer.idQuestion === index + 1 && answer.isCorrect)).length;
const correctAnswersPercentage = (correctAnswers / mockStudents.length) * 100;
const correctAnswersElements = screen.getAllByText(`${correctAnswersPercentage.toFixed()} %`);
const correctAnswersElement = correctAnswersElements.find((element) => {
return element.closest('td')?.classList.contains('MuiTableCell-root');
// Check if the class average is displayed correctly
const classAverageElements = screen.getAllByText(`${classAverage.toFixed()} %`);
const classAverageElement = classAverageElements.find((element) => {
return element.closest('td')?.classList.contains('MuiTableCell-footer');
});
expect(correctAnswersElement).toBeInTheDocument();
expect(classAverageElement).toBeInTheDocument();
});
});
test('displays the correct answers per question', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Check if the correct answers per question are displayed correctly
mockQuestions.forEach((_, index) => {
const correctAnswers = mockStudents.filter(student => student.answers.some(answer => answer.idQuestion === index + 1 && answer.isCorrect)).length;
const correctAnswersPercentage = (correctAnswers / mockStudents.length) * 100;
const correctAnswersElements = screen.getAllByText(`${correctAnswersPercentage.toFixed()} %`);
const correctAnswersElement = correctAnswersElements.find((element) => {
return element.closest('td')?.classList.contains('MuiTableCell-root');
});
expect(correctAnswersElement).toBeInTheDocument();
});
});
});

View file

@ -57,6 +57,7 @@ describe('MultipleChoiceQuestionDisplay', () => {
fireEvent.click(submitButton);
});
expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled();
mockHandleOnSubmitAnswer.mockClear();
});
test('submits the selected answer', () => {
@ -70,9 +71,35 @@ describe('MultipleChoiceQuestionDisplay', () => {
fireEvent.click(submitButton);
});
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith('Choice 1');
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(['Choice 1']);
mockHandleOnSubmitAnswer.mockClear();
});
test('submits multiple selected answers', () => {
const choiceButton1 = screen.getByText('Choice 1').closest('button');
const choiceButton2 = screen.getByText('Choice 2').closest('button');
if (!choiceButton1 || !choiceButton2) throw new Error('Choice buttons not found');
// Simulate selecting multiple answers
act(() => {
fireEvent.click(choiceButton1);
});
act(() => {
fireEvent.click(choiceButton2);
});
// Simulate submitting the answers
const submitButton = screen.getByText('Répondre');
act(() => {
fireEvent.click(submitButton);
});
// Verify that the mockHandleOnSubmitAnswer function is called with both answers
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(['Choice 1', 'Choice 2']);
mockHandleOnSubmitAnswer.mockClear();
});
it('should show ✅ next to the correct answer and ❌ next to the wrong answers when showAnswer is true', async () => {
const choiceButton = screen.getByText('Choice 1').closest('button');
if (!choiceButton) throw new Error('Choice button not found');

View file

@ -67,6 +67,7 @@ describe('NumericalQuestion Component', () => {
fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled();
mockHandleOnSubmitAnswer.mockClear();
});
it('submits answer correctly', () => {
@ -77,6 +78,7 @@ describe('NumericalQuestion Component', () => {
fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(7);
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith([7]);
mockHandleOnSubmitAnswer.mockClear();
});
});

View file

@ -29,23 +29,24 @@ describe('Questions Component', () => {
render(<QuestionDisplay question={question} {...sampleProps} />);
};
describe('question type parsing', () => {
it('parses true/false question type correctly', () => {
expect(sampleTrueFalseQuestion.type).toBe('TF');
});
// describe('question type parsing', () => {
// it('parses true/false question type correctly', () => {
// expect(sampleTrueFalseQuestion.type).toBe('TF');
// });
it('parses multiple choice question type correctly', () => {
expect(sampleMultipleChoiceQuestion.type).toBe('MC');
});
// it('parses multiple choice question type correctly', () => {
// expect(sampleMultipleChoiceQuestion.type).toBe('MC');
// });
it('parses numerical question type correctly', () => {
expect(sampleNumericalQuestion.type).toBe('Numerical');
});
// it('parses numerical question type correctly', () => {
// expect(sampleNumericalQuestion.type).toBe('Numerical');
// });
// it('parses short answer question type correctly', () => {
// expect(sampleShortAnswerQuestion.type).toBe('Short');
// });
// });
it('parses short answer question type correctly', () => {
expect(sampleShortAnswerQuestion.type).toBe('Short');
});
});
it('renders correctly for True/False question', () => {
renderComponent(sampleTrueFalseQuestion);
@ -73,7 +74,8 @@ describe('Questions Component', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('Choice 1');
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['Choice 1']);
mockHandleSubmitAnswer.mockClear();
});
it('renders correctly for Numerical question', () => {
@ -93,7 +95,8 @@ describe('Questions Component', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(7);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([7]);
mockHandleSubmitAnswer.mockClear();
});
it('renders correctly for Short Answer question', () => {
@ -117,7 +120,7 @@ describe('Questions Component', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input');
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']);
});
});

View file

@ -47,6 +47,7 @@ describe('ShortAnswerQuestion Component', () => {
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
mockHandleSubmitAnswer.mockClear();
});
it('submits answer correctly', () => {
@ -60,6 +61,7 @@ describe('ShortAnswerQuestion Component', () => {
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input');
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']);
mockHandleSubmitAnswer.mockClear();
});
});

View file

@ -56,6 +56,7 @@ describe('TrueFalseQuestion Component', () => {
});
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
mockHandleSubmitAnswer.mockClear();
});
it('submits answer correctly for True', () => {
@ -70,7 +71,8 @@ describe('TrueFalseQuestion Component', () => {
fireEvent.click(submitButton);
});
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(true);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([true]);
mockHandleSubmitAnswer.mockClear();
});
it('submits answer correctly for False', () => {
@ -83,7 +85,8 @@ describe('TrueFalseQuestion Component', () => {
fireEvent.click(submitButton);
});
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(false);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([false]);
mockHandleSubmitAnswer.mockClear();
});
@ -112,7 +115,7 @@ describe('TrueFalseQuestion Component', () => {
expect(wrongAnswer1?.textContent).toContain('❌');
});
it('should not show ✅ or ❌ when repondre button is not clicked', async () => {
it('should not show ✅ or ❌ when pondre button is not clicked', async () => {
const choiceButton = screen.getByText('Vrai').closest('button');
if (!choiceButton) throw new Error('Choice button not found');

View file

@ -0,0 +1,353 @@
import { checkIfIsCorrect } from 'src/pages/Teacher/ManageRoom/useRooms';
import { HighLowNumericalAnswer, MultipleChoiceQuestion, MultipleNumericalAnswer, NumericalQuestion, RangeNumericalAnswer, ShortAnswerQuestion, SimpleNumericalAnswer, TrueFalseQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
import { QuestionType } from 'src/Types/QuestionType';
describe('checkIfIsCorrect', () => {
const mockQuestions: QuestionType[] = [
{
question: {
id: '1',
type: 'MC',
choices: [
{ isCorrect: true, formattedText: { text: 'Answer1' } },
{ isCorrect: true, formattedText: { text: 'Answer2' } },
{ isCorrect: false, formattedText: { text: 'Answer3' } },
],
} as MultipleChoiceQuestion,
},
];
test('returns true when all selected answers are correct', () => {
const answer: AnswerType = ['Answer1', 'Answer2'];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(true);
});
test('returns false when some selected answers are incorrect', () => {
const answer: AnswerType = ['Answer1', 'Answer3'];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(false);
});
test('returns false when no answers are selected', () => {
const answer: AnswerType = [];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(false);
});
test('returns false when no correct answers are provided in the question', () => {
const mockQuestionsWithNoCorrectAnswers: QuestionType[] = [
{
question: {
id: '1',
type: 'MC',
choices: [
{ isCorrect: false, formattedText: { text: 'Answer1' } },
{ isCorrect: false, formattedText: { text: 'Answer2' } },
],
} as MultipleChoiceQuestion,
},
];
const answer: AnswerType = ['Answer1'];
const result = checkIfIsCorrect(answer, 1, mockQuestionsWithNoCorrectAnswers);
expect(result).toBe(false);
});
test('returns true for a correct true/false answer', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '2',
type: 'TF',
isTrue: true,
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [true];
const result = checkIfIsCorrect(answer, 2, mockQuestionsTF);
expect(result).toBe(true);
});
test('returns false for an incorrect true/false answer', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '2',
type: 'TF',
isTrue: true,
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [false];
const result = checkIfIsCorrect(answer, 2, mockQuestionsTF);
expect(result).toBe(false);
});
test('returns false for a true/false question with no answer', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '2',
type: 'TF',
isTrue: true,
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [];
const result = checkIfIsCorrect(answer, 2, mockQuestionsTF);
expect(result).toBe(false);
});
test('returns true for a correct true/false answer when isTrue is false', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '3',
type: 'TF',
isTrue: false, // Correct answer is false
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [false];
const result = checkIfIsCorrect(answer, 3, mockQuestionsTF);
expect(result).toBe(true);
});
test('returns false for an incorrect true/false answer when isTrue is false', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '3',
type: 'TF',
isTrue: false, // Correct answer is false
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [true];
const result = checkIfIsCorrect(answer, 3, mockQuestionsTF);
expect(result).toBe(false);
});
test('returns true for a correct short answer', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = ['CorrectAnswer1'];
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(true);
});
test('returns false for an incorrect short answer', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = ['WrongAnswer'];
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(false);
});
test('returns true for a correct short answer with case insensitivity', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = ['correctanswer1']; // Lowercase version of the correct answer
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(true);
});
test('returns false for a short answer question with no answer', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = [];
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(false);
});
test('returns true for a correct simple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '5',
type: 'Numerical',
choices: [
{ type: 'simple', number: 42 } as SimpleNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [42]; // User's answer
const result = checkIfIsCorrect(answer, 5, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an incorrect simple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '5',
type: 'Numerical',
choices: [
{ type: 'simple', number: 42 } as SimpleNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [43]; // User's answer
const result = checkIfIsCorrect(answer, 5, mockQuestionsNumerical);
expect(result).toBe(false);
});
test('returns true for a correct range numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '6',
type: 'Numerical',
choices: [
{ type: 'range', number: 50, range: 5 } as RangeNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [52]; // User's answer within the range (50 ± 5)
const result = checkIfIsCorrect(answer, 6, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an out-of-range numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '6',
type: 'Numerical',
choices: [
{ type: 'range', number: 50, range: 5 } as RangeNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [56]; // User's answer outside the range (50 ± 5)
const result = checkIfIsCorrect(answer, 6, mockQuestionsNumerical);
expect(result).toBe(false);
});
test('returns true for a correct high-low numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '7',
type: 'Numerical',
choices: [
{ type: 'high-low', numberHigh: 100, numberLow: 90 } as HighLowNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [95]; // User's answer within the range (90 to 100)
const result = checkIfIsCorrect(answer, 7, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an out-of-range high-low numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '7',
type: 'Numerical',
choices: [
{ type: 'high-low', numberHigh: 100, numberLow: 90 } as HighLowNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [105]; // User's answer outside the range (90 to 100)
const result = checkIfIsCorrect(answer, 7, mockQuestionsNumerical);
expect(result).toBe(false);
});
test('returns true for a correct multiple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '8',
type: 'Numerical',
choices: [
{
isCorrect: true,
answer: { type: 'simple', number: 42 } as SimpleNumericalAnswer,
} as MultipleNumericalAnswer,
{
isCorrect: false,
answer: { type: 'high-low', numberHigh: 100, numberLow: 90 } as HighLowNumericalAnswer,
formattedFeedback: { text: 'You guessed way too high' },
}
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [42]; // User's answer matches the correct multiple numerical answer
const result = checkIfIsCorrect(answer, 8, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an incorrect multiple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '8',
type: 'Numerical',
choices: [
{
type: 'multiple',
isCorrect: true,
answer: { type: 'simple', number: 42 } as SimpleNumericalAnswer,
} as MultipleNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [43]; // User's answer does not match the correct multiple numerical answer
const result = checkIfIsCorrect(answer, 8, mockQuestionsNumerical);
expect(result).toBe(false);
});
});

View file

@ -44,7 +44,7 @@ const mockStudents: StudentType[] = [
];
const mockAnswerData: AnswerReceptionFromBackendType = {
answer: 'Answer1',
answer: ['Answer1'],
idQuestion: 1,
idUser: '1',
username: 'Student 1',
@ -233,7 +233,7 @@ describe('ManageRoom', () => {
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
createSuccessCallback('Test Room');
});
const launchButton = screen.getByText('Lancer');
@ -256,6 +256,7 @@ describe('ManageRoom', () => {
});
await waitFor(() => {
// console.info(consoleSpy.mock.calls);
expect(consoleSpy).toHaveBeenCalledWith(
'Received answer from Student 1 for question 1: Answer1'
);
@ -294,3 +295,4 @@ describe('ManageRoom', () => {
});
});
});

View file

@ -8,7 +8,7 @@ import { QuestionType } from 'src/Types/QuestionType';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Option A ~Option B}
`::Sample Question 1:: Sample Question 1 {=Option A =Option B ~Option C}
::Sample Question 2:: Sample Question 2 {T}`);
@ -23,9 +23,6 @@ const mockSubmitAnswer = jest.fn();
const mockDisconnectWebSocket = jest.fn();
beforeEach(() => {
// Clear local storage before each test
// localStorage.clear();
render(
<MemoryRouter>
<StudentModeQuiz
@ -54,7 +51,7 @@ describe('StudentModeQuiz', () => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1);
});
test('handles shows feedback for an already answered question', async () => {
@ -65,13 +62,13 @@ describe('StudentModeQuiz', () => {
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1);
const firstButtonA = screen.getByRole("button", {name: '✅ A Option A'});
expect(firstButtonA).toBeInTheDocument();
expect(firstButtonA.querySelector('.selected')).toBeInTheDocument();
expect(screen.getByRole("button", {name: ' B Option B'})).toBeInTheDocument();
expect(screen.getByRole("button", {name: ' B Option B'})).toBeInTheDocument();
expect(screen.queryByText('Répondre')).not.toBeInTheDocument();
// Navigate to the next question
@ -87,12 +84,12 @@ describe('StudentModeQuiz', () => {
});
expect(await screen.findByText('Sample Question 1')).toBeInTheDocument();
// Since answers are mocked, the it doesn't recognize the question as already answered
// Since answers are mocked, it doesn't recognize the question as already answered
// TODO these tests are partially faked, need to be fixed if we can mock the answers
// const buttonA = screen.getByRole("button", {name: '✅ A Option A'});
const buttonA = screen.getByRole("button", {name: 'A Option A'});
expect(buttonA).toBeInTheDocument();
// const buttonB = screen.getByRole("button", {name: ' B Option B'});
// const buttonB = screen.getByRole("button", {name: ' B Option B'});
const buttonB = screen.getByRole("button", {name: 'B Option B'});
expect(buttonB).toBeInTheDocument();
// // "Option A" div inside the name of button should have selected class
@ -122,4 +119,28 @@ describe('StudentModeQuiz', () => {
expect(screen.getByText('Sample Question 2')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
test('allows multiple answers to be selected for a question', async () => {
// Simulate selecting multiple answers
act(() => {
fireEvent.click(screen.getByText('Option A'));
});
act(() => {
fireEvent.click(screen.getByText('Option B'));
});
// Simulate submitting the answers
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
// Verify that the mockSubmitAnswer function is called with both answers
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A', 'Option B'], 1);
// Verify that the selected answers are displayed as selected
const buttonA = screen.getByRole('button', { name: '✅ A Option A' });
const buttonB = screen.getByRole('button', { name: '✅ B Option B' });
expect(buttonA).toBeInTheDocument();
expect(buttonB).toBeInTheDocument();
});
});

View file

@ -63,7 +63,8 @@ describe('TeacherModeQuiz', () => {
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1);
mockSubmitAnswer.mockClear();
});
test('handles shows feedback for an already answered question', () => {
@ -74,7 +75,8 @@ describe('TeacherModeQuiz', () => {
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1);
mockSubmitAnswer.mockClear();
mockQuestion = mockQuestions[1].question as MultipleChoiceQuestion;
// Navigate to the next question by re-rendering with new props
act(() => {

View file

@ -51,7 +51,7 @@ const LiveResultsTableFooter: React.FC<LiveResultsFooterProps> = ({
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
color: 'rgba(0, 0, 0)'
color: 'rgba(0, 0, 0)',
}}
>
{students.length > 0
@ -67,7 +67,7 @@ const LiveResultsTableFooter: React.FC<LiveResultsFooterProps> = ({
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
fontSize: '1rem',
color: 'rgba(0, 0, 0)'
color: 'rgba(0, 0, 0)',
}}
>
{students.length > 0 ? `${classAverage.toFixed()} %` : '-'}
@ -76,4 +76,4 @@ const LiveResultsTableFooter: React.FC<LiveResultsFooterProps> = ({
</TableFooter>
);
};
export default LiveResultsTableFooter;
export default LiveResultsTableFooter;

View file

@ -15,76 +15,95 @@ interface Props {
const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props;
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || '');
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || []);
let disableButton = false;
if(handleOnSubmitAnswer === undefined){
if (handleOnSubmitAnswer === undefined) {
disableButton = true;
}
useEffect(() => {
if (passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
if (passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
}, [passedAnswer]);
const handleOnClickAnswer = (choice: string) => {
setAnswer(choice);
setAnswer((prevAnswer) => {
if (prevAnswer.includes(choice)) {
// Remove the choice if it's already selected
return prevAnswer.filter((selected) => selected !== choice);
} else {
// Add the choice if it's not already selected
return [...prevAnswer, choice];
}
});
};
const alpha = Array.from(Array(26)).map((_e, i) => i + 65);
const alphabet = alpha.map((x) => String.fromCharCode(x));
return (
return (
<div className="question-container">
<div className="question content">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedStem) }} />
</div>
<div className="choices-wrapper mb-1">
{question.choices.map((choice, i) => {
const selected = answer === choice.formattedText.text ? 'selected' : '';
const selected = answer.includes(choice.formattedText.text) ? 'selected' : '';
return (
<div key={choice.formattedText.text + i} className="choice-container">
<Button
variant="text"
className="button-wrapper"
disabled={disableButton}
onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}>
{showAnswer? (<div> {(choice.isCorrect ? '✅' : '❌')}</div>)
:``}
onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}
>
{showAnswer ? (
<div>{choice.isCorrect ? '✅' : '❌'}</div>
) : (
''
)}
<div className={`circle ${selected}`}>{alphabet[i]}</div>
<div className={`answer-text ${selected}`}>
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(choice.formattedText) }} />
<div
dangerouslySetInnerHTML={{
__html: FormattedTextTemplate(choice.formattedText),
}}
/>
</div>
{choice.formattedFeedback && showAnswer && (
<div className="feedback-container mb-1 mt-1/2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(choice.formattedFeedback) }} />
</div>
)}
<div className="feedback-container mb-1 mt-1/2">
<div
dangerouslySetInnerHTML={{
__html: FormattedTextTemplate(choice.formattedFeedback),
}}
/>
</div>
)}
</Button>
</div>
);
})}
</div>
{question.formattedGlobalFeedback && showAnswer && (
<div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
</div>
<div
dangerouslySetInnerHTML={{
__html: FormattedTextTemplate(question.formattedGlobalFeedback),
}}
/>
</div>
)}
{!showAnswer && handleOnSubmitAnswer && (
<Button
variant="contained"
onClick={() =>
answer !== "" && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
answer.length > 0 && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
}
disabled={answer === '' || answer === null}
disabled={answer.length === 0}
>
Répondre
</Button>
)}
</div>

View file

@ -17,7 +17,7 @@ interface Props {
const NumericalQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } =
props;
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || '');
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || []);
const correctAnswers = question.choices;
let correctAnswer = '';
@ -69,7 +69,7 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
id={question.formattedStem.text}
name={question.formattedStem.text}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setAnswer(e.target.valueAsNumber);
setAnswer([e.target.valueAsNumber]);
}}
inputProps={{ 'data-testid': 'number-input' }}
/>
@ -87,7 +87,7 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer)
}
disabled={answer === "" || isNaN(answer as number)}
disabled={answer === undefined || answer === null || isNaN(answer[0] as number)}
>
Répondre
</Button>

View file

@ -16,7 +16,7 @@ interface Props {
const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props;
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || '');
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || []);
useEffect(() => {
if (passedAnswer !== undefined) {
@ -58,7 +58,7 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
id={question.formattedStem.text}
name={question.formattedStem.text}
onChange={(e) => {
setAnswer(e.target.value);
setAnswer([e.target.value]);
}}
disabled={showAnswer}
aria-label="short-answer-input"
@ -72,7 +72,7 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer)
}
disabled={answer === null || answer === ''}
disabled={answer === null || answer === undefined || answer.length === 0}
>
Répondre
</Button>

View file

@ -1,5 +1,5 @@
// TrueFalseQuestion.tsx
import React, { useState,useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import '../questionStyle.css';
import { Button } from '@mui/material';
import { TrueFalseQuestion } from 'gift-pegjs';
@ -8,37 +8,37 @@ import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props {
question: TrueFalseQuestion;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean;
passedAnswer?: AnswerType;
}
const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer} =
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } =
props;
let disableButton = false;
if(handleOnSubmitAnswer === undefined){
if (handleOnSubmitAnswer === undefined) {
disableButton = true;
}
useEffect(() => {
console.log("passedAnswer", answer);
if (passedAnswer === true || passedAnswer === false) {
setAnswer(passedAnswer);
} else {
setAnswer(undefined);
}
}, [passedAnswer, question.id]);
console.log("passedAnswer", passedAnswer);
if (passedAnswer && (passedAnswer[0] === true || passedAnswer[0] === false)) {
setAnswer(passedAnswer[0]);
} else {
setAnswer(undefined);
}
}, [passedAnswer, question.id]);
const [answer, setAnswer] = useState<boolean | undefined>(() => {
if (passedAnswer === true || passedAnswer === false) {
return passedAnswer;
if (passedAnswer && (passedAnswer[0] === true || passedAnswer[0] === false)) {
return passedAnswer[0];
}
return undefined;
});
});
const handleOnClickAnswer = (choice: boolean) => {
setAnswer(choice);
@ -49,7 +49,7 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
return (
<div className="question-container">
<div className="question content">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedStem) }} />
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedStem) }} />
</div>
<div className="choices-wrapper mb-1">
<Button
@ -58,15 +58,15 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
fullWidth
disabled={disableButton}
>
{showAnswer? (<div> {(question.isTrue ? '✅' : '❌')}</div>):``}
{showAnswer ? (<div> {(question.isTrue ? '✅' : '❌')}</div>) : ``}
<div className={`circle ${selectedTrue}`}>V</div>
<div className={`answer-text ${selectedTrue}`}>Vrai</div>
{showAnswer && answer && question.trueFormattedFeedback && (
<div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div>
)}
<div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div>
)}
</Button>
<Button
className="button-wrapper"
@ -75,15 +75,15 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
disabled={disableButton}
>
{showAnswer? (<div> {(!question.isTrue ? '✅' : '❌')}</div>):``}
{showAnswer ? (<div> {(!question.isTrue ? '✅' : '❌')}</div>) : ``}
<div className={`circle ${selectedFalse}`}>F</div>
<div className={`answer-text ${selectedFalse}`}>Faux</div>
{showAnswer && !answer && question.falseFormattedFeedback && (
<div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div>
)}
<div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div>
)}
</Button>
</div>
{question.formattedGlobalFeedback && showAnswer && (
@ -95,8 +95,7 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
<Button
variant="contained"
onClick={() =>
answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer([answer])
}
disabled={answer === undefined}
>

View file

@ -17,7 +17,7 @@ import LoginContainer from 'src/components/LoginContainer/LoginContainer'
import ApiService from '../../../services/ApiService'
export type AnswerType = string | number | boolean;
export type AnswerType = Array<string | number | boolean>;
const JoinRoom: React.FC = () => {
const [roomName, setRoomName] = useState('');
@ -39,9 +39,8 @@ const JoinRoom: React.FC = () => {
}, []);
useEffect(() => {
// init the answers array, one for each question
setAnswers(Array(questions.length).fill({} as AnswerSubmissionToBackendType));
console.log(`JoinRoom: useEffect: questions: ${JSON.stringify(questions)}`);
setAnswers(questions ? Array(questions.length).fill({} as AnswerSubmissionToBackendType) : []);
}, [questions]);
@ -64,6 +63,7 @@ const JoinRoom: React.FC = () => {
console.log('on(launch-teacher-mode): Received launch-teacher-mode:', questions);
setQuizMode('teacher');
setIsWaitingForTeacher(true);
setQuestions([]); // clear out from last time (in case quiz is repeated)
setQuestions(questions);
// wait for next-question
});
@ -72,6 +72,7 @@ const JoinRoom: React.FC = () => {
setQuizMode('student');
setIsWaitingForTeacher(false);
setQuestions([]); // clear out from last time (in case quiz is repeated)
setQuestions(questions);
setQuestion(questions[0]);
});

View file

@ -1,12 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Socket } from 'socket.io-client';
import { ParsedGIFTQuestion, BaseQuestion, parse, Question } from 'gift-pegjs';
import {
isSimpleNumericalAnswer,
isRangeNumericalAnswer,
isHighLowNumericalAnswer
} from 'gift-pegjs/typeGuards';
import { BaseQuestion, parse, Question } from 'gift-pegjs';
import LiveResultsComponent from 'src/components/LiveResults/LiveResults';
import webSocketService, {
AnswerReceptionFromBackendType
@ -24,7 +19,7 @@ import QuestionDisplay from 'src/components/QuestionsDisplay/QuestionDisplay';
import ApiService from '../../../services/ApiService';
import { QuestionType } from 'src/Types/QuestionType';
import { Button } from '@mui/material';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
import { checkIfIsCorrect } from './useRooms';
const ManageRoom: React.FC = () => {
const navigate = useNavigate();
@ -36,8 +31,40 @@ const ManageRoom: React.FC = () => {
const [quizMode, setQuizMode] = useState<'teacher' | 'student'>('teacher');
const [connectingError, setConnectingError] = useState<string>('');
const [currentQuestion, setCurrentQuestion] = useState<QuestionType | undefined>(undefined);
const [quizStarted, setQuizStarted] = useState(false);
const [quizStarted, setQuizStarted] = useState<boolean>(false);
const [formattedRoomName, setFormattedRoomName] = useState("");
const [newlyConnectedUser, setNewlyConnectedUser] = useState<StudentType | null>(null);
// Handle the newly connected user in useEffect, because it needs state info
// not available in the socket.on() callback
useEffect(() => {
if (newlyConnectedUser) {
console.log(`Handling newly connected user: ${newlyConnectedUser.name}`);
setStudents((prevStudents) => [...prevStudents, newlyConnectedUser]);
// only send nextQuestion if the quiz has started
if (!quizStarted) {
console.log(`!quizStarted: returning.... `);
return;
}
if (quizMode === 'teacher') {
webSocketService.nextQuestion({
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: Number(currentQuestion?.question.id) - 1,
isLaunch: true // started late
});
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions);
} else {
console.error('Invalid quiz mode:', quizMode);
}
// Reset the newly connected user state
setNewlyConnectedUser(null);
}
}, [newlyConnectedUser]);
useEffect(() => {
const verifyLogin = async () => {
@ -110,6 +137,17 @@ const ManageRoom: React.FC = () => {
const roomNameUpper = roomName.toUpperCase();
setFormattedRoomName(roomNameUpper);
console.log(`Creating WebSocket room named ${roomNameUpper}`);
/**
* ATTENTION: Lire les variables d'état dans
* les .on() n'est pas une bonne pratique.
* Les valeurs sont celles au moment de la création
* de la fonction et non au moment de l'exécution.
* Il faut utiliser des refs pour les valeurs qui
* changent fréquemment. Sinon, utiliser un trigger
* de useEffect pour mettre déclencher un traitement
* (voir user-joined plus bas).
*/
socket.on('connect', () => {
webSocketService.createRoom(roomNameUpper);
});
@ -124,23 +162,9 @@ const ManageRoom: React.FC = () => {
});
socket.on('user-joined', (student: StudentType) => {
console.log(`Student joined: name = ${student.name}, id = ${student.id}, quizMode = ${quizMode}, quizStarted = ${quizStarted}`);
setStudents((prevStudents) => [...prevStudents, student]);
// only send nextQuestion if the quiz has started
if (!quizStarted) return;
if (quizMode === 'teacher') {
webSocketService.nextQuestion(
{roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: Number(currentQuestion?.question.id) - 1,
isLaunch: false});
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions);
}
setNewlyConnectedUser(student);
});
socket.on('join-failure', (message) => {
setConnectingError(message);
setSocket(null);
@ -286,21 +310,19 @@ const ManageRoom: React.FC = () => {
};
const launchQuiz = () => {
setQuizStarted(true);
if (!socket || !formattedRoomName || !quiz?.content || quiz?.content.length === 0) {
// TODO: This error happens when token expires! Need to handle it properly
console.log(
`Error launching quiz. socket: ${socket}, roomName: ${formattedRoomName}, quiz: ${quiz}`
);
setQuizStarted(true);
return;
}
console.log(`Launching quiz in ${quizMode} mode...`);
switch (quizMode) {
case 'student':
setQuizStarted(true);
return launchStudentMode();
case 'teacher':
setQuizStarted(true);
return launchTeacherMode();
}
};
@ -319,63 +341,6 @@ const ManageRoom: React.FC = () => {
navigate('/teacher/dashboard');
};
function checkIfIsCorrect(
answer: AnswerType,
idQuestion: number,
questions: QuestionType[]
): boolean {
const questionInfo = questions.find((q) =>
q.question.id ? q.question.id === idQuestion.toString() : false
) as QuestionType | undefined;
const answerText = answer.toString();
if (questionInfo) {
const question = questionInfo.question as ParsedGIFTQuestion;
if (question.type === 'TF') {
return (
(question.isTrue && answerText == 'true') ||
(!question.isTrue && answerText == 'false')
);
} else if (question.type === 'MC') {
return question.choices.some(
(choice) => choice.isCorrect && choice.formattedText.text === answerText
);
} else if (question.type === 'Numerical') {
if (isHighLowNumericalAnswer(question.choices[0])) {
const choice = question.choices[0];
const answerNumber = parseFloat(answerText);
if (!isNaN(answerNumber)) {
return (
answerNumber <= choice.numberHigh && answerNumber >= choice.numberLow
);
}
}
if (isRangeNumericalAnswer(question.choices[0])) {
const answerNumber = parseFloat(answerText);
const range = question.choices[0].range;
const correctAnswer = question.choices[0].number;
if (!isNaN(answerNumber)) {
return (
answerNumber <= correctAnswer + range &&
answerNumber >= correctAnswer - range
);
}
}
if (isSimpleNumericalAnswer(question.choices[0])) {
const answerNumber = parseFloat(answerText);
if (!isNaN(answerNumber)) {
return answerNumber === question.choices[0].number;
}
}
} else if (question.type === 'Short') {
return question.choices.some(
(choice) => choice.text.toUpperCase() === answerText.toUpperCase()
);
}
}
return false;
}
if (!formattedRoomName) {
return (
<div className="center">

View file

@ -1,8 +1,15 @@
import { useContext } from 'react';
import { RoomType } from 'src/Types/RoomType';
import { createContext } from 'react';
//import { RoomContext } from './RoomContext';
import { MultipleNumericalAnswer, NumericalAnswer, ParsedGIFTQuestion } from 'gift-pegjs';
import { QuestionType } from 'src/Types/QuestionType';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
import {
isSimpleNumericalAnswer,
isRangeNumericalAnswer,
isHighLowNumericalAnswer,
isMultipleNumericalAnswer
} from 'gift-pegjs/typeGuards';
type RoomContextType = {
rooms: RoomType[];
@ -18,3 +25,136 @@ export const useRooms = () => {
if (!context) throw new Error('useRooms must be used within a RoomProvider');
return context;
};
/**
* Checks if the answer is correct - logic varies by type of question!
* True/False: answer must match the isTrue property
* Multiple Choice: answer must match the correct choice(s)
* Numerical: answer must be within the range or equal to the number (for each type of correct answer)
* Short Answer: answer must match the correct choice(s) (case-insensitive)
* @param answer
* @param idQuestion
* @param questions
* @returns
*/
export function checkIfIsCorrect(
answer: AnswerType,
idQuestion: number,
questions: QuestionType[]
): boolean {
const questionInfo = questions.find((q) =>
q.question.id ? q.question.id === idQuestion.toString() : false
) as QuestionType | undefined;
const simpleAnswerText = answer.toString();
if (questionInfo) {
const question = questionInfo.question as ParsedGIFTQuestion;
if (question.type === 'TF') {
return (
(question.isTrue && simpleAnswerText == 'true') ||
(!question.isTrue && simpleAnswerText == 'false')
);
} else if (question.type === 'MC') {
const correctAnswers = question.choices.filter((choice) => choice.isCorrect
/* || (choice.weight && choice.weight > 0)*/ // handle weighted answers
);
const multipleAnswers = Array.isArray(answer) ? answer : [answer as string];
if (correctAnswers.length === 0) {
return false;
}
return correctAnswers.every(
(choice) => multipleAnswers.includes(choice.formattedText.text)
);
} else if (question.type === 'Numerical') {
if (isMultipleNumericalAnswer(question.choices[0])) { // Multiple numerical answers
// check to see if answer[0] is a match for any of the choices that isCorrect
const correctChoices = question.choices.filter((choice) => isMultipleNumericalAnswer(choice) && choice.isCorrect);
if (correctChoices.length === 0) { // weird case where there are multiple numerical answers but none are correct
return false;
}
return correctChoices.some((choice) => {
// narrow choice to MultipleNumericalAnswer type
const multipleNumericalChoice = choice as MultipleNumericalAnswer;
return isCorrectNumericalAnswer(multipleNumericalChoice.answer, simpleAnswerText);
});
}
if (isHighLowNumericalAnswer(question.choices[0])) {
// const choice = question.choices[0];
// const answerNumber = parseFloat(simpleAnswerText);
// if (!isNaN(answerNumber)) {
// return (
// answerNumber <= choice.numberHigh && answerNumber >= choice.numberLow
// );
// }
return isCorrectNumericalAnswer(question.choices[0], simpleAnswerText);
}
if (isRangeNumericalAnswer(question.choices[0])) {
// const answerNumber = parseFloat(simpleAnswerText);
// const range = question.choices[0].range;
// const correctAnswer = question.choices[0].number;
// if (!isNaN(answerNumber)) {
// return (
// answerNumber <= correctAnswer + range &&
// answerNumber >= correctAnswer - range
// );
// }
return isCorrectNumericalAnswer(question.choices[0], simpleAnswerText);
}
if (isSimpleNumericalAnswer(question.choices[0])) {
// const answerNumber = parseFloat(simpleAnswerText);
// if (!isNaN(answerNumber)) {
// return answerNumber === question.choices[0].number;
// }
return isCorrectNumericalAnswer(question.choices[0], simpleAnswerText);
}
} else if (question.type === 'Short') {
return question.choices.some(
(choice) => choice.text.toUpperCase() === simpleAnswerText.toUpperCase()
);
}
}
return false;
}
/**
* Determines if a numerical answer is correct based on the type of numerical answer.
* @param correctAnswer The correct answer (of type NumericalAnswer).
* @param userAnswer The user's answer (as a string or number).
* @returns True if the user's answer is correct, false otherwise.
*/
export function isCorrectNumericalAnswer(
correctAnswer: NumericalAnswer,
userAnswer: string | number
): boolean {
const answerNumber = typeof userAnswer === 'string' ? parseFloat(userAnswer) : userAnswer;
if (isNaN(answerNumber)) {
return false; // User's answer is not a valid number
}
if (isSimpleNumericalAnswer(correctAnswer)) {
// Exact match for simple numerical answers
return answerNumber === correctAnswer.number;
}
if (isRangeNumericalAnswer(correctAnswer)) {
// Check if the user's answer is within the range
const { number, range } = correctAnswer;
return answerNumber >= number - range && answerNumber <= number + range;
}
if (isHighLowNumericalAnswer(correctAnswer)) {
// Check if the user's answer is within the high-low range
const { numberLow, numberHigh } = correctAnswer;
return answerNumber >= numberLow && answerNumber <= numberHigh;
}
// if (isMultipleNumericalAnswer(correctAnswer)) {
// // Check if the user's answer matches any of the multiple numerical answers
// return correctAnswer.answer.some((choice) =>
// isCorrectNumericalAnswer(choice, answerNumber)
// );
// }
return false; // Default to false if the answer type is not recognized
}