diff --git a/LICENSE b/LICENSE index cf016c7..406c5af 100644 --- a/LICENSE +++ b/LICENSE @@ -3,6 +3,7 @@ MIT License Copyright (c) 2023 ETS-PFE004-Plateforme-sondage-minitest Copyright (c) 2024 Louis-Antoine Caron, Mathieu Roy, Mélanie St-Hilaire, Samy Waddah Copyright (c) 2024 Gabriel Moisan-Matte, Mathieu Sévigny-Lavallée, Jerry Kwok Hiu Fung, Bruno Roesner, Florent Serres +Copyright (c) 2025 Nouhaïla Aâter, Kendrick Chan Hing Wah, Philippe Côté, Edwin Stanley Lopez Andino, Ana Lucia Munteanu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/client/package-lock.json b/client/package-lock.json index 8105575..3c24ffc 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,7 +14,7 @@ "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@mui/icons-material": "^6.1.0", + "@mui/icons-material": "^6.4.1", "@mui/lab": "^5.0.0-alpha.153", "@mui/material": "^6.1.0", "@types/uuid": "^9.0.7", @@ -2093,15 +2093,15 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", - "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.1", + "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, @@ -3355,9 +3355,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.6.tgz", - "integrity": "sha512-nz1SlR9TdBYYPz4qKoNasMPRiGb4PaIHFkzLzhju0YVYS5QSuFF2+n7CsiHMIDcHv3piPu/xDWI53ruhOqvZwQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.1.tgz", + "integrity": "sha512-SfDLWMV5b5oXgDf3NTa2hCTPC1d2defhDH2WgFKmAiejC4mSfXYbyi+AFCLzpizauXhgBm8OaZy9BHKnrSpahQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -3365,9 +3365,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.6.tgz", - "integrity": "sha512-5r9urIL2lxXb/sPN3LFfFYEibsXJUb986HhhIeu1gOcte460pwdSiEhBSxkAuyT8Dj7jvu9MjqSBmSumQELo8A==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.1.tgz", + "integrity": "sha512-wsxFcUTQxt4s+7Bg4GgobqRjyaHLmZGNOs+HJpbwrwmLbT6mhIJxhpqsKzzWq9aDY8xIe7HCjhpH7XI5UD6teA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0" @@ -3380,7 +3380,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^6.1.6", + "@mui/material": "^6.4.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -3464,22 +3464,22 @@ } }, "node_modules/@mui/material": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.6.tgz", - "integrity": "sha512-1yvejiQ/601l5AK3uIdUlAVElyCxoqKnl7QA+2oFB/2qYPWfRwDgavW/MoywS5Y2gZEslcJKhe0s2F3IthgFgw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.1.tgz", + "integrity": "sha512-MFBfia6UiKxyoLeGkAh8M15bkeDmfnsUTMRJd/vTQue6YQ8AQ6lw9HqDthyYghzDEWIvZO/lQQzLrZE8XwNJLA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.1.6", - "@mui/system": "^6.1.6", - "@mui/types": "^7.2.19", - "@mui/utils": "^6.1.6", + "@mui/core-downloads-tracker": "^6.4.1", + "@mui/system": "^6.4.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.1", "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.11", + "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^18.3.1", + "react-is": "^19.0.0", "react-transition-group": "^4.4.5" }, "engines": { @@ -3492,7 +3492,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.1.6", + "@mui/material-pigment-css": "^6.4.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -3513,13 +3513,13 @@ } }, "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.6.tgz", - "integrity": "sha512-ioAiFckaD/fJSnTrUMWgjl9HYBWt7ixCh7zZw7gDZ+Tae7NuprNV6QJK95EidDT7K0GetR2rU3kAeIR61Myttw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.1.tgz", + "integrity": "sha512-DcT7mwK89owwgcEuiE7w458te4CIjHbYWW6Kn6PiR6eLtxBsoBYphA968uqsQAOBQDpbYxvkuFLwhgk4bxoN/Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.1.6", + "@mui/utils": "^6.4.1", "prop-types": "^15.8.1" }, "engines": { @@ -3540,14 +3540,14 @@ } }, "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.6.tgz", - "integrity": "sha512-I+yS1cSuSvHnZDBO7e7VHxTWpj+R7XlSZvTC4lS/OIbUNJOMMSd3UDP6V2sfwzAdmdDNBi7NGCRv2SZ6O9hGDA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.0.tgz", + "integrity": "sha512-ek/ZrDujrger12P6o4luQIfRd2IziH7jQod2WMbLqGE03Iy0zUwYmckRTVhRQTLPNccpD8KXGcALJF+uaUQlbg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@emotion/cache": "^11.13.1", - "@emotion/serialize": "^1.3.2", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -3574,16 +3574,16 @@ } }, "node_modules/@mui/material/node_modules/@mui/system": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.6.tgz", - "integrity": "sha512-qOf1VUE9wK8syiB0BBCp82oNBAVPYdj4Trh+G1s+L+ImYiKlubWhhqlnvWt3xqMevR+D2h1CXzA1vhX2FvA+VQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.1.tgz", + "integrity": "sha512-rgQzgcsHCTtzF9MZ+sL0tOhf2ZBLazpjrujClcb4Siju5lTrK0xX4PsiropActzCemNfM+mOu+0jezAVnfRK8g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.1.6", - "@mui/styled-engine": "^6.1.6", - "@mui/types": "^7.2.19", - "@mui/utils": "^6.1.6", + "@mui/private-theming": "^6.4.1", + "@mui/styled-engine": "^6.4.0", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.1", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -3614,17 +3614,17 @@ } }, "node_modules/@mui/material/node_modules/@mui/utils": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.6.tgz", - "integrity": "sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.1.tgz", + "integrity": "sha512-iQUDUeYh87SvR4lVojaRaYnQix8BbRV51MxaV6MBmqthecQoxwSbS5e2wnbDJUeFxY2ppV505CiqPLtd0OWkqw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.19", - "@types/prop-types": "^15.7.13", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.3.1" + "react-is": "^19.0.0" }, "engines": { "node": ">=14.0.0" @@ -3643,6 +3643,12 @@ } } }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, "node_modules/@mui/private-theming": { "version": "5.16.14", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.14.tgz", @@ -3743,9 +3749,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.19", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.19.tgz", - "integrity": "sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==", + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", "license": "MIT", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4682,9 +4688,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "license": "MIT" }, "node_modules/@types/react": { @@ -4718,11 +4724,11 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", - "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", - "dependencies": { + "peerDependencies": { "@types/react": "*" } }, diff --git a/client/package.json b/client/package.json index 690ed35..2a99173 100644 --- a/client/package.json +++ b/client/package.json @@ -18,7 +18,7 @@ "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@mui/icons-material": "^6.1.0", + "@mui/icons-material": "^6.4.1", "@mui/lab": "^5.0.0-alpha.153", "@mui/material": "^6.1.0", "@types/uuid": "^9.0.7", diff --git a/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResults.test.tsx b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResults.test.tsx new file mode 100644 index 0000000..3431b73 --- /dev/null +++ b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResults.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import LiveResults from 'src/components/LiveResults/LiveResults'; +import { QuestionType } from 'src/Types/QuestionType'; +import { StudentType } from 'src/Types/StudentType'; +import { BaseQuestion, parse } from 'gift-pegjs'; + +const mockGiftQuestions = parse( + `::Sample Question 1:: Sample Question 1 {=Answer 1 ~Answer 2} + + ::Sample Question 2:: Sample Question 2 {T}`); + +const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => { + if (question.type !== "Category") + question.id = (index + 1).toString(); + const newMockQuestion = question; + return {question : newMockQuestion as BaseQuestion}; +}); + +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 }] }, +]; + +const mockShowSelectedQuestion = jest.fn(); + +describe('LiveResults', () => { + test('renders LiveResults component', () => { + render( + + ); + + expect(screen.getByText('Résultats du quiz')).toBeInTheDocument(); + }); + + test('toggles show usernames switch', () => { + render( + + ); + + const switchElement = screen.getByLabelText('Afficher les noms'); + expect(switchElement).toBeInTheDocument(); + + fireEvent.click(switchElement); + expect(switchElement).toBeChecked(); + }); + + test('toggles show correct answers switch', () => { + render( + + ); + + const switchElement = screen.getByLabelText('Afficher les réponses'); + expect(switchElement).toBeInTheDocument(); + + fireEvent.click(switchElement); + expect(switchElement).toBeChecked(); + }); + + test('calls showSelectedQuestion when a table cell is clicked', () => { + render( + + ); + + const tableCell = screen.getByText('Q1'); + fireEvent.click(tableCell); + + expect(mockShowSelectedQuestion).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/LiveResultsTable.test.tsx b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/LiveResultsTable.test.tsx new file mode 100644 index 0000000..021b82d --- /dev/null +++ b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/LiveResultsTable.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { StudentType } from 'src/Types/StudentType'; +import LiveResultsTable from 'src/components/LiveResults/LiveResultsTable/LiveResultsTable'; +import { QuestionType } from 'src/Types/QuestionType'; +import { BaseQuestion, parse } from 'gift-pegjs'; + +const mockGiftQuestions = parse( + `::Sample Question 1:: Sample Question 1 {=Answer 1 ~Answer 2} + + ::Sample Question 2:: Sample Question 2 {T}`); + +const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => { + if (question.type !== "Category") + question.id = (index + 1).toString(); + const newMockQuestion = question; + return {question : newMockQuestion as BaseQuestion}; +}); + + +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 }] }, +]; + +const mockShowSelectedQuestion = jest.fn(); + +describe('LiveResultsTable', () => { + test('renders LiveResultsTable component', () => { + render( + + ); + + expect(screen.getByText('Student 1')).toBeInTheDocument(); + expect(screen.getByText('Student 2')).toBeInTheDocument(); + }); + + test('displays correct and incorrect answers', () => { + render( + + ); + + expect(screen.getByText('Answer 1')).toBeInTheDocument(); + expect(screen.getByText('Answer 2')).toBeInTheDocument(); + }); + + test('calls showSelectedQuestion when a table cell is clicked', () => { + render( + + ); + + const tableCell = screen.getByText('Q1'); + fireEvent.click(tableCell); + + expect(mockShowSelectedQuestion).toHaveBeenCalled(); + }); + + test('calculates and displays student grades', () => { + render( + + ); + + //50% because only one of the two questions have been answered (getALLByText, because there are a value 50% for the %reussite de la question + // and a second one for the student grade) + const gradeElements = screen.getAllByText('50 %'); + expect(gradeElements.length).toBe(2); + + const gradeElements2 = screen.getAllByText('0 %'); + expect(gradeElements2.length).toBe(2); }); + + test('calculates and displays class average', () => { + render( + + ); + + //1 good answer out of 4 possible good answers (the second question has not been answered) + expect(screen.getByText('25 %')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.test.tsx b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.test.tsx new file mode 100644 index 0000000..11e41f1 --- /dev/null +++ b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { StudentType } from 'src/Types/StudentType'; +import LiveResultsTableBody from 'src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody'; +import { QuestionType } from 'src/Types/QuestionType'; +import { BaseQuestion, parse } from 'gift-pegjs'; + + +const mockGiftQuestions = parse( + `::Sample Question 1:: Sample Question 1 {=Answer 1 ~Answer 2} + + ::Sample Question 2:: Sample Question 2 {T}`); + +const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => { + if (question.type !== "Category") + question.id = (index + 1).toString(); + const newMockQuestion = question; + return {question : newMockQuestion as BaseQuestion}; +}); + +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 }] }, +]; + +const mockGetStudentGrade = jest.fn((student: StudentType) => { + const correctAnswers = student.answers.filter(answer => answer.isCorrect).length; + return (correctAnswers / mockQuestions.length) * 100; +}); + +describe('LiveResultsTableBody', () => { + test('renders LiveResultsTableBody component', () => { + render( + + ); + + expect(screen.getByText('Student 1')).toBeInTheDocument(); + expect(screen.getByText('Student 2')).toBeInTheDocument(); + }); + + test('displays correct and incorrect answers', () => { + render( + + ); + + expect(screen.getByText('Answer 1')).toBeInTheDocument(); + expect(screen.getByText('Answer 2')).toBeInTheDocument(); + }); + + test('displays icons for correct and incorrect answers when showCorrectAnswers is false', () => { + render( + + ); + + expect(screen.getByLabelText('correct')).toBeInTheDocument(); + expect(screen.getByLabelText('incorrect')).toBeInTheDocument(); + }); + + test('hides usernames when showUsernames is false', () => { + render( + + ); + + expect(screen.getAllByText('******').length).toBe(2); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableFooter.test.tsx b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableFooter.test.tsx new file mode 100644 index 0000000..99a6dc3 --- /dev/null +++ b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableFooter.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { StudentType } from 'src/Types/StudentType'; +import LiveResultsTableFooter from 'src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultTableFooter'; + + +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 }] }, +]; + +const mockGetStudentGrade = jest.fn((student: StudentType) => { + const correctAnswers = student.answers.filter(answer => answer.isCorrect).length; + return (correctAnswers / 2) * 100; // Assuming there are 2 questions +}); + +describe('LiveResultsTableFooter', () => { + test('renders LiveResultsTableFooter component', () => { + render( + + ); + + expect(screen.getByText('% réussite')).toBeInTheDocument(); + }); + + test('calculates and displays correct answers per question', () => { + render( + + ); + + expect(screen.getByText('50 %')).toBeInTheDocument(); + expect(screen.getByText('0 %')).toBeInTheDocument(); + }); + + test('calculates and displays class average', () => { + render( + + ); + + expect(screen.getByText('50 %')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader.test.tsx b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader.test.tsx new file mode 100644 index 0000000..5dff41a --- /dev/null +++ b/client/src/__tests__/components/GiftTemplate/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import LiveResultsTableHeader from 'src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader'; + + +const mockShowSelectedQuestion = jest.fn(); + +describe('LiveResultsTableHeader', () => { + test('renders LiveResultsTableHeader component', () => { + render( + + ); + + expect(screen.getByText("Nom d'utilisateur")).toBeInTheDocument(); + for (let i = 1; i <= 5; i++) { + expect(screen.getByText(`Q${i}`)).toBeInTheDocument(); + } + expect(screen.getByText('% réussite')).toBeInTheDocument(); + }); + + test('calls showSelectedQuestion when a question header is clicked', () => { + render( + + ); + + const questionHeader = screen.getByText('Q1'); + fireEvent.click(questionHeader); + + expect(mockShowSelectedQuestion).toHaveBeenCalledWith(0); + }); + + test('renders the correct number of question headers', () => { + render( + + ); + + for (let i = 1; i <= 3; i++) { + expect(screen.getByText(`Q${i}`)).toBeInTheDocument(); + } + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/LiveResults/LiveResults.test.tsx b/client/src/__tests__/components/LiveResults/LiveResults.test.tsx new file mode 100644 index 0000000..ce6244e --- /dev/null +++ b/client/src/__tests__/components/LiveResults/LiveResults.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +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'; + +const mockSocket: Socket = { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), +} as unknown as Socket; + +const mockGiftQuestions = parse( + `::Sample Question 1:: Question stem + { + =Choice 1 + ~Choice 2 + }`); + +const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => { + if (question.type !== "Category") + question.id = (index + 1).toString(); + const newMockQuestion = question; + return {question : newMockQuestion as BaseQuestion}; +}); + +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 }] }, +]; + +describe('LiveResults', () => { + test('renders the component with questions and students', () => { + render( + + ); + expect(screen.getByText(`Q${1}`)).toBeInTheDocument(); + + // Toggle the display of usernames + const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms'); + + // 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(); + }); + }); + + test('toggles the display of usernames', () => { + render( + + ); + + // Toggle the display of usernames + const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms'); + + // Toggle the display of usernames back + fireEvent.click(toggleUsernamesSwitch); + + // Check if the usernames are shown again + mockStudents.forEach((student) => { + expect(screen.getByText(student.name)).toBeInTheDocument(); + }); + }); + +}); +test('calculates and displays the correct student grades', () => { + render( + + ); + + + // 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(); + }); +}); + +test('calculates and displays the class average', () => { + render( + + ); + + // 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; + + // 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(); +}); + +test('displays the correct answers per question', () => { + render( + + ); + + // 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(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/pages/ManageRoom/ManageRoom.test.tsx b/client/src/__tests__/pages/ManageRoom/ManageRoom.test.tsx new file mode 100644 index 0000000..684f92e --- /dev/null +++ b/client/src/__tests__/pages/ManageRoom/ManageRoom.test.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, useNavigate, useParams } from 'react-router-dom'; +import ManageRoom from 'src/pages/Teacher/ManageRoom/ManageRoom'; +import { StudentType } from 'src/Types/StudentType'; +import { QuizType } from 'src/Types/QuizType'; +import webSocketService, { AnswerReceptionFromBackendType } from 'src/services/WebsocketService'; +import ApiService from 'src/services/ApiService'; +import { Socket } from 'socket.io-client'; + +jest.mock('src/services/WebsocketService'); +jest.mock('src/services/ApiService'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useParams: jest.fn(), +})); + +const mockSocket = { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), +} as unknown as Socket; + +const mockQuiz: QuizType = { + _id: 'test-quiz-id', + title: 'Test Quiz', + content: ['::Q1:: Question 1 { =Answer1 ~Answer2 }', '::Q2:: Question 2 { =Answer1 ~Answer2 }'], + folderId: 'folder-id', + folderName: 'folder-name', + userId: 'user-id', + created_at: new Date(), + updated_at: new Date() +}; + +const mockStudents: StudentType[] = [ + { id: '1', name: 'Student 1', answers: [] }, + { id: '2', name: 'Student 2', answers: [] }, +]; + +const mockAnswerData: AnswerReceptionFromBackendType = { + answer: 'Answer1', + idQuestion: 1, + idUser: '1', + username: 'Student 1', +}; + +describe('ManageRoom', () => { + const navigate = jest.fn(); + const useParamsMock = useParams as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigate as jest.Mock).mockReturnValue(navigate); + useParamsMock.mockReturnValue({ id: 'test-quiz-id' }); + (ApiService.getQuiz as jest.Mock).mockResolvedValue(mockQuiz); + (webSocketService.connect as jest.Mock).mockReturnValue(mockSocket); + }); + + test('prepares to launch quiz and fetches quiz data', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1]; + createSuccessCallback('test-room-name'); + }); + + await waitFor(() => { + expect(ApiService.getQuiz).toHaveBeenCalledWith('test-quiz-id'); + }); + + const launchButton = screen.getByText('Lancer'); + fireEvent.click(launchButton); + + const rythmeButton = screen.getByText('Rythme du professeur'); + fireEvent.click(rythmeButton); + + const secondLaunchButton = screen.getAllByText('Lancer'); + fireEvent.click(secondLaunchButton[1]); + + await waitFor(() => { + expect(screen.getByText('Test Quiz')).toBeInTheDocument(); + expect(screen.getByText('Salle: test-room-name')).toBeInTheDocument(); + expect(screen.getByText('0/60')).toBeInTheDocument(); + expect(screen.getByText('Question 1/2')).toBeInTheDocument(); + }); + }); + + test('handles create-success event', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1]; + createSuccessCallback('test-room-name'); + }); + + await waitFor(() => { + expect(screen.getByText('Salle: test-room-name')).toBeInTheDocument(); + }); + }); + + test('handles user-joined event', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1]; + createSuccessCallback('test-room-name'); + }); + + await act(async () => { + const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1]; + userJoinedCallback(mockStudents[0]); + }); + + await waitFor(() => { + expect(screen.getByText('Student 1')).toBeInTheDocument(); + + }); + + const launchButton = screen.getByText('Lancer'); + fireEvent.click(launchButton); + + const rythmeButton = screen.getByText('Rythme du professeur'); + fireEvent.click(rythmeButton); + + const secondLaunchButton = screen.getAllByText('Lancer'); + fireEvent.click(secondLaunchButton[1]); + + await waitFor(() => { + expect(screen.getByText('1/60')).toBeInTheDocument(); + + }); + }); + + test('handles submit-answer-room event', async () => { + const consoleSpy = jest.spyOn(console, 'log'); + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1]; + createSuccessCallback('test-room-name'); + }); + + const launchButton = screen.getByText('Lancer'); + fireEvent.click(launchButton); + + const rythmeButton = screen.getByText('Rythme du professeur'); + fireEvent.click(rythmeButton); + + const secondLaunchButton = screen.getAllByText('Lancer'); + fireEvent.click(secondLaunchButton[1]); + + await act(async () => { + const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1]; + userJoinedCallback(mockStudents[0]); + }); + + await act(async () => { + const submitAnswerCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'submit-answer-room')[1]; + submitAnswerCallback(mockAnswerData); + }); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Received answer from Student 1 for question 1: Answer1'); + }); + + consoleSpy.mockRestore(); + }); + + test('handles next question', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1]; + createSuccessCallback('test-room-name'); + }); + + const launchButton = screen.getByText('Lancer'); + fireEvent.click(launchButton); + + const rythmeButton = screen.getByText('Rythme du professeur'); + fireEvent.click(rythmeButton); + + const secondLaunchButton = screen.getAllByText('Lancer'); + fireEvent.click(secondLaunchButton[1]); + + const nextQuestionButton = screen.getByText('Prochaine question'); + fireEvent.click(nextQuestionButton); + + await waitFor(() => { + expect(screen.getByText('Question 2/2')).toBeInTheDocument(); + }); + }); + + test('handles disconnect', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1]; + createSuccessCallback('test-room-name'); + }); + + const disconnectButton = screen.getByText('Quitter'); + fireEvent.click(disconnectButton); + + const confirmButton = screen.getAllByText('Confirmer'); + fireEvent.click(confirmButton[1]); + + await waitFor(() => { + expect(webSocketService.disconnect).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith('/teacher/dashboard'); + }); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/pages/Student/TeacherModeQuiz/TeacherModeQuiz.test.tsx b/client/src/__tests__/pages/Student/TeacherModeQuiz/TeacherModeQuiz.test.tsx index f3b3e57..0332f1a 100644 --- a/client/src/__tests__/pages/Student/TeacherModeQuiz/TeacherModeQuiz.test.tsx +++ b/client/src/__tests__/pages/Student/TeacherModeQuiz/TeacherModeQuiz.test.tsx @@ -53,7 +53,7 @@ describe('TeacherModeQuiz', () => { fireEvent.click(screen.getByText('Répondre')); }); expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1); - expect(screen.getByText('Votre réponse est "Option A".')).toBeInTheDocument(); + expect(screen.getByText('Votre réponse est:')).toBeInTheDocument(); }); test('handles disconnect button click', () => { diff --git a/client/src/components/GIFTCheatSheet/GiftCheatSheet.tsx b/client/src/components/GIFTCheatSheet/GiftCheatSheet.tsx index 98d2b96..036f2d0 100644 --- a/client/src/components/GIFTCheatSheet/GiftCheatSheet.tsx +++ b/client/src/components/GIFTCheatSheet/GiftCheatSheet.tsx @@ -21,11 +21,11 @@ const GiftCheatSheet: React.FC = () => { }; - const QuestionVraiFaux = "2+2 \\= 4 ? {T}\n// Utilisez les valeurs {T}, {F}, {TRUE} \net {FALSE}."; - const QuestionChoixMul = "Quelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Bonne réponse!\n}\n// La bonne réponse est Ottawa"; - const QuestionChoixMulMany = "Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n ~ %33.3% Ottawa \n ~ %33.3% Vancouver \n ~ %-100% New York \n ~ %-100% Paris \n#### La bonne réponse est Montréal, Ottawa et Vancouver \n}\n// Utilisez tilde (signe de vague) pour toutes les réponses.\n// On doit indiquer le pourcentage de chaque réponse."; - const QuestionCourte ="Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n}\n// Permet de fournir plusieurs bonnes réponses.\n// Note: La casse n'est pas prise en compte."; - const QuestionNum ="// Question de plage mathématique. \n Quel est un nombre de 1 à 5 ? {\n#3:2\n}\n \n// Plage mathématique spécifiée avec des points de fin d'intervalle. \n Quel est un nombre de 1 à 5 ? {\n#1..5\n} \n\n// Réponses numériques multiples avec crédit partiel et commentaires.\nQuand est né Ulysses S. Grant ? {\n# =1822:0 # Correct ! Crédit complet. \n=%50%1822:2 # Il est né en 1822. Demi-crédit pour être proche.\n}"; + const QuestionVraiFaux = "::Exemple de question vrai/faux:: \n 2+2 \\= 4 ? {T} //Utilisez les valeurs {T}, {F}, {TRUE} et {FALSE}."; + const QuestionChoixMul = "::Ville capitale du Canada:: \nQuelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Rétroaction spécifique.\n} // Commentaire non visible (au besoin)"; + const QuestionChoixMulMany = "::Villes canadiennes:: \n Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n ~ %33.3% Ottawa \n ~ %33.3% Vancouver \n ~ %-100% New York \n ~ %-100% Paris \n#### Rétroaction globale de la question. \n} // Utilisez tilde (signe de vague) pour toutes les réponses. // On doit indiquer le pourcentage de chaque réponse."; + const QuestionCourte ="::Clé et porte:: \n Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n} // Permet de fournir plusieurs bonnes réponses. // Note: La casse n'est pas prise en compte."; + const QuestionNum ="::Question numérique avec marge:: \nQuel est un nombre de 1 à 5 ? {\n#3:2\n}\n \n// Plage mathématique spécifiée avec des points de fin d'intervalle. \n ::Question numérique avec plage:: \n Quel est un nombre de 1 à 5 ? {\n#1..5\n} \n\n// Réponses numériques multiples avec crédit partiel et commentaires.\n::Question numérique avec plusieurs réponses::\nQuand est né Ulysses S. Grant ? {\n# =1822:0 # Correct ! Crédit complet. \n=%50%1822:2 # Il est né en 1822. Demi-crédit pour être proche.\n}"; return (

Informations pratiques sur l'éditeur

@@ -79,7 +79,7 @@ const GiftCheatSheet: React.FC = () => {
-

5. Question numérique

+

5. Questions numériques

                     
                         {
diff --git a/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx b/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx
index 51dbd3f..1bacf8c 100644
--- a/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx
+++ b/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
 import Template, { ErrorTemplate } from './templates';
 import { parse } from 'gift-pegjs';
 import './styles.css';
-import DOMPurify from 'dompurify';
+import { FormattedTextTemplate } from './templates/TextTypeTemplate';
 
 interface GIFTTemplatePreviewProps {
     questions: string[];
@@ -74,7 +74,8 @@ const GIFTTemplatePreview: React.FC = ({
                 
{error}
) : isPreviewReady ? (
-
+ +
) : (
Chargement de la prévisualisation...
diff --git a/client/src/components/GiftTemplate/templates/TextTypeTemplate.ts b/client/src/components/GiftTemplate/templates/TextTypeTemplate.ts index 8a3e24b..c5569f4 100644 --- a/client/src/components/GiftTemplate/templates/TextTypeTemplate.ts +++ b/client/src/components/GiftTemplate/templates/TextTypeTemplate.ts @@ -4,14 +4,24 @@ import katex from 'katex'; import { TextFormat } from 'gift-pegjs'; import DOMPurify from 'dompurify'; // cleans HTML to prevent XSS attacks, etc. -export function formatLatex(text: string): string { - return text +function formatLatex(text: string): string { + + let renderedText = ''; + + try { + renderedText = text .replace(/\$\$(.*?)\$\$/g, (_, inner) => katex.renderToString(inner, { displayMode: true })) .replace(/\$(.*?)\$/g, (_, inner) => katex.renderToString(inner, { displayMode: false })) .replace(/\\\[(.*?)\\\]/g, (_, inner) => katex.renderToString(inner, { displayMode: true })) .replace(/\\\((.*?)\\\)/g, (_, inner) => katex.renderToString(inner, { displayMode: false }) ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + renderedText = text; + } + + return renderedText; } /** diff --git a/client/src/components/LaunchQuizDialog/LaunchQuizDialog.tsx b/client/src/components/LaunchQuizDialog/LaunchQuizDialog.tsx index 3bd307b..3aaf401 100644 --- a/client/src/components/LaunchQuizDialog/LaunchQuizDialog.tsx +++ b/client/src/components/LaunchQuizDialog/LaunchQuizDialog.tsx @@ -47,10 +47,10 @@ const LaunchQuizDialog: React.FC = ({ open, handleOnClose, launchQuiz, se diff --git a/client/src/components/LiveResults/LiveResults.tsx b/client/src/components/LiveResults/LiveResults.tsx index 4731aa8..f165e10 100644 --- a/client/src/components/LiveResults/LiveResults.tsx +++ b/client/src/components/LiveResults/LiveResults.tsx @@ -1,27 +1,16 @@ // LiveResults.tsx -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { Socket } from 'socket.io-client'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCheck, faCircleXmark } from '@fortawesome/free-solid-svg-icons'; import { QuestionType } from '../../Types/QuestionType'; - import './liveResult.css'; import { - Button, FormControlLabel, FormGroup, - Paper, Switch, - Table, - TableBody, - TableCell, - TableContainer, - TableFooter, - TableHead, - TableRow } from '@mui/material'; import { StudentType } from '../../Types/StudentType'; -import { formatLatex } from '../GiftTemplate/templates/TextTypeTemplate'; + +import LiveResultsTable from './LiveResultsTable/LiveResultsTable'; interface LiveResultsProps { socket: Socket | null; @@ -31,269 +20,10 @@ interface LiveResultsProps { students: StudentType[] } -// interface Answer { -// answer: string | number | boolean; -// isCorrect: boolean; -// idQuestion: number; -// } - -// interface StudentResult { -// username: string; -// idUser: string; -// answers: Answer[]; -// } const LiveResults: React.FC = ({ questions, showSelectedQuestion, students }) => { const [showUsernames, setShowUsernames] = useState(false); const [showCorrectAnswers, setShowCorrectAnswers] = useState(false); - const [showFullAnswer, setShowFullAnswer] = useState(false); - const [selectedAnswer, setSelectedAnswer] = useState(''); - - const handleShowAnswer = (answer: string) => { - setSelectedAnswer(answer); - setShowFullAnswer(true); - }; - - const renderAnswerCell = (answer: string) => { - if (!answer) return null; - const shortAnswer = answer.length > 20 ? answer.slice(0, 20) : answer; - - return ( - <> - {shortAnswer} - {answer.length > 20 && ( - - )} - - ); - }; - // const [students, setStudents] = useState(initialStudents); - // const [studentResultsMap, setStudentResultsMap] = useState>(new Map()); - - const maxQuestions = questions.length; - - // useEffect(() => { - // // Initialize the map with the current students - // const newStudentResultsMap = new Map(); - - // for (const student of students) { - // newStudentResultsMap.set(student.id, { username: student.name, idUser: student.id, answers: [] }); - // } - - // setStudentResultsMap(newStudentResultsMap); - // }, []) - - // update when students change - // useEffect(() => { - // // studentResultsMap is inconsistent with students -- need to update - - // for (const student of students as StudentType[]) { - // } - - // }, [students]) - - // useEffect(() => { - // if (socket) { - // const submitAnswerHandler = ({ - // idUser, - // answer, - // idQuestion - // }: { - // idUser: string; - // username: string; - // answer: string | number | boolean; - // idQuestion: number; - // }) => { - // console.log(`Received answer from ${idUser} for question ${idQuestion}: ${answer}`); - - // // print the list of current student names - // console.log('Current students:'); - // students.forEach((student) => { - // console.log(student.name); - // }); - - // // Update the students state using the functional form of setStudents - // setStudents((prevStudents) => { - // let foundStudent = false; - // const updatedStudents = prevStudents.map((student) => { - // if (student.id === idUser) { - // foundStudent = true; - // const updatedAnswers = student.answers.map((ans) => { - // const newAnswer: Answer = { answer, isCorrect: checkIfIsCorrect(answer, idQuestion), idQuestion }; - // console.log(`Updating answer for ${student.name} for question ${idQuestion} to ${answer}`); - // return (ans.idQuestion === idQuestion ? { ...ans, newAnswer } : ans); - // } - // ); - // return { ...student, answers: updatedAnswers }; - // } - // return student; - // }); - // if (!foundStudent) { - // console.log(`Student ${idUser} not found in the list of students in LiveResults`); - // } - // return updatedStudents; - // }); - - - // // make a copy of the students array so we can update it - // // const updatedStudents = [...students]; - - // // const student = updatedStudents.find((student) => student.id === idUser); - // // if (!student) { - // // // this is a bad thing if an answer was submitted but the student isn't in the list - // // console.log(`Student ${idUser} not found in the list of students in LiveResults`); - // // return; - // // } - - // // const isCorrect = checkIfIsCorrect(answer, idQuestion); - // // const newAnswer: Answer = { answer, isCorrect, idQuestion }; - // // student.answers.push(newAnswer); - // // // print list of answers - // // console.log('Answers:'); - // // student.answers.forEach((answer) => { - // // console.log(answer.answer); - // // }); - // // setStudents(updatedStudents); // update the state - // }; - - // socket.on('submit-answer', submitAnswerHandler); - // return () => { - // socket.off('submit-answer'); - // }; - // } - // }, [socket]); - - const getStudentGrade = (student: StudentType): number => { - if (student.answers.length === 0) { - return 0; - } - - const uniqueQuestions = new Set(); - let correctAnswers = 0; - - for (const answer of student.answers) { - const { idQuestion, isCorrect } = answer; - - if (!uniqueQuestions.has(idQuestion)) { - uniqueQuestions.add(idQuestion); - - if (isCorrect) { - correctAnswers++; - } - } - } - - return (correctAnswers / questions.length) * 100; - }; - - const classAverage: number = useMemo(() => { - let classTotal = 0; - - students.forEach((student) => { - classTotal += getStudentGrade(student); - }); - - return classTotal / students.length; - }, [students]); - - const getCorrectAnswersPerQuestion = (index: number): number => { - return ( - (students.filter((student) => - student.answers.some( - (answer) => - parseInt(answer.idQuestion.toString()) === index + 1 && answer.isCorrect - ) - ).length / students.length) * 100 - ); - }; - - // (studentResults.filter((student) => - // student.answers.some( - // (answer) => - // parseInt(answer.idQuestion.toString()) === index + 1 && answer.isCorrect - // ) - // ).length / - // studentResults.length) * - // 100 - // ); - // }; - - // function checkIfIsCorrect(answer: string | number | boolean, idQuestion: number): 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 GIFTQuestion; - // 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.text.text === answerText - // ); - // } else if (question.type === 'Numerical') { - // if (question.choices && !Array.isArray(question.choices)) { - // if ( - // question.choices.type === 'high-low' && - // question.choices.numberHigh && - // question.choices.numberLow - // ) { - // const answerNumber = parseFloat(answerText); - // if (!isNaN(answerNumber)) { - // return ( - // answerNumber <= question.choices.numberHigh && - // answerNumber >= question.choices.numberLow - // ); - // } - // } - // } - // if (question.choices && Array.isArray(question.choices)) { - // if ( - // question.choices[0].text.type === 'range' && - // question.choices[0].text.number && - // question.choices[0].text.range - // ) { - // const answerNumber = parseFloat(answerText); - // const range = question.choices[0].text.range; - // const correctAnswer = question.choices[0].text.number; - // if (!isNaN(answerNumber)) { - // return ( - // answerNumber <= correctAnswer + range && - // answerNumber >= correctAnswer - range - // ); - // } - // } - // if ( - // question.choices[0].text.type === 'simple' && - // question.choices[0].text.number - // ) { - // const answerNumber = parseFloat(answerText); - // if (!isNaN(answerNumber)) { - // return answerNumber === question.choices[0].text.number; - // } - // } - // } - // } else if (question.type === 'Short') { - // return question.choices.some( - // (choice) => choice.text.text.toUpperCase() === answerText.toUpperCase() - // ); - // } - // } - // return false; - // } return ( @@ -328,201 +58,15 @@ const LiveResults: React.FC = ({ questions, showSelectedQuesti
- - - - - -
Nom d'utilisateur
-
- {Array.from({ length: maxQuestions }, (_, index) => ( - showSelectedQuestion(index)} - > -
{`Q${index + 1}`}
-
- ))} - -
% réussite
-
-
-
- - {students.map((student) => ( - - -
- {showUsernames ? student.name : '******'} -
-
- {Array.from({ length: maxQuestions }, (_, index) => { - const answer = student.answers.find( - (answer) => parseInt(answer.idQuestion.toString()) === index + 1 - ); - const answerText = answer ? answer.answer.toString() : ''; - const isCorrect = answer ? answer.isCorrect : false; - return ( - - {showCorrectAnswers ? ( -
{renderAnswerCell(formatLatex(answerText))}
- ) : isCorrect ? ( - - ) : ( - answerText !== '' && ( - - ) - )} -
- ); - })} - - {getStudentGrade(student).toFixed()} % - -
- ))} -
- - - -
% réussite
-
- {Array.from({ length: maxQuestions }, (_, index) => ( - - {students.length > 0 - ? `${getCorrectAnswersPerQuestion(index).toFixed()} %` - : '-'} - - ))} - - {students.length > 0 ? `${classAverage.toFixed()} %` : '-'} - -
-
-
-
-
- {showFullAnswer && ( -
setShowFullAnswer(false)} - style={{ - position: 'fixed', - top: 0, - left: 0, - right: 0, - bottom: 0, - background: 'rgba(0,0,0,0.3)', - zIndex: 9999,}}> - e.stopPropagation()} - style={{ - position: 'fixed', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - border: 'none', - padding: '1rem', - background: '#fff', - boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)', - minWidth: '300px', - minHeight: '200px', - }} - > -
-

{selectedAnswer}

- -
-
-
-)} + + + ); }; diff --git a/client/src/components/LiveResults/LiveResultsTable/LiveResultsTable.tsx b/client/src/components/LiveResults/LiveResultsTable/LiveResultsTable.tsx new file mode 100644 index 0000000..e23c4a6 --- /dev/null +++ b/client/src/components/LiveResults/LiveResultsTable/LiveResultsTable.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Paper, Table, TableContainer } from '@mui/material'; +import { StudentType } from 'src/Types/StudentType'; +import { QuestionType } from '../../../Types/QuestionType'; +import LiveResultsTableFooter from './TableComponents/LiveResultTableFooter'; +import LiveResultsTableHeader from './TableComponents/LiveResultsTableHeader'; +import LiveResultsTableBody from './TableComponents/LiveResultsTableBody'; + +interface LiveResultsTableProps { + students: StudentType[]; + questions: QuestionType[]; + showCorrectAnswers: boolean; + showSelectedQuestion: (index: number) => void; + showUsernames: boolean; +} + +const LiveResultsTable: React.FC = ({ + questions, + students, + showSelectedQuestion, + showUsernames, + showCorrectAnswers +}) => { + + const maxQuestions = questions.length; + + const getStudentGrade = (student: StudentType): number => { + if (student.answers.length === 0) { + return 0; + } + + const uniqueQuestions = new Set(); + let correctAnswers = 0; + + for (const answer of student.answers) { + const { idQuestion, isCorrect } = answer; + + if (!uniqueQuestions.has(idQuestion)) { + uniqueQuestions.add(idQuestion); + + if (isCorrect) { + correctAnswers++; + } + } + } + + return (correctAnswers / questions.length) * 100; + }; + + + return ( + + + + + +
+
+ ); +}; + +export default LiveResultsTable; \ No newline at end of file diff --git a/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultTableFooter.tsx b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultTableFooter.tsx new file mode 100644 index 0000000..a24694e --- /dev/null +++ b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultTableFooter.tsx @@ -0,0 +1,79 @@ +import { TableCell, TableFooter, TableRow } from "@mui/material"; +import React, { useMemo } from "react"; +import { StudentType } from "src/Types/StudentType"; + +interface LiveResultsFooterProps { + students: StudentType[]; + maxQuestions: number; + getStudentGrade: (student: StudentType) => number; +} + +const LiveResultsTableFooter: React.FC = ({ + maxQuestions, + students, + getStudentGrade + +}) => { + + const getCorrectAnswersPerQuestion = (index: number): number => { + return ( + (students.filter((student) => + student.answers.some( + (answer) => + parseInt(answer.idQuestion.toString()) === index + 1 && answer.isCorrect + ) + ).length / students.length) * 100 + ); + }; + + const classAverage: number = useMemo(() => { + let classTotal = 0; + + students.forEach((student) => { + classTotal += getStudentGrade(student); + }); + + return classTotal / students.length; + }, [students]); + + return ( + + + +
% réussite
+
+ {Array.from({ length: maxQuestions }, (_, index) => ( + + {students.length > 0 + ? `${getCorrectAnswersPerQuestion(index).toFixed()} %` + : '-'} + + ))} + + {students.length > 0 ? `${classAverage.toFixed()} %` : '-'} + +
+
+ ); +}; +export default LiveResultsTableFooter; \ No newline at end of file diff --git a/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx new file mode 100644 index 0000000..a0c67f7 --- /dev/null +++ b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx @@ -0,0 +1,94 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { TableBody, TableCell, TableRow } from "@mui/material"; +import { faCheck, faCircleXmark } from '@fortawesome/free-solid-svg-icons'; +import { FormattedTextTemplate } from '../../../GiftTemplate/templates/TextTypeTemplate'; +import React from "react"; +import { StudentType } from "src/Types/StudentType"; + +interface LiveResultsFooterProps { + maxQuestions: number; + students: StudentType[]; + showUsernames: boolean; + showCorrectAnswers: boolean; + getStudentGrade: (student: StudentType) => number; + +} + +const LiveResultsTableFooter: React.FC = ({ + maxQuestions, + students, + showUsernames, + showCorrectAnswers, + getStudentGrade +}) => { + + return ( + + {students.map((student) => ( + + +
+ {showUsernames ? student.name : '******'} +
+
+ {Array.from({ length: maxQuestions }, (_, index) => { + const answer = student.answers.find( + (answer) => parseInt(answer.idQuestion.toString()) === index + 1 + ); + const answerText = answer ? answer.answer.toString() : ''; + const isCorrect = answer ? answer.isCorrect : false; + + return ( + + {showCorrectAnswers ? ( +
+ ) : isCorrect ? ( + + ) : ( + answerText !== '' && ( + + ) + )} +
+ ); + })} + + {getStudentGrade(student).toFixed()} % + +
+ ))} +
+ ); +}; +export default LiveResultsTableFooter; \ No newline at end of file diff --git a/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader.tsx b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader.tsx new file mode 100644 index 0000000..fb4219b --- /dev/null +++ b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader.tsx @@ -0,0 +1,50 @@ +import { TableCell, TableHead, TableRow } from "@mui/material"; +import React from "react"; + +interface LiveResultsFooterProps { + maxQuestions: number; + showSelectedQuestion: (index: number) => void; +} + +const LiveResultsTableFooter: React.FC = ({ + maxQuestions, + showSelectedQuestion, +}) => { + + return ( + + + +
Nom d'utilisateur
+
+ {Array.from({ length: maxQuestions }, (_, index) => ( + showSelectedQuestion(index)} + > +
{`Q${index + 1}`}
+
+ ))} + +
% réussite
+
+
+
+ ); +}; +export default LiveResultsTableFooter; \ No newline at end of file diff --git a/client/src/components/QuestionsDisplay/questionStyle.css b/client/src/components/QuestionsDisplay/questionStyle.css index 3958e92..cdf611f 100644 --- a/client/src/components/QuestionsDisplay/questionStyle.css +++ b/client/src/components/QuestionsDisplay/questionStyle.css @@ -27,7 +27,6 @@ } .question-wrapper .katex { - display: block; text-align: center; } @@ -120,9 +119,9 @@ } .feedback-container { - margin-left: 1.1rem; - display: inline-flex !important; /* override the parent */ + display: inline-block !important; /* override the parent */ align-items: center; + margin-left: 1.1rem; position: relative; padding: 0 0.5rem; background-color: hsl(43, 100%, 94%); diff --git a/client/src/components/TeacherModeQuiz/TeacherModeQuiz.tsx b/client/src/components/TeacherModeQuiz/TeacherModeQuiz.tsx index ae2d382..3188211 100644 --- a/client/src/components/TeacherModeQuiz/TeacherModeQuiz.tsx +++ b/client/src/components/TeacherModeQuiz/TeacherModeQuiz.tsx @@ -23,16 +23,33 @@ const TeacherModeQuiz: React.FC = ({ }) => { const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false); const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); - const [feedbackMessage, setFeedbackMessage] = useState(''); + const [feedbackMessage, setFeedbackMessage] = useState(''); + + const renderFeedbackMessage = (answer: string) => { + if(answer === 'true' || answer === 'false'){ + return ( + Votre réponse est: {answer==="true" ? 'Vrai' : 'Faux'} + ) + } + else{ + return ( + + Votre réponse est: {answer.toString()} + + );} + }; useEffect(() => { + // Close the feedback dialog when the question changes + handleFeedbackDialogClose(); setIsAnswerSubmitted(false); - }, [questionInfos]); + + }, [questionInfos.question]); const handleOnSubmitAnswer = (answer: string | number | boolean) => { const idQuestion = Number(questionInfos.question.id) || -1; submitAnswer(answer, idQuestion); - setFeedbackMessage(`Votre réponse est "${answer.toString()}".`); + setFeedbackMessage(renderFeedbackMessage(answer.toString())); setIsFeedbackDialogOpen(true); }; @@ -74,7 +91,17 @@ const TeacherModeQuiz: React.FC = ({ > Rétroaction +
{feedbackMessage} +
Question :
+
+ { const [quizMode, setQuizMode] = useState<'teacher' | 'student'>('teacher'); const [connectingError, setConnectingError] = useState(''); const [currentQuestion, setCurrentQuestion] = useState(undefined); - + const [quizStarted, setQuizStarted] = useState(false); + useEffect(() => { if (quizId.id) { const fetchquiz = async () => { @@ -145,7 +147,7 @@ const ManageRoom: React.FC = () => { console.log('Quiz questions not found (cannot update answers without them).'); return; } - + // Update the students state using the functional form of setStudents setStudents((prevStudents) => { // print the list of current student names @@ -153,7 +155,7 @@ const ManageRoom: React.FC = () => { prevStudents.forEach((student) => { console.log(student.name); }); - + let foundStudent = false; const updatedStudents = prevStudents.map((student) => { console.log(`Comparing ${student.id} to ${idUser}`); @@ -173,7 +175,7 @@ const ManageRoom: React.FC = () => { updatedAnswers = [...student.answers, newAnswer]; } return { ...student, answers: updatedAnswers }; - } + } return student; }); if (!foundStudent) { @@ -316,13 +318,18 @@ const ManageRoom: React.FC = () => { if (!socket || !roomName || !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: ${roomName}, quiz: ${quiz}`); + setQuizStarted(true); + return; } switch (quizMode) { case 'student': + setQuizStarted(true); return launchStudentMode(); case 'teacher': + setQuizStarted(true); return launchTeacherMode(); + } }; @@ -427,9 +434,19 @@ const ManageRoom: React.FC = () => { askConfirm message={`Êtes-vous sûr de vouloir quitter?`} /> -
-
Salle: {roomName}
-
Utilisateurs: {students.length}/60
+ + + +
+
+
Salle: {roomName}
+
+ {quizStarted && ( +
+ + {students.length}/60 +
+ )}
@@ -441,8 +458,12 @@ const ManageRoom: React.FC = () => { {quizQuestions ? (
-
{quiz?.title}
+ {!isNaN(Number(currentQuestion?.question.id)) && ( + + Question {Number(currentQuestion?.question.id)}/{quizQuestions?.length} + + )} {quizMode === 'teacher' && ( @@ -479,23 +500,23 @@ const ManageRoom: React.FC = () => {
{quizMode === 'teacher' && ( -
-
- -
-
- -
-
)} +
+
+ +
+
+ +
+
)}
diff --git a/client/src/pages/Teacher/ManageRoom/manageRoom.css b/client/src/pages/Teacher/ManageRoom/manageRoom.css index ffb83fa..ad870a9 100644 --- a/client/src/pages/Teacher/ManageRoom/manageRoom.css +++ b/client/src/pages/Teacher/ManageRoom/manageRoom.css @@ -18,8 +18,8 @@ display: flex; flex-direction: column; - justify-content: center; - align-items: center; + justify-content: flex-end; + align-items: flex-end; }