diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx deleted file mode 100644 index ee71223d..00000000 --- a/webapp/src/App.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { useState } from 'react'; -import './App.css'; -import { Game } from './components/Game'; -import { Participation } from './components/Participation'; - -function App() { - const [menuState, setMenuState] = useState(0); - - const goTo = (parameter) => { - setMenuState(parameter); - }; - - return ( - <> - {menuState === 0 && goTo(x)} />} - {menuState === 1 && goTo(x)} />} - - ); -} - -export default App; diff --git a/webapp/src/SessionContext.js b/webapp/src/SessionContext.js new file mode 100644 index 00000000..88465be5 --- /dev/null +++ b/webapp/src/SessionContext.js @@ -0,0 +1,33 @@ +import React, { createContext, useState, useEffect } from 'react'; + +export const SessionContext = createContext(); + +export const SessionProvider = ({ children }) => { + const [sessionData, setSessionData] = useState(() => { + const storedSessionData = localStorage.getItem('sessionData'); + return storedSessionData ? JSON.parse(storedSessionData) : null; + }); + + const saveSessionData = (data) => { + setSessionData(data); + localStorage.setItem('sessionData', JSON.stringify(data)); + }; + + const clearSessionData = () => { + setSessionData(null); + localStorage.removeItem('sessionData'); + }; + + useEffect(() => { + const storedSessionData = localStorage.getItem('sessionData'); + if (storedSessionData) { + setSessionData(JSON.parse(storedSessionData)); + } + }, []); + + return ( + + {children} + + ); +}; diff --git a/webapp/src/assets/defaultImgProfile.jpg b/webapp/src/assets/defaultImgProfile.jpg new file mode 100644 index 00000000..08e4a5b4 Binary files /dev/null and b/webapp/src/assets/defaultImgProfile.jpg differ diff --git a/webapp/src/assets/sonidoOFF.png b/webapp/src/assets/sonidoOFF.png new file mode 100644 index 00000000..3ef0dbaa Binary files /dev/null and b/webapp/src/assets/sonidoOFF.png differ diff --git a/webapp/src/assets/sonidoON.png b/webapp/src/assets/sonidoON.png new file mode 100644 index 00000000..bc0f5ad2 Binary files /dev/null and b/webapp/src/assets/sonidoON.png differ diff --git a/webapp/src/audio/correct.mp3 b/webapp/src/audio/correct.mp3 new file mode 100644 index 00000000..9e371409 Binary files /dev/null and b/webapp/src/audio/correct.mp3 differ diff --git a/webapp/src/audio/incorrect.mp3 b/webapp/src/audio/incorrect.mp3 new file mode 100644 index 00000000..8a58254f Binary files /dev/null and b/webapp/src/audio/incorrect.mp3 differ diff --git a/webapp/src/components/Game.jsx b/webapp/src/components/Game.jsx index 1dea9ae8..11e77a76 100644 --- a/webapp/src/components/Game.jsx +++ b/webapp/src/components/Game.jsx @@ -1,165 +1,6 @@ -import { Card, List, ListItem, ListItemButton, ListItemText, Typography } from '@mui/material' - -import React from 'react' -import { useState, useEffect } from 'react' -import { PostGame } from './PostGame' - -const N_QUESTIONS = 10 -const MAX_TIME = 600; - -const gatewayUrl=process.env.REACT_APP_API_ENDPOINT||"http://localhost:8000" - -const Question = ({ goTo, setGameFinished }) => { - - const [question, setQuestion] = useState(''); - const [options, setOptions] = useState([]); - - const [selectedOption, setSelectedOption] = useState(null); - const [selectedIndex, setSelectedIndex] = useState(); - const [isSelected, setIsSelected] = useState(false); - - const [correct, setCorrect] = useState(''); - const [numberCorrect, setNumberCorrect] = useState(0); - const [nQuestion, setNQuestion] = useState(0); - - const [segundos, setSegundos] = useState(MAX_TIME); - useEffect(() => { - - const intervalId = setInterval(() => { - setSegundos(segundos => { - if (segundos === 1) { clearInterval(intervalId); finish() } - return segundos - 1; - }); - }, 1000); - - return () => clearInterval(intervalId); - // eslint-disable-next-line - }, []); - - const formatTiempo = (segundos) => { - const minutos = Math.floor((segundos % 3600) / 60); - const segs = segundos % 60; - return `${minutos < 10 ? '0' : ''}${minutos}:${segs < 10 ? '0' : ''}${segs}`; - }; - - const fetchQuestion = async () => { - - try { - const response = await fetch(`${gatewayUrl}/api/questions/create`, { - method: 'GET' - }); - const data = await response.json(); - - setQuestion(data.question); - setCorrect(data.correct); - setOptions(shuffleOptions([data.correct, ...data.incorrects])); - - setSelectedOption(null); - setIsSelected(false); - setNQuestion((prevNQuestion) => prevNQuestion + 1); - handleGameFinish(); - } catch (error) { - console.error('Error fetching question:', error); - } - }; - - - const getBackgroundColor = (option, index) => { - - if (selectedOption == null) return 'transparent'; - - if (!isCorrect(option) && index === selectedIndex) return 'red'; - - if (isCorrect(option)) return 'green'; - }; - - // @SONAR_STOP@ - // sonarignore:start - const shuffleOptions = (options) => { - //NOSONAR - return options.sort(() => Math.random() - 0.5); //NOSONAR - //NOSONAR - }; - // sonarignore:end - // @SONAR_START@ - - const handleSubmit = (option, index) => { - - if (isSelected) return; - - setSelectedOption(option); - setSelectedIndex(index); - setIsSelected(true); - - if (isCorrect(option)) { - setNumberCorrect(numberCorrect+1); - } - }; - - const isCorrect = (option) => { - - return option === correct; - }; - - const handleGameFinish = () => { - - if (nQuestion === N_QUESTIONS) { finish() } - if (segundos === 1) { setSegundos(0); finish() } - } - - const finish = () => { - // Almacenar datos - localStorage.setItem("pAcertadas", numberCorrect); - localStorage.setItem("pFalladas", N_QUESTIONS - numberCorrect); - localStorage.setItem("tiempoUsado", MAX_TIME - segundos); - localStorage.setItem("tiempoRestante", segundos) - - setGameFinished(true); - goTo(1); - } - - useEffect(() => { - fetchQuestion(); - // eslint-disable-next-line - }, []); - - return( - -
-
-
- Question: {nQuestion} - Time: {formatTiempo(segundos)} -
- - - - {question} - - - - {options.map((option, index) => ( - handleSubmit(option, index) } key={index} sx={{ bgcolor: getBackgroundColor(option, index)}}> - - - {option} - - - - ))} - - - { isSelected ? ( - - fetchQuestion() } sx={{ justifyContent: 'center' , marginTop: 2}} > - Next - - ) : (null) - } -
-
- ) -} +import React, { useState, useEffect } from 'react'; +import { PostGame } from './PostGame'; +import Question from './Question'; export const Game = () => { const [gameState, setGameState] = useState(0); diff --git a/webapp/src/components/Login.js b/webapp/src/components/Login.js index d8d076d3..924807ca 100644 --- a/webapp/src/components/Login.js +++ b/webapp/src/components/Login.js @@ -1,9 +1,13 @@ // src/components/Login.js -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; import axios from 'axios'; import { Container, Typography, TextField, Button, Snackbar } from '@mui/material'; +import { SessionContext } from '../SessionContext'; const Login = ({ goTo }) => { + + const { saveSessionData } = useContext(SessionContext); + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -20,12 +24,12 @@ const Login = ({ goTo }) => { const response = await axios.post(`${apiEndpoint}/login`, { username, password }); // Extract data from the response - const { createdAt: userCreatedAt } = response.data; + const { createdAt: userCreatedAt, username: loggedInUsername } = response.data; setTimeStart(Date.now()); setCreatedAt(userCreatedAt); setLoginSuccess(true); - + saveSessionData({ username: loggedInUsername, createdAt: userCreatedAt }); setOpenSnackbar(true); } catch (error) { setError(error.response.data.error); diff --git a/webapp/src/components/Nav.jsx b/webapp/src/components/Nav.jsx index f8658219..3ec83949 100644 --- a/webapp/src/components/Nav.jsx +++ b/webapp/src/components/Nav.jsx @@ -13,7 +13,15 @@ import Tooltip from '@mui/material/Tooltip'; import MenuItem from '@mui/material/MenuItem'; import AdbIcon from '@mui/icons-material/Adb'; +import { useContext } from 'react'; +import { SessionContext } from '../SessionContext'; +import profileImage from '../assets/defaultImgProfile.jpg'; + function Nav({ goTo }) { + + const { sessionData } = useContext(SessionContext); + const username = sessionData ? sessionData.username : 'noUser'; + const [anchorElNav, setAnchorElNav] = React.useState(null); const [anchorElUser, setAnchorElUser] = React.useState(null); @@ -53,17 +61,16 @@ function Nav({ goTo }) { noWrap component="a" href="#app-bar-with-responsive-menu" + onClick={() => goToMenuClic()} sx={{ mr: 2, display: { xs: 'none', md: 'flex' }, - fontFamily: 'roboto', fontWeight: 700, - letterSpacing: '.3rem', color: 'inherit', - textDecoration: 'none', + textDecoration: 'none' }} > - React Quiz + ASW WIQ @@ -101,38 +108,21 @@ function Nav({ goTo }) { - - - React Quiz - + - + + {username} - + { }; return ( +

Participation

{participationData ? ( @@ -54,7 +55,7 @@ export const Participation = ({ userId, goTo }) => { ) : (

Cargando datos de participación...

)} -
+
); }; \ No newline at end of file diff --git a/webapp/src/components/Question.jsx b/webapp/src/components/Question.jsx new file mode 100644 index 00000000..fcb1ad2c --- /dev/null +++ b/webapp/src/components/Question.jsx @@ -0,0 +1,192 @@ +// Question.js +import React, { useState, useEffect, useContext } from 'react'; +import { Card, List, ListItem, ListItemButton, ListItemText, Typography } from '@mui/material'; +import { SessionContext } from '../SessionContext'; +import correctSound from '../audio/correct.mp3'; +import incorrectSound from '../audio/incorrect.mp3'; +import soundOnImage from '../assets/sonidoON.png'; +import soundOffImage from '../assets/sonidoOFF.png'; +import PropTypes from 'prop-types'; + + +const N_QUESTIONS = 10; +const MAX_TIME = 120; + +const correctAudio = new Audio(correctSound); +const incorrectAudio = new Audio(incorrectSound); + +const gatewayUrl = process.env.REACT_APP_API_ENDPOINT || "http://localhost:8000"; + +export const finishByQuestions = (segundos, MAX_TIME) => { + localStorage.setItem("tiempoUsado", MAX_TIME - segundos); + localStorage.setItem("tiempoRestante", segundos); +}; + +export const finishByTime = (sonido) => { + localStorage.setItem("tiempoUsado", MAX_TIME); + localStorage.setItem("tiempoRestante", 0); + if (sonido) { incorrectAudio.play(); } +}; + +export const handleGameFinish = (nQuestion, numberCorrect, segundos, MAX_TIME, sonido) => { + if (nQuestion === N_QUESTIONS) { + localStorage.setItem("pAcertadas", numberCorrect); + localStorage.setItem("pFalladas", N_QUESTIONS - numberCorrect); + finishByQuestions(segundos, MAX_TIME); + } + if (segundos === 1) { + localStorage.setItem("pAcertadas", numberCorrect); + localStorage.setItem("pFalladas", N_QUESTIONS - numberCorrect); + finishByTime(sonido); + } +}; + +const Question = ({ goTo, setGameFinished }) => { + localStorage.setItem("pAcertadas", 0); + + useContext(SessionContext); + + const [question, setQuestion] = useState(''); + const [options, setOptions] = useState([]); + const [selectedOption, setSelectedOption] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(); + const [isSelected, setIsSelected] = useState(false); + const [correct, setCorrect] = useState(''); + const [numberCorrect, setNumberCorrect] = useState(0); + const [nQuestion, setNQuestion] = useState(-1); + const [segundos, setSegundos] = useState(MAX_TIME); + const [sonido, setSonido] = useState(true); + + useEffect(() => { + const intervalId = setInterval(() => { + setSegundos(segundos => { + if (segundos === 1) { clearInterval(intervalId); finishByTime(sonido); setGameFinished(true); goTo(1); } + return segundos - 1; + }); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + + const formatTiempo = (segundos) => { + const minutos = Math.floor((segundos % 3600) / 60); + const segs = segundos % 60; + return `${minutos < 10 ? '0' : ''}${minutos}:${segs < 10 ? '0' : ''}${segs}`; + }; + + const fetchQuestion = async () => { + try { + const response = await fetch(`${gatewayUrl}/api/questions/create`, { + method: 'GET' + }); + const data = await response.json(); + + setQuestion(data.question); + setCorrect(data.correct); + setOptions(shuffleOptions([data.correct, ...data.incorrects])); + + setSelectedOption(null); + setIsSelected(false); + setNQuestion((prevNQuestion) => prevNQuestion + 1); + handleGameFinish(nQuestion, numberCorrect, segundos, MAX_TIME, sonido); + if (nQuestion === N_QUESTIONS) { setGameFinished(true); goTo(1);} + if (segundos === 1) {setGameFinished(true); goTo(1);} + } catch (error) { + console.error('Error fetching question:', error); + } + }; + + const getBackgroundColor = (option, index) => { + if (selectedOption == null) return 'transparent'; + if (!isCorrect(option) && index === selectedIndex) return 'red'; + if (isCorrect(option)) return 'green'; + }; + + // @SONAR_STOP@ + // sonarignore:start + const shuffleOptions = (options) => { + //NOSONAR + return options.sort(() => Math.random() - 0.5); //NOSONAR + //NOSONAR + }; + // sonarignore:end + // @SONAR_START@ + + const handleSubmit = (option, index) => { + if (isSelected) return; + + setSelectedOption(option); + setSelectedIndex(index); + setIsSelected(true); + + if (isCorrect(option)) { + setNumberCorrect(numberCorrect + 1); + if (sonido) { correctAudio.play(); } + } else if (sonido) { + incorrectAudio.play(); + } + }; + + const isCorrect = (option) => { + return option === correct; + }; + + useEffect(() => { + fetchQuestion(); + }, []); + + // @SONAR_STOP@ + // sonarignore:start + const generateUniqueId = () => { + //NOSONAR + return Math.random().toString(36).substr(2, 9);//NOSONAR + //NOSONAR + }; + // sonarignore:end + // @SONAR_START@ + + return ( +
+
+
+
+ + Question: {nQuestion} +
+ Time: {formatTiempo(segundos)} +
+ + + {question} + + + {options.map((option, index) => ( + handleSubmit(option, index)} key={generateUniqueId()} + sx={{ bgcolor: getBackgroundColor(option, index) }}> + + + {option} + + + + ))} + + + fetchQuestion() : null} + sx={{ justifyContent: 'center', marginTop: 2 }} + className={isSelected ? '' : 'isNotSelected'} > + Next + +
+
+ ); +} + +Question.propTypes = { + goTo: PropTypes.func.isRequired, // Asegura que goTo sea una función y es requerida + setGameFinished: PropTypes.func.isRequired, +}; + +export default Question; diff --git a/webapp/src/components/Start.jsx b/webapp/src/components/Start.jsx index 6ee87712..5de28494 100644 --- a/webapp/src/components/Start.jsx +++ b/webapp/src/components/Start.jsx @@ -5,13 +5,13 @@ export const Start = ({ goTo }) => { <>
-

Quiz ASW

+

ASW Quiz - WIQ

diff --git a/webapp/src/css/index.css b/webapp/src/css/index.css index 493a2444..490f3690 100644 --- a/webapp/src/css/index.css +++ b/webapp/src/css/index.css @@ -85,6 +85,10 @@ button:focus-visible { gap: 10px; } +.tituloStart { + color: #8f95fd; +} + .questionTime { display: flex; justify-content: space-between; @@ -110,6 +114,35 @@ button:focus-visible { font-family: 'Roboto slab'; } +.navButton:hover { + color: #747bff; +} + +.isNotSelected { + color: #555 !important; + cursor: not-allowed; + pointer-events: none; +} + +.disabledButton { + cursor: not-allowed; + pointer-events: none; +} + +.audioQuestion { + margin: 1px; +} + +.audioQuestion button { + vertical-align: middle; +} + +.audioImg { + width: 1.6em; + cursor: pointer; + vertical-align: middle; +} + @media (prefers-color-scheme: light) { :root { color: #213547; diff --git a/webapp/src/index.js b/webapp/src/index.js index 125d7182..4e0fef57 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; +import { SessionProvider } from './SessionContext'; const darkTheme = createTheme({ palette: { @@ -23,10 +24,12 @@ const darkTheme = createTheme({ const root = ReactDOM.createRoot(document.getElementById('root')); root.render( + + ); diff --git a/webapp/src/test/App.test.js b/webapp/src/test/App.test.js index 7faf84d5..6a4cfb52 100644 --- a/webapp/src/test/App.test.js +++ b/webapp/src/test/App.test.js @@ -1,8 +1,14 @@ import { render, screen } from '@testing-library/react'; import App from '../App'; +import { SessionProvider } from '../SessionContext'; test('renders learn react link', () => { - render(); + render( + + + + ); + const linkElement = screen.getByText(/Welcome to the 2024 edition of the Software Architecture course/i); expect(linkElement).toBeInTheDocument(); }); diff --git a/webapp/src/test/Game.test.js b/webapp/src/test/Game.test.js index e7d8d221..af9af1e9 100644 --- a/webapp/src/test/Game.test.js +++ b/webapp/src/test/Game.test.js @@ -1,6 +1,7 @@ import React from 'react'; -import { render, fireEvent, act } from '@testing-library/react'; +import { render, fireEvent, act, waitFor, findByText, screen } from '@testing-library/react'; import { Game } from '../components/Game'; +import { SessionProvider } from '../SessionContext'; const MAX_TIME = 600; @@ -43,40 +44,46 @@ describe('Game component', () => { }); it('renders question and options correctly', async () => { - const { getByText, findAllByText } = render(); + const { getByText, findAllByText } = render( + + + + ); expect(getByText(/Question/i)).toBeInTheDocument(); const options = await findAllByText(/./i); //expect(options).toHaveLength(4); // Verifica que haya 4 opciones }); it('handles option selection correctly', async () => { - const { getByText, findByText } = render(); - await findByText(mockQuestions[0].question); // Espera a que se cargue la pregunta + const { getByText, findAllByText } = render( + + + + ); - const correctOption = getByText(mockQuestions[0].correct); - fireEvent.click(correctOption); - //expect(correctOption.parentElement).toHaveStyle('background-color: green'); - - const incorrectOption = getByText(mockQuestions[0].incorrects[0]); - fireEvent.click(incorrectOption); - //expect(incorrectOption.parentElement).toHaveStyle('background-color: red'); - }); + await waitFor(() => { + expect(getByText(mockQuestions[0].question)).toBeInTheDocument(); + }); - it('handles Next button click correctly', async () => { - const { getByText, findByText } = render(); - await findByText(mockQuestions[0].question); // Espera a que se cargue la pregunta + // Seleccionar la opción correcta + fireEvent.click(getByText(mockQuestions[0].correct)); + expect(getByText(mockQuestions[0].correct).parentElement.toBeInTheDocument); - //const nextButton = getByText(/Next/i); - //fireEvent.click(nextButton); - //expect(global.fetch).toHaveBeenCalledTimes(2); // Verifica que se hizo una segunda llamada a fetch para obtener la siguiente pregunta + // Seleccionar una opción incorrecta + fireEvent.click(getByText(mockQuestions[0].incorrects[0])); + expect(getByText(mockQuestions[0].incorrects[0]).parentElement.toBeInTheDocument); }); // Test para verificar que el juego finaliza cuando se alcanza el número máximo de preguntas -test('El juego finaliza correctamente cuando se alcanza el número máximo de preguntas', async () => { + test('El juego finaliza correctamente cuando se alcanza el número máximo de preguntas', async () => { - render( {}} setGameFinished={() => {}} />); - act(() => { - jest.advanceTimersByTime(MAX_TIME * 1000); + await act(async () => { + render( + + {}} setGameFinished={() => {}} /> + + ); + jest.advanceTimersByTime(MAX_TIME * 1000); + }); }); }); -}); diff --git a/webapp/src/test/Login.test.js b/webapp/src/test/Login.test.js index d43d4baa..12486cc3 100644 --- a/webapp/src/test/Login.test.js +++ b/webapp/src/test/Login.test.js @@ -3,9 +3,14 @@ import { render, fireEvent, screen, waitFor, act } from '@testing-library/react' import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import Login from '../components/Login'; +import { SessionContext } from '../SessionContext'; const mockAxios = new MockAdapter(axios); +const mockValue = { + saveSessionData: () => {} +}; + describe('Login component', () => { beforeEach(() => { mockAxios.reset(); @@ -13,7 +18,11 @@ describe('Login component', () => { it('should log in successfully', async () => { - render( {}} />); + render( + + {}} /> + + ); const usernameInput = screen.getByLabelText(/Username/i); const passwordInput = screen.getByLabelText(/Password/i); @@ -36,7 +45,11 @@ describe('Login component', () => { it('should handle error when logging in', async () => { - render( {}} />); + render( + + {}} /> + + ); const usernameInput = screen.getByLabelText(/Username/i); const passwordInput = screen.getByLabelText(/Password/i); diff --git a/webapp/src/test/Nav.test.js b/webapp/src/test/Nav.test.js index 5b9215f5..c624cd49 100644 --- a/webapp/src/test/Nav.test.js +++ b/webapp/src/test/Nav.test.js @@ -1,11 +1,20 @@ import React from 'react'; +import { useContext } from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; import Nav from '../components/Nav'; +import { SessionProvider, SessionContext } from '../SessionContext'; + + describe('Nav Component', () => { test('opens and closes navigation menu on menu icon click', async () => { - const { getByLabelText, queryByText } = render(