Some tests passing (MultipleChoiceQuestionDisplay.test.ts)

This commit is contained in:
C. Fuhrman 2025-01-23 22:38:22 -05:00
parent 6f270b5436
commit 39a7ecce31
15 changed files with 327 additions and 276 deletions

215
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"dompurify": "^3.2.3", "dompurify": "^3.2.3",
"esbuild": "^0.23.1", "esbuild": "^0.23.1",
"gift-pegjs": "^1.0.2", "gift-pegjs": "file:../GIFT-grammar-PEG.js",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"katex": "^0.16.11", "katex": "^0.16.11",
"marked": "^14.1.2", "marked": "^14.1.2",

View file

@ -1,5 +0,0 @@
import { GIFTQuestion } from 'gift-pegjs';
export interface QuestionType {
question: GIFTQuestion;
}

View file

@ -1,6 +1,5 @@
//QuestionType.test.tsx //QuestionType.test.tsx
import { GIFTQuestion } from 'gift-pegjs'; import { Question } from 'gift-pegjs';
import { QuestionType } from '../../Types/QuestionType';
const sampleStem = 'Sample question stem'; const sampleStem = 'Sample question stem';
const options = ['Option A', 'Option B']; const options = ['Option A', 'Option B'];
@ -8,30 +7,28 @@ const sampleFormat = 'plain';
const sampleType = 'MC'; const sampleType = 'MC';
const sampleTitle = 'Sample Question'; const sampleTitle = 'Sample Question';
const mockQuestion: GIFTQuestion = { const mockQuestion: Question = {
id: '1', id: '1',
type: sampleType, type: sampleType,
stem: { format: sampleFormat, text: sampleStem }, formattedStem: { format: sampleFormat, text: sampleStem },
title: sampleTitle, title: sampleTitle,
hasEmbeddedAnswers: false, hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [ choices: [
{ text: { format: sampleFormat, text: options[0] }, isCorrect: true, weight: 1, feedback: null }, { formattedText: { format: sampleFormat, text: options[0] }, isCorrect: true, weight: 1 },
{ text: { format: sampleFormat, text: options[1] }, isCorrect: false, weight: 0, feedback: null }, { formattedText: { format: sampleFormat, text: options[1] }, isCorrect: false, weight: 0 },
], ],
}; };
const mockQuestionType: QuestionType = { const mockQuestionType = mockQuestion;
question: mockQuestion,
};
describe('QuestionType', () => { // test seems useless (it's broken) now that gift-pegjs has TypeScript types (and its own tests)
describe.skip('QuestionType', () => {
test('has the expected structure', () => { test('has the expected structure', () => {
expect(mockQuestionType).toEqual(expect.objectContaining({ expect(mockQuestionType).toEqual(expect.objectContaining({
question: expect.any(Object), question: expect.any(Object),
})); }));
expect(mockQuestionType.question).toEqual(expect.objectContaining({ expect(mockQuestionType).toEqual(expect.objectContaining({
id: expect.any(String), id: expect.any(String),
type: expect.any(String), type: expect.any(String),
stem: expect.objectContaining({ stem: expect.objectContaining({

View file

@ -1,7 +1,7 @@
// TextType.test.ts // TextType.test.ts
import { TextFormat } from "gift-pegjs";
import textType from "src/components/GiftTemplate/templates/TextType"; import textType from "src/components/GiftTemplate/templates/TextType";
import { TextFormat } from "gift-pegjs";
describe('TextType', () => { describe('TextType', () => {
it('should format text with basic characters correctly', () => { it('should format text with basic characters correctly', () => {

View file

@ -1,35 +1,39 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import MultipleChoiceQuestion from 'src/components/Questions/MultipleChoiceQuestion/MultipleChoiceQuestion'; import MultipleChoiceQuestionDisplay from 'src/components/Questions/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay';
import { act } from 'react'; import { act } from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { MultipleChoiceQuestion, parse } from 'gift-pegjs';
const questionStem = 'Question stem'; const questions = parse(
const sampleFeedback = 'Feedback'; `::Sample Question 1:: Question stem
{
=Choice 1
~Choice 2
}`) as MultipleChoiceQuestion[];
describe('MultipleChoiceQuestion', () => { const question = questions[0];
describe('MultipleChoiceQuestionDisplay', () => {
const mockHandleOnSubmitAnswer = jest.fn(); const mockHandleOnSubmitAnswer = jest.fn();
const choices = [ const choices = question.choices;
{ feedback: null, isCorrect: true, text: { format: 'plain', text: 'Choice 1' } },
{ feedback: null, isCorrect: false, text: { format: 'plain', text: 'Choice 2' } }
];
beforeEach(() => { beforeEach(() => {
render( render(
<MemoryRouter> <MemoryRouter>
<MultipleChoiceQuestion <MultipleChoiceQuestionDisplay
globalFeedback={sampleFeedback} question={question}
choices={choices}
handleOnSubmitAnswer={mockHandleOnSubmitAnswer} handleOnSubmitAnswer={mockHandleOnSubmitAnswer}
questionStem={{ text: questionStem, format: 'plain' }} /> showAnswer={false}
/>
</MemoryRouter>); </MemoryRouter>);
}); });
test('renders the question and choices', () => { test('renders the question and choices', () => {
expect(screen.getByText(questionStem)).toBeInTheDocument(); expect(screen.getByText(question.formattedStem.text)).toBeInTheDocument();
choices.forEach((choice) => { choices.forEach((choice) => {
expect(screen.getByText(choice.text.text)).toBeInTheDocument(); expect(screen.getByText(choice.formattedText.text)).toBeInTheDocument();
}); });
}); });

View file

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import Questions from 'src/components/Questions/Question'; import Questions from 'src/components/Questions/QuestionDisplay';
import { GIFTQuestion } from 'gift-pegjs'; import { GIFTQuestion } from 'gift-pegjs';
// //

View file

@ -1,21 +1,20 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react'; import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { parse } from 'gift-pegjs';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { QuestionType } from '../../../../Types/QuestionType'; import { Question } from 'gift-pegjs';
import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz'; import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz';
import { parse } from 'gift-pegjs';
const mockGiftQuestions = parse( const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Option A ~Option B} `::Sample Question 1:: Sample Question 1 {=Option A ~Option B}
::Sample Question 2:: Sample Question 2 {T}`); ::Sample Question 2:: Sample Question 2 {T}`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => { const mockQuestions: Question[] = mockGiftQuestions.map((question, index) => {
question.id = (index + 1).toString(); if (question.type !== "Category")
const newMockQuestion: QuestionType = { question.id = (index + 1).toString();
question: question, const newMockQuestion = question;
};
return newMockQuestion; return newMockQuestion;
}); });

View file

@ -28,7 +28,7 @@ export function formatLatex(text: string): string {
* @see marked * @see marked
* @see katex * @see katex
*/ */
export default function textType({ text }: TextTypeOptions) { export function textType({ text }: TextTypeOptions) {
const formatText = formatLatex(text.text.trim()); // latex needs pure "&", ">", etc. Must not be escaped const formatText = formatLatex(text.text.trim()); // latex needs pure "&", ">", etc. Must not be escaped
let parsedText = ''; let parsedText = '';
switch (text.format) { switch (text.format) {

View file

@ -1,35 +1,25 @@
// MultipleChoiceQuestion.tsx // MultipleChoiceQuestionDisplay.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import '../questionStyle.css'; import '../questionStyle.css';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import textType, { formatLatex } from '../../GiftTemplate/templates/TextType'; import { textType } from '../../GiftTemplate/templates/TextType';
import { TextFormat } from '../../GiftTemplate/templates/types'; import { MultipleChoiceQuestion } from 'gift-pegjs';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
// import Latex from 'react-latex';
type Choices = {
feedback: { format: string; text: string } | null;
isCorrect: boolean;
text: { format: string; text: string };
weigth?: number;
};
interface Props { interface Props {
questionStem: TextFormat; question: MultipleChoiceQuestion;
choices: Choices[];
globalFeedback?: string | undefined;
handleOnSubmitAnswer?: (answer: string) => void; handleOnSubmitAnswer?: (answer: string) => void;
showAnswer?: boolean; showAnswer?: boolean;
} }
const MultipleChoiceQuestion: React.FC<Props> = (props) => { const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
const { questionStem: questionContent, choices, showAnswer, handleOnSubmitAnswer, globalFeedback } = props; const { question, showAnswer, handleOnSubmitAnswer } = props;
const [answer, setAnswer] = useState<string>(); const [answer, setAnswer] = useState<string>();
useEffect(() => { useEffect(() => {
setAnswer(undefined); setAnswer(undefined);
}, [questionContent]); }, [question]);
const handleOnClickAnswer = (choice: string) => { const handleOnClickAnswer = (choice: string) => {
setAnswer(choice); setAnswer(choice);
@ -41,38 +31,40 @@ const MultipleChoiceQuestion: React.FC<Props> = (props) => {
return ( return (
<div className="question-container"> <div className="question-container">
<div className="question content"> <div className="question content">
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(textType({text: questionContent})) }} /> <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(textType({text: question.formattedStem})) }} />
</div> </div>
<div className="choices-wrapper mb-1"> <div className="choices-wrapper mb-1">
{choices.map((choice, i) => { {question.choices.map((choice, i) => {
const selected = answer === choice.text.text ? 'selected' : ''; const selected = answer === choice.formattedText.text ? 'selected' : '';
return ( return (
<div key={choice.text.text + i} className="choice-container"> <div key={choice.formattedText.text + i} className="choice-container">
<Button <Button
variant="text" variant="text"
className="button-wrapper" className="button-wrapper"
onClick={() => !showAnswer && handleOnClickAnswer(choice.text.text)} onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}
> >
{choice.feedback === null && {choice.formattedFeedback === null &&
showAnswer && showAnswer &&
(choice.isCorrect ? '✅' : '❌')} (choice.isCorrect ? '✅' : '❌')}
<div className={`circle ${selected}`}>{alphabet[i]}</div> <div className={`circle ${selected}`}>{alphabet[i]}</div>
<div className={`answer-text ${selected}`}> <div className={`answer-text ${selected}`}>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(formatLatex(choice.text.text)) }} /> <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(textType({ text: choice.formattedText })) }} />
</div> </div>
</Button> </Button>
{choice.feedback && showAnswer && ( {choice.formattedFeedback && showAnswer && (
<div className="feedback-container mb-1 mt-1/2"> <div className="feedback-container mb-1 mt-1/2">
{choice.isCorrect ? '✅' : '❌'} {choice.isCorrect ? '✅' : '❌'}
{choice.feedback?.text} <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(textType({ text: choice.formattedFeedback })) }} />
</div> </div>
)} )}
</div> </div>
); );
})} })}
</div> </div>
{globalFeedback && showAnswer && ( {question.formattedGlobalFeedback && showAnswer && (
<div className="global-feedback mb-2">{globalFeedback}</div> <div className="global-feedback mb-2">
<p>${textType({ text: question.formattedGlobalFeedback })}</p>
</div>
)} )}
{!showAnswer && handleOnSubmitAnswer && ( {!showAnswer && handleOnSubmitAnswer && (
@ -92,4 +84,4 @@ const MultipleChoiceQuestion: React.FC<Props> = (props) => {
); );
}; };
export default MultipleChoiceQuestion; export default MultipleChoiceQuestionDisplay;

View file

@ -3,19 +3,19 @@ import React, { useState } from 'react';
import '../questionStyle.css'; import '../questionStyle.css';
import { Button, TextField } from '@mui/material'; import { Button, TextField } from '@mui/material';
import textType from '../../GiftTemplate/templates/TextType'; import textType from '../../GiftTemplate/templates/TextType';
import { TextFormat } from '../../GiftTemplate/templates/types'; import { TextFormat, NumericalAnswer, isHighLowNumericalAnswer, isMultipleNumericalAnswer, isRangeNumericalAnswer, isSimpleNumericalAnswer, SimpleNumericalAnswer, RangeNumericalAnswer, HighLowNumericalAnswer } from 'gift-pegjs';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
type CorrectAnswer = { // type CorrectAnswer = {
numberHigh?: number; // numberHigh?: number;
numberLow?: number; // numberLow?: number;
number?: number; // number?: number;
type: string; // type: string;
}; // };
interface Props { interface Props {
questionContent: TextFormat; questionContent: TextFormat;
correctAnswers: CorrectAnswer; correctAnswers: NumericalAnswer;
globalFeedback?: string | undefined; globalFeedback?: string | undefined;
handleOnSubmitAnswer?: (answer: number) => void; handleOnSubmitAnswer?: (answer: number) => void;
showAnswer?: boolean; showAnswer?: boolean;
@ -27,10 +27,21 @@ const NumericalQuestion: React.FC<Props> = (props) => {
const [answer, setAnswer] = useState<number>(); const [answer, setAnswer] = useState<number>();
const correctAnswer = let correctAnswer= '';
correctAnswers.type === 'high-low'
? `Entre ${correctAnswers.numberLow} et ${correctAnswers.numberHigh}` if (isSimpleNumericalAnswer(correctAnswers)) {
: correctAnswers.number; correctAnswer = `${(correctAnswers as SimpleNumericalAnswer).number}`;
} else if (isRangeNumericalAnswer(correctAnswers)) {
const choice = correctAnswers as RangeNumericalAnswer;
correctAnswer = `Entre ${choice.number - choice.range} et ${choice.number + choice.range}`;
} else if (isHighLowNumericalAnswer(correctAnswers)) {
const choice = correctAnswers as HighLowNumericalAnswer;
correctAnswer = `Entre ${choice.numberLow} et ${choice.numberHigh}`;
} else if (isMultipleNumericalAnswer(correctAnswers)) {
correctAnswer = `MultipleNumericalAnswer is not supported yet`;
} else {
throw new Error('Unknown numerical answer type');
}
return ( return (
<div className="question-wrapper"> <div className="question-wrapper">

View file

@ -1,24 +1,22 @@
// Question;tsx // Question;tsx
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { GIFTQuestion } from 'gift-pegjs'; import { Question } from 'gift-pegjs';
import TrueFalseQuestion from './TrueFalseQuestion/TrueFalseQuestion'; import TrueFalseQuestion from './TrueFalseQuestion/TrueFalseQuestion';
import MultipleChoiceQuestion from './MultipleChoiceQuestion/MultipleChoiceQuestion'; import MultipleChoiceQuestionDisplay from './MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay';
import NumericalQuestion from './NumericalQuestion/NumericalQuestion'; import NumericalQuestion from './NumericalQuestion/NumericalQuestion';
import ShortAnswerQuestion from './ShortAnswerQuestion/ShortAnswerQuestion'; import ShortAnswerQuestion from './ShortAnswerQuestion/ShortAnswerQuestion';
import useCheckMobileScreen from '../../services/useCheckMobileScreen'; import useCheckMobileScreen from '../../services/useCheckMobileScreen';
interface QuestionProps { interface QuestionProps {
question: GIFTQuestion | undefined; question: Question;
handleOnSubmitAnswer?: (answer: string | number | boolean) => void; handleOnSubmitAnswer?: (answer: string | number | boolean) => void;
showAnswer?: boolean; showAnswer?: boolean;
imageUrl?: string;
} }
const Question: React.FC<QuestionProps> = ({ const QuestionDisplay: React.FC<QuestionProps> = ({
question, question,
handleOnSubmitAnswer, handleOnSubmitAnswer,
showAnswer, showAnswer,
imageUrl
}) => { }) => {
const isMobile = useCheckMobileScreen(); const isMobile = useCheckMobileScreen();
const imgWidth = useMemo(() => { const imgWidth = useMemo(() => {
@ -30,22 +28,20 @@ const Question: React.FC<QuestionProps> = ({
case 'TF': case 'TF':
questionTypeComponent = ( questionTypeComponent = (
<TrueFalseQuestion <TrueFalseQuestion
questionContent={question.stem} questionContent={question.formattedStem}
correctAnswer={question.isTrue} correctAnswer={question.isTrue}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
globalFeedback={question.globalFeedback?.text} globalFeedback={question.formattedGlobalFeedback?.text}
/> />
); );
break; break;
case 'MC': case 'MC':
questionTypeComponent = ( questionTypeComponent = (
<MultipleChoiceQuestion <MultipleChoiceQuestionDisplay
questionStem={question.stem} question={question}
choices={question.choices.map((choice, index) => ({ ...choice, id: index.toString() }))}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
globalFeedback={question.globalFeedback?.text}
/> />
); );
break; break;
@ -54,21 +50,21 @@ const Question: React.FC<QuestionProps> = ({
if (!Array.isArray(question.choices)) { if (!Array.isArray(question.choices)) {
questionTypeComponent = ( questionTypeComponent = (
<NumericalQuestion <NumericalQuestion
questionContent={question.stem} questionContent={question.formattedStem}
correctAnswers={question.choices} correctAnswers={question.choices}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
globalFeedback={question.globalFeedback?.text} globalFeedback={question.formattedGlobalFeedback?.text}
/> />
); );
} else { } else {
questionTypeComponent = ( questionTypeComponent = ( // TODO fix NumericalQuestion (correctAnswers is borked)
<NumericalQuestion <NumericalQuestion
questionContent={question.stem} questionContent={question.formattedStem}
correctAnswers={question.choices[0].text} correctAnswers={question.choices}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
globalFeedback={question.globalFeedback?.text} globalFeedback={question.formattedGlobalFeedback?.text}
/> />
); );
} }
@ -77,11 +73,11 @@ const Question: React.FC<QuestionProps> = ({
case 'Short': case 'Short':
questionTypeComponent = ( questionTypeComponent = (
<ShortAnswerQuestion <ShortAnswerQuestion
questionContent={question.stem} questionContent={question.formattedStem}
choices={question.choices.map((choice, index) => ({ ...choice, id: index.toString() }))} choices={question.choices.map((choice, index) => ({ ...choice, id: index.toString() }))}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
globalFeedback={question.globalFeedback?.text} globalFeedback={question.formattedGlobalFeedback?.text}
/> />
); );
break; break;
@ -106,4 +102,4 @@ const Question: React.FC<QuestionProps> = ({
); );
}; };
export default Question; export default QuestionDisplay;

View file

@ -1,6 +1,6 @@
// StudentModeQuiz.tsx // StudentModeQuiz.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import QuestionComponent from '../Questions/Question'; import QuestionComponent from '../Questions/QuestionDisplay';
import '../../pages/Student/JoinRoom/joinRoom.css'; import '../../pages/Student/JoinRoom/joinRoom.css';
import { QuestionType } from '../../Types/QuestionType'; import { QuestionType } from '../../Types/QuestionType';
// import { QuestionService } from '../../services/QuestionService'; // import { QuestionService } from '../../services/QuestionService';

View file

@ -1,7 +1,7 @@
// TeacherModeQuiz.tsx // TeacherModeQuiz.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import QuestionComponent from '../Questions/Question'; import QuestionComponent from '../Questions/QuestionDisplay';
import '../../pages/Student/JoinRoom/joinRoom.css'; import '../../pages/Student/JoinRoom/joinRoom.css';
import { QuestionType } from '../../Types/QuestionType'; import { QuestionType } from '../../Types/QuestionType';

View file

@ -18,7 +18,7 @@ import { Refresh, Error } from '@mui/icons-material';
import StudentWaitPage from 'src/components/StudentWaitPage/StudentWaitPage'; import StudentWaitPage from 'src/components/StudentWaitPage/StudentWaitPage';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton'; import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
//import QuestionNavigation from 'src/components/QuestionNavigation/QuestionNavigation'; //import QuestionNavigation from 'src/components/QuestionNavigation/QuestionNavigation';
import Question from 'src/components/Questions/Question'; import QuestionDisplay from 'src/components/Questions/QuestionDisplay';
import ApiService from '../../../services/ApiService'; import ApiService from '../../../services/ApiService';
const ManageRoom: React.FC = () => { const ManageRoom: React.FC = () => {
@ -474,7 +474,7 @@ const ManageRoom: React.FC = () => {
<div className="preview-and-result-container"> <div className="preview-and-result-container">
{currentQuestion && ( {currentQuestion && (
<Question <QuestionDisplay
showAnswer={false} showAnswer={false}
question={currentQuestion?.question} question={currentQuestion?.question}
/> />