From 1a992a871e6b9632927521c88cfcbc302ebdd7ca Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Mon, 28 Oct 2024 22:58:56 +0100 Subject: [PATCH 01/12] [Added] Setup for running production in docker-compose --- docker-compose.yml | 8 ++++++ frontend/.devcontainer/Dockerfile | 21 ---------------- frontend/.devcontainer/devcontainer.json | 9 ++++--- frontend/.devcontainer/oldDockerfile.txt | 21 ++++++++++++++++ frontend/.dockerignore | 9 +++++++ frontend/Dockerfile | 31 ++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 docker-compose.yml delete mode 100644 frontend/.devcontainer/Dockerfile create mode 100644 frontend/.devcontainer/oldDockerfile.txt create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5765b4f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: production + ports: + - "4200:80" \ No newline at end of file diff --git a/frontend/.devcontainer/Dockerfile b/frontend/.devcontainer/Dockerfile deleted file mode 100644 index 6b60469..0000000 --- a/frontend/.devcontainer/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM node:22-bookworm-slim - -ARG WORKDIR -WORKDIR ${WORKDIR} - -RUN apt-get update && \ - apt-get install -y git && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# Install Angular CLI globally -RUN npm install -g @angular/cli - -# Switching user so it doesn't use root -# If root adds files, it takes ownership, making them unremovable on the host except by using sudo -# node tranfers ownership to the host user -USER node - -EXPOSE 4200 - -CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/frontend/.devcontainer/devcontainer.json b/frontend/.devcontainer/devcontainer.json index 0ef6dbc..db9dbcb 100644 --- a/frontend/.devcontainer/devcontainer.json +++ b/frontend/.devcontainer/devcontainer.json @@ -2,11 +2,12 @@ "name": "Quiz Frontend", "build": { - "context": ".", - "dockerfile": "Dockerfile", + "context": "..", + "dockerfile": "../Dockerfile", "args": { - "WORKDIR": "/app" - } + // "WORKDIR": "/app" + }, + "target": "development" }, "runArgs": [ "--name=quiz-frontend-dev-cont", diff --git a/frontend/.devcontainer/oldDockerfile.txt b/frontend/.devcontainer/oldDockerfile.txt new file mode 100644 index 0000000..1ec5c0b --- /dev/null +++ b/frontend/.devcontainer/oldDockerfile.txt @@ -0,0 +1,21 @@ +# FROM node:22-bookworm-slim + +# ARG WORKDIR +# WORKDIR ${WORKDIR} + +# RUN apt-get update && \ +# apt-get install -y git && \ +# apt-get clean && \ +# rm -rf /var/lib/apt/lists/* + +# # Install Angular CLI globally +# RUN npm install -g @angular/cli + +# # Switching user so it doesn't use root +# # If root adds files, it takes ownership, making them unremovable on the host except by using sudo +# # node tranfers ownership to the host user +# USER node + +# EXPOSE 4200 + +# CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e8a6b1c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules/ +.git/ +.gitignore +README.md +LICENSE +.vscode/ +Dockerfile* +docker-compose* +npm-debug.log \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..619be8d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +FROM node:22-bookworm-slim AS base +WORKDIR /app +ENV NG_CLI_ANALYTICS=ci +RUN npm install -g @angular/cli + +#--- Development +FROM base AS development +RUN apt-get update && \ + apt-get install -y git && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +# Switching user so it doesn't use root +# If root adds files, it takes ownership, making them unremovable on the host except by using sudo +# node tranfers ownership to the host user +USER node +EXPOSE 4200 +CMD ["tail","-f","/dev/null"] + +#--- Building +FROM base AS build +COPY package*.json ./ +RUN npm install +# Copied here, since the the Development setup uses a bind volume instead +COPY . . +RUN npm run build --prod + +#--- Production +FROM nginx:alpine AS production +COPY --from=build /app/dist/quiz/browser /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file From bf66869ebf5ef45ef84c98c2fc0b3d7a11a05c0e Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Tue, 29 Oct 2024 21:21:14 +0100 Subject: [PATCH 02/12] [Added] Basis for Backend --- backend/.devcontainer/Dockerfile | 15 --- backend/.devcontainer/devcontainer.json | 18 ++- backend/Dockerfile | 26 ++++ backend/pom.xml | 11 ++ .../com/ninjaws/quiz/Config/AppConfig.java | 14 ++ .../quiz/Controllers/QuizController.java | 37 +++++ .../Exception/GlobalExceptionHandler.java | 20 +++ .../com/ninjaws/quiz/Models/ApiResponse.java | 21 +++ .../com/ninjaws/quiz/Models/Question.java | 59 ++++++++ .../java/com/ninjaws/quiz/Models/Request.java | 14 ++ .../java/com/ninjaws/quiz/Models/Session.java | 18 +++ .../com/ninjaws/quiz/QuizApplication.java | 2 + .../com/ninjaws/quiz/Services/ApiService.java | 23 ++++ .../ninjaws/quiz/Services/QuizService.java | 126 ++++++++++++++++++ .../src/main/resources/application.properties | 1 + backend/src/main/resources/static/index.html | 1 + docker-compose.yml | 2 + 17 files changed, 388 insertions(+), 20 deletions(-) delete mode 100644 backend/.devcontainer/Dockerfile create mode 100644 backend/Dockerfile create mode 100644 backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Question.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Request.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Session.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java create mode 100644 backend/src/main/resources/static/index.html diff --git a/backend/.devcontainer/Dockerfile b/backend/.devcontainer/Dockerfile deleted file mode 100644 index deef895..0000000 --- a/backend/.devcontainer/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Use an official OpenJDK as a parent image -FROM openjdk:17-jdk-slim - -# Set the working directory -WORKDIR /app - -# Install Maven -RUN apt-get update && apt-get install -y \ - maven=3.3.9-4 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN mvn clean package - -CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/backend/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json index 3075d71..aead60f 100644 --- a/backend/.devcontainer/devcontainer.json +++ b/backend/.devcontainer/devcontainer.json @@ -2,13 +2,19 @@ "name": "Quiz Backend", "build": { - "context": ".", - "dockerfile": "Dockerfile", + "context": "..", + "dockerfile": "../Dockerfile", "args": { - "WORKDIR": "/app" - } + "WORKDIR": "/app", + // "UID": "${localEnv:USER_UID}", + // "GID": "${localEnv:USER_GID}" + }, + "target": "development" }, - "runArgs": ["--name=quiz-backend-dev-cont"], + "runArgs": [ + "--name=quiz-backend-dev-cont", + "--net=host" // Allows it to be opened on the host network + ], "workspaceFolder": "/app", "mounts": [ "source=.,target=/app,type=bind" @@ -17,6 +23,8 @@ "vscode": { "settings": {}, "extensions": [ + "vscjava.vscode-java-pack", + "Oracle.oracle-java" ] } } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fc70432 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM openjdk:17-jdk-slim AS base +WORKDIR /app +RUN apt-get update && apt install -y \ + maven \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +#---Development +FROM base AS development +# Switching user so it doesn't use root +# If root adds files, it takes ownership, making them unremovable on the host except by using sudo +# node tranfers ownership to the host user +ENV UID=1000 +ENV GID=1000 +ENV USER=dev +RUN groupadd -g ${GID} ${USER} \ + && useradd -m -u ${UID} -g ${USER} ${USER} +USER ${USER} +EXPOSE 8080 +CMD ["tail","-f","/dev/null"] + +#---Building + + + +#---Production \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index 80df2a1..47ec3a2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -55,6 +55,17 @@ spring-boot-starter-test test + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + org.projectlombok + lombok + 1.18.34 + provided + diff --git a/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java b/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java new file mode 100644 index 0000000..a426b14 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.Config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class AppConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java new file mode 100644 index 0000000..6ab69c3 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java @@ -0,0 +1,37 @@ +package com.ninjaws.quiz.Controllers; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ninjaws.quiz.Models.Session; +import com.ninjaws.quiz.Services.QuizService; + +@RestController +@RequestMapping("/quiz") +public class QuizController { + + @Autowired + private QuizService quizService; + + @GetMapping("/questions") + public String startSession() { + // String sessionId = generateSessionId(); + String sessionId = quizService.createSession(); + return sessionId; + } + + @GetMapping("/questions/{sessionId}") + public Optional checkStatus(@PathVariable String sessionId) { + return quizService.checkSession(sessionId); + } + + // @PostMapping("/checkanswers") + // public Result checkAnswers(@RequestBody String answers) { + // return new Result(); + // } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..07d7a06 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java @@ -0,0 +1,20 @@ +package com.ninjaws.quiz.Exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +/** + * Determines behaviour when certain types of Exceptions are encountered + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(e.getMessage()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java b/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java new file mode 100644 index 0000000..0d1da5c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java @@ -0,0 +1,21 @@ +package com.ninjaws.quiz.Models; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + + +/** + * The response sent by the external API + */ +@Getter +@Setter +public class ApiResponse { + @JsonProperty("response_code") + private int responseCode; + @JsonProperty("results") + private List results; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Question.java b/backend/src/main/java/com/ninjaws/quiz/Models/Question.java new file mode 100644 index 0000000..4efb9a9 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/Question.java @@ -0,0 +1,59 @@ +package com.ninjaws.quiz.Models; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; + +/** + * Holds the question, answers, and correct answer + * TODO: Should not send correct answer to the user + */ +// @Setter() +@Getter() +public class Question { + private String type; + private String difficulty; + private String category; + private String question; + + // @JsonIgnore + private String correctAnswer; + + // @JsonIgnore + private List incorrectAnswers; + + @JsonProperty("answers") + public List getAnswers() { + List possibleAnswers = new ArrayList<>(incorrectAnswers); + possibleAnswers.add(correctAnswer); + Collections.shuffle(possibleAnswers); + return possibleAnswers; + } + + @JsonProperty("correct_answer") + public void setCorrectAnswer(String correctAnswer) { + this.correctAnswer = correctAnswer; + } + + @JsonProperty("incorrect_answers") + public void setIncorrectAnswer(List incorrectAnswers) { + this.incorrectAnswers = incorrectAnswers; + } + + @JsonProperty("correct_answer") + @JsonIgnore + public String getCorrectAnswer() { + return correctAnswer; + } + + @JsonProperty("incorrect_answers") + @JsonIgnore + public List getIncorrectAnswers() { + return incorrectAnswers; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Request.java b/backend/src/main/java/com/ninjaws/quiz/Models/Request.java new file mode 100644 index 0000000..15205ef --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/Request.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.Models; + +import lombok.Getter; + +@Getter +public class Request { + private final String sessionId; + private final String requestUrl; + + public Request(String sessionId, String requestUrl) { + this.sessionId = sessionId; + this.requestUrl = requestUrl; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Session.java b/backend/src/main/java/com/ninjaws/quiz/Models/Session.java new file mode 100644 index 0000000..f0f8ab5 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/Session.java @@ -0,0 +1,18 @@ +package com.ninjaws.quiz.Models; + +import java.util.List; +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Session { + private final String id; + private List questions; + + public Session() { + this.id = UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java b/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java index 722a567..809b4e1 100644 --- a/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java +++ b/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java @@ -2,8 +2,10 @@ package com.ninjaws.quiz; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class QuizApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java new file mode 100644 index 0000000..189f4b2 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java @@ -0,0 +1,23 @@ +package com.ninjaws.quiz.Services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class ApiService { + + private final RestTemplate restTemplate; + + @Autowired + public ApiService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public String requestQuestions(String url) { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String jsonData = response.getBody(); + return jsonData; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java new file mode 100644 index 0000000..53924fc --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java @@ -0,0 +1,126 @@ +package com.ninjaws.quiz.Services; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.Models.ApiResponse; +import com.ninjaws.quiz.Models.Question; +import com.ninjaws.quiz.Models.Request; +import com.ninjaws.quiz.Models.Session; + + +@Service +public class QuizService { + + @Autowired + private ApiService apiService; + @Autowired + private ObjectMapper objectMapper; + + /** + * Stores all active sessions + * Gets added to when a user requests data + * Gets removed from when a user requests their score (by sending their answers) + * TODO: Replace with something that automatically removes items after a certain amount of time (Caffein?) + */ + private final Map sessions = new ConcurrentHashMap<>(); + + /** + * Stores all user requests for data + * Executes them in order, one every 5 seconds (the limit of the external API) + * Removes them from the Queue when the request was succesful + */ + private final BlockingQueue requests = new LinkedBlockingQueue<>(); + + /** + * Actions: + * - Creates a session with sessionId + * - Adds session to the cache + * - Creates request and adds it to the queue + * @return The sessionId + */ + public String createSession() { + Session session = new Session(); + sessions.put(session.getId(), session); // TODO: Either remove id from session, or maybe Caffein does allow them together + try { + requests.put(new Request(session.getId(), "https://opentdb.com/api.php?amount=10")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return session.getId(); + } + + /** + * @param sessionId + * Actions: + * - Looks up the session in the cache + * - Checks if the data from the request is stored + * @return The data if it's available, otherwise a statuscode (?) + */ + public Optional checkSession(String sessionId) throws IllegalArgumentException { + Session session = sessions.get(sessionId); + + if(session == null) { + throw new IllegalArgumentException("Invalid session ID: " + sessionId); + } + + if (session.getQuestions() == null) { + return Optional.empty(); + } + System.out.print(session.getQuestions().get(0).getIncorrectAnswers().get(0)); + return Optional.of(session); + } + + /** + * Every 5 seconds: + * - Take the top request from the queue (removes it) + * - Perform the request + * - Wait for the data + * - Find the matching session from the cache + * - Add the data to the session in the cache + */ + @Scheduled(fixedDelay = 5000) + protected void processQueue() { + Request request = requests.poll(); + if (request != null) { + String json = apiService.requestQuestions(request.getRequestUrl()); + try { + List questions = extractQuestions(json); + Session session = sessions.get(request.getSessionId()); + session.setQuestions(questions); + sessions.put(request.getSessionId(), session); + } catch (IOException e) { + //TODO: Log this + } + } + } + + private List extractQuestions(String json) throws IOException { + return objectMapper.readValue(json, ApiResponse.class).getResults(); + } + + /** + * Receives: sessionId and answers + * Actions: + * - retrieves session from cache + * - Deletes session from cache + * - Matches answers answers to correctAnswers + * - Calculates score + * Returns: calculated score + * */ + public void calculateScore() { + + } + +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b3b3a2f..8d23e20 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=quiz +server.port=9090 \ No newline at end of file diff --git a/backend/src/main/resources/static/index.html b/backend/src/main/resources/static/index.html new file mode 100644 index 0000000..4b255a9 --- /dev/null +++ b/backend/src/main/resources/static/index.html @@ -0,0 +1 @@ +

Quiz backend

\ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5765b4f..9e0016c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,5 +4,7 @@ services: context: ./frontend dockerfile: Dockerfile target: production + image: quiz-frontend-prod + container_name: quiz-frontend-prod-cont ports: - "4200:80" \ No newline at end of file From 2505fce920e05cfea9f8dafd98f4e65b04a15b2a Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Wed, 30 Oct 2024 21:47:45 +0100 Subject: [PATCH 03/12] [Added] Tests and some more functionality to the Services --- Reflection.md | 14 +++ backend/.devcontainer/devcontainer.json | 5 +- backend/pom.xml | 18 ++- .../quiz/Controllers/QuizController.java | 37 ------ .../java/com/ninjaws/quiz/Models/Request.java | 14 --- .../com/ninjaws/quiz/Services/ApiService.java | 23 ---- .../quiz/{Config => config}/AppConfig.java | 2 +- .../quiz/controllers/QuizController.java | 44 +++++++ .../GlobalExceptionHandler.java | 2 +- .../java/com/ninjaws/quiz/models/Answers.java | 17 +++ .../quiz/{Models => models}/ApiResponse.java | 2 +- .../quiz/{Models => models}/Question.java | 16 +-- .../com/ninjaws/quiz/models/QuizSettings.java | 86 +++++++++++++ .../java/com/ninjaws/quiz/models/Request.java | 14 +++ .../quiz/{Models => models}/Session.java | 2 +- .../com/ninjaws/quiz/services/ApiService.java | 74 ++++++++++++ .../{Services => services}/QuizService.java | 71 ++++++++--- .../ninjaws/quiz/QuizApplicationTests.java | 7 +- .../quiz/scala/QuestionStatusSimulation.scala | 21 ++++ .../ninjaws/quiz/services/ApiServiceTest.java | 114 ++++++++++++++++++ .../quiz/services/QuizServiceTest.java | 73 +++++++++++ 21 files changed, 535 insertions(+), 121 deletions(-) create mode 100644 Reflection.md delete mode 100644 backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java delete mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Request.java delete mode 100644 backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java rename backend/src/main/java/com/ninjaws/quiz/{Config => config}/AppConfig.java (90%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java rename backend/src/main/java/com/ninjaws/quiz/{Exception => exception}/GlobalExceptionHandler.java (94%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/models/Answers.java rename backend/src/main/java/com/ninjaws/quiz/{Models => models}/ApiResponse.java (91%) rename backend/src/main/java/com/ninjaws/quiz/{Models => models}/Question.java (83%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/models/Request.java rename backend/src/main/java/com/ninjaws/quiz/{Models => models}/Session.java (89%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/ApiService.java rename backend/src/main/java/com/ninjaws/quiz/{Services => services}/QuizService.java (61%) create mode 100644 backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/ApiServiceTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java diff --git a/Reflection.md b/Reflection.md new file mode 100644 index 0000000..21a6bd3 --- /dev/null +++ b/Reflection.md @@ -0,0 +1,14 @@ +# Issues + +## Issues during testing +### Scheduler +I don't know if it was the scheduler specifically, but whenever I try to test it, both the cache and the queue seem to become empty after the first function is completed. +I don't know how to solve this yet + + +## Caching solution +### Lack of data +Not being able to gather blocks of 50 is a huge issue. The idea of reusing blocks becomes less viable if I can just get a big chunk every time for others to use +I can't force a big chunk, because it might just return a status 1, and then the user gets nothing. + +Perhaps I can separate the questions myself and make a collection, that i can build the requests from. However, for every quest tracking if someone has already seen is a huge pain. diff --git a/backend/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json index aead60f..22891ae 100644 --- a/backend/.devcontainer/devcontainer.json +++ b/backend/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "context": "..", "dockerfile": "../Dockerfile", "args": { - "WORKDIR": "/app", + "WORKDIR": "/app" // "UID": "${localEnv:USER_UID}", // "GID": "${localEnv:USER_GID}" }, @@ -24,7 +24,8 @@ "settings": {}, "extensions": [ "vscjava.vscode-java-pack", - "Oracle.oracle-java" + "Oracle.oracle-java", + "scala-lang.scala" ] } } diff --git a/backend/pom.xml b/backend/pom.xml index 47ec3a2..ac725c2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -28,12 +28,14 @@ 17 + 1.18.34 + 3.1.8 - + org.springframework.boot spring-boot-starter-web @@ -45,11 +47,11 @@ runtime true - + org.springframework.boot spring-boot-starter-test @@ -58,14 +60,18 @@ com.github.ben-manes.caffeine caffeine - 3.1.8 + ${caffeine.version} org.projectlombok lombok - 1.18.34 + ${lombok.version} provided + + org.springframework.boot + spring-boot-starter-validation + diff --git a/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java deleted file mode 100644 index 6ab69c3..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.ninjaws.quiz.Controllers; - -import java.util.Optional; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.ninjaws.quiz.Models.Session; -import com.ninjaws.quiz.Services.QuizService; - -@RestController -@RequestMapping("/quiz") -public class QuizController { - - @Autowired - private QuizService quizService; - - @GetMapping("/questions") - public String startSession() { - // String sessionId = generateSessionId(); - String sessionId = quizService.createSession(); - return sessionId; - } - - @GetMapping("/questions/{sessionId}") - public Optional checkStatus(@PathVariable String sessionId) { - return quizService.checkSession(sessionId); - } - - // @PostMapping("/checkanswers") - // public Result checkAnswers(@RequestBody String answers) { - // return new Result(); - // } -} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Request.java b/backend/src/main/java/com/ninjaws/quiz/Models/Request.java deleted file mode 100644 index 15205ef..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/Models/Request.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ninjaws.quiz.Models; - -import lombok.Getter; - -@Getter -public class Request { - private final String sessionId; - private final String requestUrl; - - public Request(String sessionId, String requestUrl) { - this.sessionId = sessionId; - this.requestUrl = requestUrl; - } -} diff --git a/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java deleted file mode 100644 index 189f4b2..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ninjaws.quiz.Services; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -@Service -public class ApiService { - - private final RestTemplate restTemplate; - - @Autowired - public ApiService(RestTemplate restTemplate) { - this.restTemplate = restTemplate; - } - - public String requestQuestions(String url) { - ResponseEntity response = restTemplate.getForEntity(url, String.class); - String jsonData = response.getBody(); - return jsonData; - } -} diff --git a/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/AppConfig.java similarity index 90% rename from backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java rename to backend/src/main/java/com/ninjaws/quiz/config/AppConfig.java index a426b14..eef023d 100644 --- a/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java +++ b/backend/src/main/java/com/ninjaws/quiz/config/AppConfig.java @@ -1,4 +1,4 @@ -package com.ninjaws.quiz.Config; +package com.ninjaws.quiz.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java new file mode 100644 index 0000000..d1cbe81 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -0,0 +1,44 @@ +package com.ninjaws.quiz.controllers; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ninjaws.quiz.models.Answers; +import com.ninjaws.quiz.models.Session; +import com.ninjaws.quiz.services.QuizService; + +@RestController +@RequestMapping("/quiz") +public class QuizController { + + @Autowired + private QuizService quizService; + + @GetMapping("/questions") + public String startSession( + @RequestParam(required = true) String amount, + @RequestParam(required = false) Integer category, + @RequestParam(required = false) String difficulty, + @RequestParam(required = false) String type + ) { + return quizService.createSession(quizService.jsonToQuizSettings("todo")); + } + + @GetMapping("/status/{sessionId}") + public Optional checkStatus(@PathVariable String sessionId) { + return quizService.checkSession(sessionId); + } + + @PostMapping("/checkanswers") + public String checkAnswers(@RequestBody Answers answers) { + return quizService.getScore(answers); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ninjaws/quiz/exception/GlobalExceptionHandler.java similarity index 94% rename from backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java rename to backend/src/main/java/com/ninjaws/quiz/exception/GlobalExceptionHandler.java index 07d7a06..c684ebc 100644 --- a/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ninjaws/quiz/exception/GlobalExceptionHandler.java @@ -1,4 +1,4 @@ -package com.ninjaws.quiz.Exception; +package com.ninjaws.quiz.exception; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Answers.java b/backend/src/main/java/com/ninjaws/quiz/models/Answers.java new file mode 100644 index 0000000..c87ee61 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/Answers.java @@ -0,0 +1,17 @@ +package com.ninjaws.quiz.models; + +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Answers { + private final String sessionId; + private List answers; + + public Answers(String sessionId) { + this.sessionId = sessionId; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java b/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java similarity index 91% rename from backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java rename to backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java index 0d1da5c..776d0e0 100644 --- a/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java @@ -1,4 +1,4 @@ -package com.ninjaws.quiz.Models; +package com.ninjaws.quiz.models; import java.util.List; diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Question.java b/backend/src/main/java/com/ninjaws/quiz/models/Question.java similarity index 83% rename from backend/src/main/java/com/ninjaws/quiz/Models/Question.java rename to backend/src/main/java/com/ninjaws/quiz/models/Question.java index 4efb9a9..36ee786 100644 --- a/backend/src/main/java/com/ninjaws/quiz/Models/Question.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/Question.java @@ -1,4 +1,4 @@ -package com.ninjaws.quiz.Models; +package com.ninjaws.quiz.models; import java.util.ArrayList; import java.util.Collections; @@ -9,22 +9,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; -/** - * Holds the question, answers, and correct answer - * TODO: Should not send correct answer to the user - */ -// @Setter() -@Getter() +@Getter public class Question { private String type; private String difficulty; private String category; private String question; + - // @JsonIgnore - private String correctAnswer; - - // @JsonIgnore + //* Hidden from the user */ + private String correctAnswer; private List incorrectAnswers; @JsonProperty("answers") diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java new file mode 100644 index 0000000..5bca555 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -0,0 +1,86 @@ +package com.ninjaws.quiz.models; + +import com.ninjaws.quiz.models.QuizSettings.Difficulty; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuizSettings { + + public enum Type { + ANY, BOOLEAN, MULTIPLE; + + public String getApiValue() { + return this.name().toLowerCase(); + } + } + + public enum Difficulty { + ANY, EASY, MEDIUM, HARD; + + public String getApiValue() { + return this.name().toLowerCase(); + } + } + + public enum Category { + ANY, GEN_KNOWLEDGE, ENT_BOOKS, ENT_FILM, ENT_MUSIC, ENT_MUSICAL_THEATRE, ENT_TELEVISION, + ENT_VIDEOGAMES, ENT_BOARDGAMES, SCIENCE_NATURE, SCIENCE_COMPUTERS, SCIENCE_MATH, MYTHOLOGY, SPORTS, GEOGRAPHY, HISTORY, POLITICS, ART, CELEBRITIES, + ANIMALS, VEHICLES, ENT_COMICS, SCIENCE_GADGETS, ENT_ANIME_MANGA, ENT_CARTOON_ANIMATIONS; + + /** + * For some reason the external API starts with a value of 9 for the General Knowledge category, so we have to adjust + * ANY also gets adjusted, but it doesn't matter since that one doesn't get used as parameter anyway + * @return The corrected value + */ + public int getApiValue() { + return 8 + this.ordinal(); + } + } + + @Min(1) + @Max(50) + private int amount = 10; + + private Type type = Type.ANY; + + private Difficulty difficulty = Difficulty.ANY; + + private Category category = Category.ANY; + + + /** + * Ensures the value is between 1 and 50 + * @Valid doesn't seem to be enforcing it, so this ensures the value is legal + */ + public void setAmount(@Valid int amount) { + this.amount = Math.max(1, Math.min(amount, 50)); + } + + /** + * Converts the class to a format that can be provided as arguments for the api request + * @return The resulting request arguments as String + */ + public String toApiQuery() { + StringBuilder query = new StringBuilder("?amount=" + amount); + + if (category != null && category != Category.ANY) { + query.append("&category=").append(category.getApiValue()); + } + + if (difficulty != null && difficulty != Difficulty.ANY) { + query.append("&difficulty=").append(difficulty.getApiValue()); + } + + if (type != null && type != Type.ANY) { + query.append("&type=").append(type.getApiValue()); + } + + return query.toString(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Request.java b/backend/src/main/java/com/ninjaws/quiz/models/Request.java new file mode 100644 index 0000000..d04c3bf --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/Request.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.models; + +import lombok.Getter; + +@Getter +public class Request { + private final String sessionId; + private final QuizSettings quizSettings; + + public Request(String sessionId, QuizSettings quizSettings) { + this.sessionId = sessionId; + this.quizSettings = quizSettings; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Session.java b/backend/src/main/java/com/ninjaws/quiz/models/Session.java similarity index 89% rename from backend/src/main/java/com/ninjaws/quiz/Models/Session.java rename to backend/src/main/java/com/ninjaws/quiz/models/Session.java index f0f8ab5..540d756 100644 --- a/backend/src/main/java/com/ninjaws/quiz/Models/Session.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/Session.java @@ -1,4 +1,4 @@ -package com.ninjaws.quiz.Models; +package com.ninjaws.quiz.models; import java.util.List; import java.util.UUID; diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java new file mode 100644 index 0000000..4201d7a --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -0,0 +1,74 @@ +package com.ninjaws.quiz.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.ninjaws.quiz.models.QuizSettings; + +import jakarta.validation.Valid; + +@Service +public class ApiService { + + // @Autowired + // private ObjectMapper objectMapper; + private final RestTemplate restTemplate; + + private final String apiUrl = "https://opentdb.com/api.php"; + + // private String sessionToken = ""; + + @Autowired + public ApiService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + // try { + // sessionToken = requestSessionToken(); + // } catch (Exception e) { + // // Failed to get a session token, setting default to none + // sessionToken = ""; + + // } + } + + private String buildRequestString(QuizSettings quizSettings) { + return apiUrl + quizSettings.toApiQuery(); + } + + // private int getResponseCode(String json) throws Exception { + // JsonNode jsonNode = objectMapper.readTree(json); + // return jsonNode.get("response_code").asInt(); + // } + + // public String requestSessionToken() throws Exception { + // String sessionTokenRequestUrl = "https://opentdb.com/api_token.php?command=request"; + // ResponseEntity response = restTemplate.getForEntity(sessionTokenRequestUrl, String.class); + // JsonNode jsonNode = objectMapper.readTree(response.getBody()); + // if (jsonNode.get("response_code").asInt() == 0) { + // sessionToken = jsonNode.get("token").asText(); + // }else { + // // Failed to get a session token, setting default to none + // sessionToken = ""; + // } + // return sessionToken; + // } + + public String requestQuestions(@Valid QuizSettings quizSettings) { + String url = buildRequestString(quizSettings); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String jsonData = response.getBody(); + + // try { + // int responseCode = getResponseCode(jsonData); + // /** When the code has expired or you've ran out of questions, update the token and try again */ + // if(responseCode == 3 || responseCode == 4) { + // requestSessionToken(); + // requestQuestions(quizSettings); + // } + // } catch (Exception e) { + // // Failed to get a responseCode, meaning the request failed completely (they always send a response_code) + // } + return jsonData; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java similarity index 61% rename from backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java rename to backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index 53924fc..8a30239 100644 --- a/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -1,22 +1,25 @@ -package com.ninjaws.quiz.Services; +package com.ninjaws.quiz.services; import java.io.IOException; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.Models.ApiResponse; -import com.ninjaws.quiz.Models.Question; -import com.ninjaws.quiz.Models.Request; -import com.ninjaws.quiz.Models.Session; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.ninjaws.quiz.models.Answers; +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.Question; +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; +import com.ninjaws.quiz.models.Session; @Service @@ -31,9 +34,9 @@ public class QuizService { * Stores all active sessions * Gets added to when a user requests data * Gets removed from when a user requests their score (by sending their answers) - * TODO: Replace with something that automatically removes items after a certain amount of time (Caffein?) */ - private final Map sessions = new ConcurrentHashMap<>(); + // private final Map sessions = new ConcurrentHashMap<>(); + private final Cache sessions; /** * Stores all user requests for data @@ -42,18 +45,34 @@ public class QuizService { */ private final BlockingQueue requests = new LinkedBlockingQueue<>(); + + public QuizService() { + sessions = (Caffeine.newBuilder() + .expireAfterWrite(1,TimeUnit.HOURS) + .build()); + } + + public QuizService(Cache cache) { + sessions = cache; + } + + public QuizSettings jsonToQuizSettings(String json){ + QuizSettings quizSettings = new QuizSettings(); + return quizSettings; + } + /** * Actions: * - Creates a session with sessionId * - Adds session to the cache - * - Creates request and adds it to the queue + * - Creates a request and adds it to the queue + * @param quizSettings The values set by the user, determining the kind of quiz they want * @return The sessionId */ - public String createSession() { + public String createSession(QuizSettings quizSettings) { Session session = new Session(); - sessions.put(session.getId(), session); // TODO: Either remove id from session, or maybe Caffein does allow them together try { - requests.put(new Request(session.getId(), "https://opentdb.com/api.php?amount=10")); + requests.put(new Request(session.getId(), quizSettings)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } @@ -62,14 +81,14 @@ public class QuizService { } /** - * @param sessionId * Actions: * - Looks up the session in the cache * - Checks if the data from the request is stored - * @return The data if it's available, otherwise a statuscode (?) + * @param sessionId + * @return The data if it's available, Optional.empty() if not, and an Exception if the session is invalid */ public Optional checkSession(String sessionId) throws IllegalArgumentException { - Session session = sessions.get(sessionId); + Session session = sessions.getIfPresent(sessionId); if(session == null) { throw new IllegalArgumentException("Invalid session ID: " + sessionId); @@ -92,12 +111,15 @@ public class QuizService { */ @Scheduled(fixedDelay = 5000) protected void processQueue() { + System.out.print("Schedule!"); Request request = requests.poll(); if (request != null) { - String json = apiService.requestQuestions(request.getRequestUrl()); + System.out.print("----------------An item!"); + String json = apiService.requestQuestions(request.getQuizSettings()); + System.out.print(json); try { List questions = extractQuestions(json); - Session session = sessions.get(request.getSessionId()); + Session session = sessions.getIfPresent(request.getSessionId()); session.setQuestions(questions); sessions.put(request.getSessionId(), session); } catch (IOException e) { @@ -112,6 +134,7 @@ public class QuizService { /** * Receives: sessionId and answers + * * Actions: * - retrieves session from cache * - Deletes session from cache @@ -119,8 +142,16 @@ public class QuizService { * - Calculates score * Returns: calculated score * */ - public void calculateScore() { - + public String getScore(Answers answers) { + Session session = sessions.getIfPresent(answers.getSessionId()); + sessions.invalidate(session.getId()); + return calculateScore(session, answers); + } + + private String calculateScore(Session session, Answers answers) { + System.out.print(session); + System.out.print(answers); + return "You did great!"; } } diff --git a/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java b/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java index 5958ed5..bac272a 100644 --- a/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java +++ b/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java @@ -1,13 +1,12 @@ package com.ninjaws.quiz; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class QuizApplicationTests { - @Test - void contextLoads() { - } + // @Test + // void contextLoads() { + // } } diff --git a/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala b/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala new file mode 100644 index 0000000..9f75a56 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala @@ -0,0 +1,21 @@ +package com.ninjaws.quiz.scala; +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ +import scala.concurrent.duration._ + +class QuestionStatusSimulation extends Simulation { + val httpProtocol = http.baseUrl("http://localhost:9090").acceptHeader("application/json") + + val requestQuestions = exec(http("Request Questions").get("/questions/bla").check(status.is(200)).check(jsonPath("$.id").saveAs("sessionId"))) + + val checkStatus = exec(http("Check Status").get("/status/${sessionId}").check(status.is(200),jsonPath("$.status").notNull)) + + val scn = scenario("Check Questions Status Completion").exec(requestQuestions).pause(1).asLongAsDuring(session => session("completed").asOption[Boolean].getOrElse(false) == false, 30.seconds) { + exec(checkStatus).pause(1).exec{ session => val status = session("status").asOption[String] + session.set("completed", status.isDefined && status.get != "null") + } + } + setUp( + scn.inject(atOnceUsers(10)) + ).protocols(httpProtocol); +} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/ApiServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/ApiServiceTest.java new file mode 100644 index 0000000..56d112b --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/ApiServiceTest.java @@ -0,0 +1,114 @@ +package com.ninjaws.quiz.services; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.models.QuizSettings; + +@SpringBootTest +public class ApiServiceTest { + + @Autowired + private ApiService apiService; + + @Autowired + private ObjectMapper objectMapper; + + /** + * The external API only allows one request every 5 seconds + */ + @AfterEach + public void cooldown() throws InterruptedException { + Thread.sleep(5000); + } + + @Test + public void testRequestQuestions() throws Exception { + QuizSettings settings = new QuizSettings(); + String jsonResponse = apiService.requestQuestions(settings); + JsonNode jsonNode = objectMapper.readTree(jsonResponse); + + // Check if the response code is as expected + assertThat(jsonNode.has("response_code")).isTrue(); + assertThat(jsonNode.get("response_code").asInt()).isEqualTo(0); + + // Check the results array + assertThat(jsonNode.has("results")).isTrue(); + assertThat(jsonNode.get("results").isArray()).isTrue(); + assertThat(jsonNode.get("results").size()).isEqualTo(10); + + // Verify the first result has the expected fields + JsonNode firstResult = jsonNode.get("results").get(0); + assertThat(firstResult.has("type")).isTrue(); + assertThat(firstResult.has("difficulty")).isTrue(); + assertThat(firstResult.has("category")).isTrue(); + assertThat(firstResult.has("question")).isTrue(); + assertThat(firstResult.has("correct_answer")).isTrue(); + assertThat(firstResult.has("incorrect_answers")).isTrue(); + assertThat(firstResult.get("incorrect_answers").size()).isGreaterThan(0); + } + + @Test + public void testAllParams() throws Exception { + int amount = 10; + + QuizSettings settings = new QuizSettings(); + settings.setAmount(amount); + settings.setCategory(QuizSettings.Category.HISTORY); + settings.setType(QuizSettings.Type.MULTIPLE); + settings.setDifficulty(QuizSettings.Difficulty.MEDIUM); + + String jsonResponse = apiService.requestQuestions(settings); + JsonNode jsonNode = objectMapper.readTree(jsonResponse); + + // Check if the response code is as expected + assertThat(jsonNode.has("response_code")).isTrue(); + assertThat(jsonNode.get("response_code").asInt()).isEqualTo(0); + + // Check the results array + assertThat(jsonNode.has("results")).isTrue(); + assertThat(jsonNode.get("results").isArray()).isTrue(); + assertThat(jsonNode.get("results").size()).isEqualTo(amount); + + // Verify the first result has the expected fields + JsonNode firstResult = jsonNode.get("results").get(0); + String retrievedType = firstResult.get("type").toString(); + String retrievedDifficulty = firstResult.get("difficulty").toString(); + String retrievedCategory = firstResult.get("category").toString(); + assertThat(retrievedType.equals(settings.getType().getApiValue())); + assertThat(retrievedDifficulty.equals(settings.getDifficulty().getApiValue())); + assertThat(retrievedCategory.equals("Animals")); + assertThat(firstResult.get("incorrect_answers").size()).isEqualTo(3); + } + + /** + * If the API doesn't have the requested amount of questions, it will return an empty list and respond with 1 + */ + @Test + public void testCombinationThatResultsInZeroQuestions() throws Exception { + int amount = 30; + + QuizSettings settings = new QuizSettings(); + settings.setAmount(amount); + settings.setCategory(QuizSettings.Category.ANIMALS); + settings.setType(QuizSettings.Type.MULTIPLE); + settings.setDifficulty(QuizSettings.Difficulty.MEDIUM); + + String jsonResponse = apiService.requestQuestions(settings); + JsonNode jsonNode = objectMapper.readTree(jsonResponse); + + // Check if the response code is as expected + assertThat(jsonNode.has("response_code")).isTrue(); + assertThat(jsonNode.get("response_code").asInt()).isEqualTo(1); + + // Check the results array + assertThat(jsonNode.has("results")).isTrue(); + assertThat(jsonNode.get("results").isArray()).isTrue(); + assertThat(jsonNode.get("results").size()).isEqualTo(0); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java new file mode 100644 index 0000000..bef75fc --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java @@ -0,0 +1,73 @@ +package com.ninjaws.quiz.services; + +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +// @EnableScheduling +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class QuizServiceTest { + + // @InjectMocks + // private QuizService quizService; + + // @Mock + // private ApiService apiService; + + // @BeforeAll + // public void setUp() { + // MockitoAnnotations.openMocks(this); + // // Cache cache = Caffeine.newBuilder() + // // .expireAfterWrite(1, TimeUnit.HOURS) + // // .build(); // Create a mock cache or a real cache for tests + // // quizService = new QuizService(cache); + // } + + // @Test + // public void testCreateSession() { + // QuizSettings settings = new QuizSettings(); + // String response = quizService.createSession(settings); + // assertTrue(response instanceof String); + // } + + // /** + // * TODO: Fix the test + // * Seems to clear the cache and queue when the createSession is finished + // */ + // @Test + // public void testCheckSession() throws InterruptedException { + + // QuizSettings settings = new QuizSettings(); + // when(apiService.requestQuestions(settings)).thenReturn("{\"response_code\":0,\"results\":[{\"type\":\"multiple\",\"difficulty\":\"hard\",\"category\":\"Entertainment: Video Games\",\"question\":\"The map featured in Arma 3 named "Altis" is based off of what Greek island?\",\"correct_answer\":\"Lemnos\",\"incorrect_answers\":[\"Ithaca\",\"Naxos\",\"Anafi\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Entertainment: Music\",\"question\":\"What is the last song on the first Panic! At the Disco album?\",\"correct_answer\":\"Build God, Then We'll Talk\",\"incorrect_answers\":[\"Nails for Breakfast, Tacks for Snacks\",\"I Write Sins Not Tragedies\",\"Lying Is The Most Fun A Girl Can Have Without Taking Her Clothes Off\"]},{\"type\":\"multiple\",\"difficulty\":\"easy\",\"category\":\"Entertainment: Video Games\",\"question\":\"Which of these is NOT a playable character in the 2016 video game Overwatch?\",\"correct_answer\":\"Invoker\",\"incorrect_answers\":[\"Mercy\",\"Winston\",\"Zenyatta\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Science: Computers\",\"question\":\"What does AD stand for in relation to Windows Operating Systems? \",\"correct_answer\":\"Active Directory\",\"incorrect_answers\":[\"Alternative Drive\",\"Automated Database\",\"Active Department\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Entertainment: Japanese Anime & Manga\",\"question\":\"In "Highschool DxD", Koneko Toujou is from what race?\",\"correct_answer\":\"Nekomata\",\"incorrect_answers\":[\"Kitsune\",\"Human\",\"Kappa\"]}]}"); + // String sessionId = quizService.createSession(settings); + + + // int interval = 1000; + // int maxTime = 5000; + // long startTime = System.currentTimeMillis(); + // Session session = null; + + // while((System.currentTimeMillis() - maxTime) < startTime) { + // Optional retrieved = quizService.checkSession(sessionId); + // System.out.println(retrieved); + // if (retrieved.isPresent()) { + // System.out.print("Present!"); + // session = retrieved.get(); + // break; + // } else { + // try { + // Thread.sleep(interval); + // } catch (InterruptedException e) { + // Thread.currentThread().interrupt(); // Restore interrupted status + // fail("Thread was interrupted during sleep"); + // } + // } + // } + + // assertNotEquals(session, null); + // } + + // public void testCalculateScore(){ + + // } +} From 3188f6ce209e09ae789ddf51966cba93fb5132b4 Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Thu, 31 Oct 2024 23:05:22 +0100 Subject: [PATCH 04/12] [Added] Most of the frontend, split up backend services --- Reflection.md | 16 + .../com/ninjaws/quiz/config/WebConfig.java | 20 + .../quiz/controllers/QuizController.java | 20 +- .../java/com/ninjaws/quiz/models/Answers.java | 4 +- .../com/ninjaws/quiz/models/QuizSettings.java | 16 + .../java/com/ninjaws/quiz/models/Session.java | 1 + .../com/ninjaws/quiz/services/ApiService.java | 40 +- .../ninjaws/quiz/services/CacheService.java | 38 + .../ninjaws/quiz/services/CleanerService.java | 16 + .../ninjaws/quiz/services/QueueService.java | 27 + .../ninjaws/quiz/services/QuizService.java | 114 +-- .../quiz/services/SchedulerService.java | 59 ++ .../ninjaws/quiz/services/StorageService.java | 5 + ...viceTest.java => backupApiServiceTest.txt} | 0 ...iceTest.java => backupQuizServiceTest.txt} | 3 +- frontend/.devcontainer/devcontainer.json | 5 +- frontend/.devcontainer/oldDockerfile.txt | 21 - frontend/angular.json | 3 + frontend/package-lock.json | 736 ++++++++++++------ frontend/package.json | 3 + frontend/src/app/Quiz/models/answers.ts | 4 + frontend/src/app/Quiz/models/question.ts | 11 + frontend/src/app/Quiz/models/quiz-params.ts | 6 + frontend/src/app/Quiz/models/score.ts | 4 + frontend/src/app/Quiz/quiz-routing.module.ts | 18 + frontend/src/app/Quiz/quiz.module.ts | 15 + .../src/app/Quiz/quiz/quiz.component.html | 19 + .../src/app/Quiz/quiz/quiz.component.scss | 3 + frontend/src/app/Quiz/quiz/quiz.component.ts | 61 ++ .../src/app/Quiz/score/score.component.html | 11 + .../src/app/Quiz/score/score.component.scss | 0 .../src/app/Quiz/score/score.component.ts | 29 + .../src/app/Quiz/services/quiz.service.ts | 131 ++++ .../src/app/Quiz/services/storage.service.ts | 17 + .../src/app/Quiz/setup/setup.component.html | 35 + .../src/app/Quiz/setup/setup.component.scss | 0 .../src/app/Quiz/setup/setup.component.ts | 60 ++ .../app/Spinner/services/spinner.service.ts | 25 + frontend/src/app/Spinner/spinner.module.ts | 11 + .../Spinner/spinner/spinner.component.html | 5 + .../Spinner/spinner/spinner.component.scss | 98 +++ .../app/Spinner/spinner/spinner.component.ts | 22 + frontend/src/app/app.component.html | 5 +- frontend/src/app/app.component.ts | 3 +- frontend/src/app/app.config.ts | 6 +- frontend/src/app/app.routes.ts | 6 +- frontend/src/styles.scss | 26 + frontend/tailwind.config.js | 11 + 48 files changed, 1391 insertions(+), 398 deletions(-) create mode 100644 backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/CacheService.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/QueueService.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/StorageService.java rename backend/src/test/java/com/ninjaws/quiz/services/{ApiServiceTest.java => backupApiServiceTest.txt} (100%) rename backend/src/test/java/com/ninjaws/quiz/services/{QuizServiceTest.java => backupQuizServiceTest.txt} (97%) delete mode 100644 frontend/.devcontainer/oldDockerfile.txt create mode 100644 frontend/src/app/Quiz/models/answers.ts create mode 100644 frontend/src/app/Quiz/models/question.ts create mode 100644 frontend/src/app/Quiz/models/quiz-params.ts create mode 100644 frontend/src/app/Quiz/models/score.ts create mode 100644 frontend/src/app/Quiz/quiz-routing.module.ts create mode 100644 frontend/src/app/Quiz/quiz.module.ts create mode 100644 frontend/src/app/Quiz/quiz/quiz.component.html create mode 100644 frontend/src/app/Quiz/quiz/quiz.component.scss create mode 100644 frontend/src/app/Quiz/quiz/quiz.component.ts create mode 100644 frontend/src/app/Quiz/score/score.component.html create mode 100644 frontend/src/app/Quiz/score/score.component.scss create mode 100644 frontend/src/app/Quiz/score/score.component.ts create mode 100644 frontend/src/app/Quiz/services/quiz.service.ts create mode 100644 frontend/src/app/Quiz/services/storage.service.ts create mode 100644 frontend/src/app/Quiz/setup/setup.component.html create mode 100644 frontend/src/app/Quiz/setup/setup.component.scss create mode 100644 frontend/src/app/Quiz/setup/setup.component.ts create mode 100644 frontend/src/app/Spinner/services/spinner.service.ts create mode 100644 frontend/src/app/Spinner/spinner.module.ts create mode 100644 frontend/src/app/Spinner/spinner/spinner.component.html create mode 100644 frontend/src/app/Spinner/spinner/spinner.component.scss create mode 100644 frontend/src/app/Spinner/spinner/spinner.component.ts create mode 100644 frontend/tailwind.config.js diff --git a/Reflection.md b/Reflection.md index 21a6bd3..88450d4 100644 --- a/Reflection.md +++ b/Reflection.md @@ -12,3 +12,19 @@ Not being able to gather blocks of 50 is a huge issue. The idea of reusing block I can't force a big chunk, because it might just return a status 1, and then the user gets nothing. Perhaps I can separate the questions myself and make a collection, that i can build the requests from. However, for every quest tracking if someone has already seen is a huge pain. + + +## Possible solution +Cache all data, 50 at a time +Store it in a database +When its done, let the db make blocks for quick cache access + +Then randomize when someone asks for an item from the cache +Or return a failure if there are not enough items (a la the api itself) + +Every night, create a new db in the background and start filling it (should take ~34 minutes for all 20k items the external API has) +When it's full, replace the old database with the new one, and delete the old one. +Then start replacing the cache items one at a time. + +Users shouldn't feel this at all, because the active sessions are stored in their own cache items + diff --git a/backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java new file mode 100644 index 0000000..691039f --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.ninjaws.quiz.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@EnableWebMvc +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") // Allow all endpoints + .allowedOrigins("http://localhost:4200") // Allow specific origin + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // Allow specific methods + .allowedHeaders("*") // Allow all headers + .allowCredentials(true); // Allow credentials + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index d1cbe81..758af5c 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.ninjaws.quiz.models.Answers; +import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Session; import com.ninjaws.quiz.services.QuizService; @@ -24,21 +25,24 @@ public class QuizController { @GetMapping("/questions") public String startSession( - @RequestParam(required = true) String amount, - @RequestParam(required = false) Integer category, - @RequestParam(required = false) String difficulty, - @RequestParam(required = false) String type + @RequestParam(required = true) Integer amount, + @RequestParam(required = false) Integer category, + @RequestParam(required = false) String difficulty, + @RequestParam(required = false) String type ) { - return quizService.createSession(quizService.jsonToQuizSettings("todo")); - } + QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type); + String jsonString = String.format("{\"sessionId\":\"%s\"}", quizService.createSession(quizSettings)); + return jsonString; + } @GetMapping("/status/{sessionId}") public Optional checkStatus(@PathVariable String sessionId) { return quizService.checkSession(sessionId); } - @PostMapping("/checkanswers") + @PostMapping("/score") public String checkAnswers(@RequestBody Answers answers) { - return quizService.getScore(answers); + String jsonString = String.format("{\"score\":\"%s\"}", quizService.getScore(answers)); + return jsonString; } } diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Answers.java b/backend/src/main/java/com/ninjaws/quiz/models/Answers.java index c87ee61..781cfbd 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/Answers.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/Answers.java @@ -8,9 +8,11 @@ import lombok.Setter; @Getter @Setter public class Answers { - private final String sessionId; + private String sessionId; private List answers; + public Answers() {} + public Answers(String sessionId) { this.sessionId = sessionId; } diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java index 5bca555..a0321e5 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -1,5 +1,6 @@ package com.ninjaws.quiz.models; +import com.ninjaws.quiz.models.QuizSettings.Category; import com.ninjaws.quiz.models.QuizSettings.Difficulty; import jakarta.validation.Valid; @@ -54,6 +55,21 @@ public class QuizSettings { private Category category = Category.ANY; + public QuizSettings(){} + + /** Inverse of toApiQuery */ + public QuizSettings(int amount, Integer category, String difficulty, String type) { + System.out.println(amount); + System.out.print(category); + System.out.print(difficulty); + System.out.print(type); + + this.amount = amount; + this.category = category == null ? Category.ANY : Category.values()[category]; + this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); + this.type = type == null ? Type.ANY : type.equals("boolean") ? Type.BOOLEAN : Type.MULTIPLE; + } + /** * Ensures the value is between 1 and 50 * @Valid doesn't seem to be enforcing it, so this ensures the value is legal diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Session.java b/backend/src/main/java/com/ninjaws/quiz/models/Session.java index 540d756..757467d 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/Session.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/Session.java @@ -10,6 +10,7 @@ import lombok.Setter; @Setter public class Session { private final String id; + private int statusCode = 0; private List questions; public Session() { diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index 4201d7a..aa096bb 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -12,48 +12,18 @@ import jakarta.validation.Valid; @Service public class ApiService { - // @Autowired - // private ObjectMapper objectMapper; - private final RestTemplate restTemplate; + @Autowired + private RestTemplate restTemplate; private final String apiUrl = "https://opentdb.com/api.php"; - // private String sessionToken = ""; - - @Autowired - public ApiService(RestTemplate restTemplate) { - this.restTemplate = restTemplate; - // try { - // sessionToken = requestSessionToken(); - // } catch (Exception e) { - // // Failed to get a session token, setting default to none - // sessionToken = ""; - - // } + public ApiService() { } private String buildRequestString(QuizSettings quizSettings) { return apiUrl + quizSettings.toApiQuery(); } - // private int getResponseCode(String json) throws Exception { - // JsonNode jsonNode = objectMapper.readTree(json); - // return jsonNode.get("response_code").asInt(); - // } - - // public String requestSessionToken() throws Exception { - // String sessionTokenRequestUrl = "https://opentdb.com/api_token.php?command=request"; - // ResponseEntity response = restTemplate.getForEntity(sessionTokenRequestUrl, String.class); - // JsonNode jsonNode = objectMapper.readTree(response.getBody()); - // if (jsonNode.get("response_code").asInt() == 0) { - // sessionToken = jsonNode.get("token").asText(); - // }else { - // // Failed to get a session token, setting default to none - // sessionToken = ""; - // } - // return sessionToken; - // } - public String requestQuestions(@Valid QuizSettings quizSettings) { String url = buildRequestString(quizSettings); ResponseEntity response = restTemplate.getForEntity(url, String.class); @@ -71,4 +41,8 @@ public class ApiService { // } return jsonData; } + + public void handleStatusCode(int statusCode) { + + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CacheService.java b/backend/src/main/java/com/ninjaws/quiz/services/CacheService.java new file mode 100644 index 0000000..fdb754b --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/CacheService.java @@ -0,0 +1,38 @@ +package com.ninjaws.quiz.services; + +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.ninjaws.quiz.models.Session; + +@Service +public class CacheService { + + /** + * Stores all active sessions + * Gets added to when a user requests data + * Gets removed from when a user requests their score (by sending their answers) + */ + private final Cache sessions; + + public CacheService() { + sessions = (Caffeine.newBuilder() + .expireAfterWrite(1,TimeUnit.HOURS) + .build()); + } + + public void addToCache(String key, Session session) { + sessions.put(key, session); + } + + public Session getFromCache(String sessionId) { + return sessions.getIfPresent(sessionId); + } + + public void removeFromCache(String sessionId) { + sessions.invalidate(sessionId); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java new file mode 100644 index 0000000..0e5544c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -0,0 +1,16 @@ +package com.ninjaws.quiz.services; + +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.QuizSettings; + +@Service +public class CleanerService { + + public CleanerService() {} + + public QuizSettings jsonToQuizSettings(String json){ + QuizSettings quizSettings = new QuizSettings(); + return quizSettings; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java new file mode 100644 index 0000000..9bda6b1 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java @@ -0,0 +1,27 @@ +package com.ninjaws.quiz.services; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.Request; + +@Service +public class QueueService { + + /** + * Stores all user requests for data + * Executes them in order, one every 5 seconds (the limit of the external API) + * Removes them from the Queue when the request was succesful + */ + private final BlockingQueue requests = new LinkedBlockingQueue<>(); + + public void addToQueue(Request request) { + requests.add(request); + } + + public Request popFromQueue() { + return requests.poll(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index 8a30239..5d3975f 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -1,22 +1,11 @@ package com.ninjaws.quiz.services; -import java.io.IOException; -import java.util.List; import java.util.Optional; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import com.ninjaws.quiz.models.Answers; -import com.ninjaws.quiz.models.ApiResponse; -import com.ninjaws.quiz.models.Question; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Request; import com.ninjaws.quiz.models.Session; @@ -26,40 +15,9 @@ import com.ninjaws.quiz.models.Session; public class QuizService { @Autowired - private ApiService apiService; + private CacheService cacheService; @Autowired - private ObjectMapper objectMapper; - - /** - * Stores all active sessions - * Gets added to when a user requests data - * Gets removed from when a user requests their score (by sending their answers) - */ - // private final Map sessions = new ConcurrentHashMap<>(); - private final Cache sessions; - - /** - * Stores all user requests for data - * Executes them in order, one every 5 seconds (the limit of the external API) - * Removes them from the Queue when the request was succesful - */ - private final BlockingQueue requests = new LinkedBlockingQueue<>(); - - - public QuizService() { - sessions = (Caffeine.newBuilder() - .expireAfterWrite(1,TimeUnit.HOURS) - .build()); - } - - public QuizService(Cache cache) { - sessions = cache; - } - - public QuizSettings jsonToQuizSettings(String json){ - QuizSettings quizSettings = new QuizSettings(); - return quizSettings; - } + private QueueService queueService; /** * Actions: @@ -71,11 +29,8 @@ public class QuizService { */ public String createSession(QuizSettings quizSettings) { Session session = new Session(); - try { - requests.put(new Request(session.getId(), quizSettings)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + cacheService.addToCache(session.getId(), session); + queueService.addToQueue(new Request(session.getId(), quizSettings)); return session.getId(); } @@ -88,7 +43,7 @@ public class QuizService { * @return The data if it's available, Optional.empty() if not, and an Exception if the session is invalid */ public Optional checkSession(String sessionId) throws IllegalArgumentException { - Session session = sessions.getIfPresent(sessionId); + Session session = cacheService.getFromCache(sessionId); if(session == null) { throw new IllegalArgumentException("Invalid session ID: " + sessionId); @@ -97,41 +52,9 @@ public class QuizService { if (session.getQuestions() == null) { return Optional.empty(); } - System.out.print(session.getQuestions().get(0).getIncorrectAnswers().get(0)); return Optional.of(session); } - /** - * Every 5 seconds: - * - Take the top request from the queue (removes it) - * - Perform the request - * - Wait for the data - * - Find the matching session from the cache - * - Add the data to the session in the cache - */ - @Scheduled(fixedDelay = 5000) - protected void processQueue() { - System.out.print("Schedule!"); - Request request = requests.poll(); - if (request != null) { - System.out.print("----------------An item!"); - String json = apiService.requestQuestions(request.getQuizSettings()); - System.out.print(json); - try { - List questions = extractQuestions(json); - Session session = sessions.getIfPresent(request.getSessionId()); - session.setQuestions(questions); - sessions.put(request.getSessionId(), session); - } catch (IOException e) { - //TODO: Log this - } - } - } - - private List extractQuestions(String json) throws IOException { - return objectMapper.readValue(json, ApiResponse.class).getResults(); - } - /** * Receives: sessionId and answers * @@ -142,16 +65,25 @@ public class QuizService { * - Calculates score * Returns: calculated score * */ - public String getScore(Answers answers) { - Session session = sessions.getIfPresent(answers.getSessionId()); - sessions.invalidate(session.getId()); + public int getScore(Answers answers) { + Session session = cacheService.getFromCache(answers.getSessionId()); + cacheService.removeFromCache(session.getId()); return calculateScore(session, answers); } - private String calculateScore(Session session, Answers answers) { - System.out.print(session); - System.out.print(answers); - return "You did great!"; - } - + private int calculateScore(Session session, Answers answers) { + int amountCorrect = 0; + for(int i = 0; i < session.getQuestions().size(); i++) { + String correctAnswer = session.getQuestions().get(i).getCorrectAnswer(); + String userAnswer = answers.getAnswers().get(i); + // for(String answer : session.getQuestions().get(i).getAnswers()) { + // System.out.print(answer); + // } + + if (correctAnswer.equalsIgnoreCase(userAnswer)) { + amountCorrect++; + } + } + return amountCorrect; + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java new file mode 100644 index 0000000..4813f68 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -0,0 +1,59 @@ +package com.ninjaws.quiz.services; + +import java.io.IOException; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.Question; +import com.ninjaws.quiz.models.Request; +import com.ninjaws.quiz.models.Session; + +@Service +public class SchedulerService { + + @Autowired + private QueueService queueService; + @Autowired + private ApiService apiService; + @Autowired + private CacheService cacheService; + @Autowired + private ObjectMapper objectMapper; + + private static final System.Logger logger = System.getLogger(SchedulerService.class.getName()); + + public SchedulerService() {} + + /** + * Every 5 seconds: + * - Take the top request from the queue (removes it) + * - Perform the request + * - Wait for the data + * - Find the matching session from the cache + * - Add the data to the session in the cache + */ + @Scheduled(fixedDelay = 5000) + protected void processQueue() { + Request request = queueService.popFromQueue(); + if (request != null) { + String json = apiService.requestQuestions(request.getQuizSettings()); + try { + List questions = extractQuestions(json); + Session session = cacheService.getFromCache(request.getSessionId()); + session.setQuestions(questions); + cacheService.addToCache(request.getSessionId(), session); + } catch (IOException | NullPointerException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } + } + + private List extractQuestions(String json) throws IOException { + return objectMapper.readValue(json, ApiResponse.class).getResults(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/StorageService.java b/backend/src/main/java/com/ninjaws/quiz/services/StorageService.java new file mode 100644 index 0000000..d670143 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/StorageService.java @@ -0,0 +1,5 @@ +package com.ninjaws.quiz.services; + +public class StorageService { + +} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/ApiServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt similarity index 100% rename from backend/src/test/java/com/ninjaws/quiz/services/ApiServiceTest.java rename to backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt diff --git a/backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt similarity index 97% rename from backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java rename to backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt index bef75fc..130fbd0 100644 --- a/backend/src/test/java/com/ninjaws/quiz/services/QuizServiceTest.java +++ b/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt @@ -1,11 +1,10 @@ package com.ninjaws.quiz.services; -import org.junit.jupiter.api.TestInstance; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest // @EnableScheduling -@TestInstance(TestInstance.Lifecycle.PER_CLASS) +// @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class QuizServiceTest { // @InjectMocks diff --git a/frontend/.devcontainer/devcontainer.json b/frontend/.devcontainer/devcontainer.json index db9dbcb..2d77981 100644 --- a/frontend/.devcontainer/devcontainer.json +++ b/frontend/.devcontainer/devcontainer.json @@ -25,7 +25,10 @@ "Angular.ng-template", "dbaeumer.vscode-eslint", "Zignd.html-css-class-completion", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "ecmel.vscode-html-css", + "pranaygp.vscode-css-peek" ] } } diff --git a/frontend/.devcontainer/oldDockerfile.txt b/frontend/.devcontainer/oldDockerfile.txt deleted file mode 100644 index 1ec5c0b..0000000 --- a/frontend/.devcontainer/oldDockerfile.txt +++ /dev/null @@ -1,21 +0,0 @@ -# FROM node:22-bookworm-slim - -# ARG WORKDIR -# WORKDIR ${WORKDIR} - -# RUN apt-get update && \ -# apt-get install -y git && \ -# apt-get clean && \ -# rm -rf /var/lib/apt/lists/* - -# # Install Angular CLI globally -# RUN npm install -g @angular/cli - -# # Switching user so it doesn't use root -# # If root adds files, it takes ownership, making them unremovable on the host except by using sudo -# # node tranfers ownership to the host user -# USER node - -# EXPOSE 4200 - -# CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/frontend/angular.json b/frontend/angular.json index 6479c1a..6b938b9 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -98,5 +98,8 @@ } } } + }, + "cli": { + "analytics": false } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a245bc3..4cd7d76 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,15 +25,31 @@ "@angular/cli": "^18.2.10", "@angular/compiler-cli": "^18.2.0", "@types/jasmine": "~5.1.0", + "autoprefixer": "^10.4.20", "jasmine-core": "~5.2.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", "typescript": "~5.5.2" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -193,6 +209,35 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss": { + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -450,6 +495,36 @@ "typescript": ">=5.4 <5.6" } }, + "node_modules/@angular/compiler-cli/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular/compiler-cli/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular/core": { "version": "18.2.9", "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.9.tgz", @@ -4962,6 +5037,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4989,6 +5071,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5474,6 +5563,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001673", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001673.tgz", @@ -5520,19 +5619,41 @@ "license": "MIT" }, "node_modules/chokidar": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", - "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 8.10.0" }, "funding": { "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/chownr": { @@ -6322,6 +6443,20 @@ "dev": true, "license": "MIT" }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -8605,31 +8740,6 @@ "source-map-support": "^0.5.5" } }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/karma/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -8649,19 +8759,6 @@ "dev": true, "license": "MIT" }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/karma/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -8672,32 +8769,6 @@ "node": ">=8" } }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/karma/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8924,6 +8995,16 @@ } } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9780,6 +9861,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -10210,6 +10303,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -10716,6 +10819,16 @@ "node": ">=6" } }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/piscina": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.6.1.tgz", @@ -10743,9 +10856,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -10764,13 +10877,100 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/postcss-loader": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", @@ -10873,6 +11073,32 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, "node_modules/postcss-selector-parser": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", @@ -11054,6 +11280,26 @@ "node": ">= 0.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -11070,17 +11316,29 @@ } }, "node_modules/readdirp": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", - "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.16.0" + "node": ">=8.6" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/reflect-metadata": { @@ -11513,70 +11771,6 @@ } } }, - "node_modules/sass/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/sass/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/sass/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -12410,6 +12604,86 @@ "node": ">=6" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12446,6 +12720,44 @@ "node": ">=0.10" } }, + "node_modules/tailwindcss": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -12637,6 +12949,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thingies": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", @@ -12720,6 +13055,13 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", @@ -13525,35 +13867,6 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/vite/node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", @@ -13752,31 +14065,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/webpack-dev-server/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -13798,19 +14086,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", @@ -13852,32 +14127,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/webpack-dev-server/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -14190,6 +14439,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d16ef44..975ced5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,12 +27,15 @@ "@angular/cli": "^18.2.10", "@angular/compiler-cli": "^18.2.0", "@types/jasmine": "~5.1.0", + "autoprefixer": "^10.4.20", "jasmine-core": "~5.2.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", "typescript": "~5.5.2" } } diff --git a/frontend/src/app/Quiz/models/answers.ts b/frontend/src/app/Quiz/models/answers.ts new file mode 100644 index 0000000..ab90e14 --- /dev/null +++ b/frontend/src/app/Quiz/models/answers.ts @@ -0,0 +1,4 @@ +export interface Answers { + sessionId: string; + answers: string[]; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/question.ts b/frontend/src/app/Quiz/models/question.ts new file mode 100644 index 0000000..fc74e84 --- /dev/null +++ b/frontend/src/app/Quiz/models/question.ts @@ -0,0 +1,11 @@ +export interface Question { + question: string; + answers: string[]; + category: string; + type: string; + difficulty: string; + + /** To make special characters visible */ + question_decoded?: string; + answers_decoded?: string[]; +} diff --git a/frontend/src/app/Quiz/models/quiz-params.ts b/frontend/src/app/Quiz/models/quiz-params.ts new file mode 100644 index 0000000..b37a2b2 --- /dev/null +++ b/frontend/src/app/Quiz/models/quiz-params.ts @@ -0,0 +1,6 @@ +export interface QuizParams { + category: string; + type: string; + amount: number; + difficulty: string; + } \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/score.ts b/frontend/src/app/Quiz/models/score.ts new file mode 100644 index 0000000..9230a22 --- /dev/null +++ b/frontend/src/app/Quiz/models/score.ts @@ -0,0 +1,4 @@ +export interface Score { + amount_questions: number; + amount_correct: number; + } \ No newline at end of file diff --git a/frontend/src/app/Quiz/quiz-routing.module.ts b/frontend/src/app/Quiz/quiz-routing.module.ts new file mode 100644 index 0000000..16851f2 --- /dev/null +++ b/frontend/src/app/Quiz/quiz-routing.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { SetupComponent } from './setup/setup.component'; +import { QuizComponent } from './quiz/quiz.component'; +import { ScoreComponent } from './score/score.component'; + +const routes: Routes = [ + { path: '', component: SetupComponent }, + { path: 'quiz', component: QuizComponent }, + { path: 'score', component: ScoreComponent }, + { path: '**', redirectTo: '' } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class QuizRoutingModule { } diff --git a/frontend/src/app/Quiz/quiz.module.ts b/frontend/src/app/Quiz/quiz.module.ts new file mode 100644 index 0000000..9605ca3 --- /dev/null +++ b/frontend/src/app/Quiz/quiz.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { QuizRoutingModule } from './quiz-routing.module'; + + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + QuizRoutingModule + ], + providers: [] +}) +export class QuizModule { } diff --git a/frontend/src/app/Quiz/quiz/quiz.component.html b/frontend/src/app/Quiz/quiz/quiz.component.html new file mode 100644 index 0000000..64d6bfe --- /dev/null +++ b/frontend/src/app/Quiz/quiz/quiz.component.html @@ -0,0 +1,19 @@ +
+
+
+

{{ question.question_decoded }}

+
+ +
+
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/Quiz/quiz/quiz.component.scss b/frontend/src/app/Quiz/quiz/quiz.component.scss new file mode 100644 index 0000000..ddfa0aa --- /dev/null +++ b/frontend/src/app/Quiz/quiz/quiz.component.scss @@ -0,0 +1,3 @@ +.no-scrollbar { + scrollbar-width: none; + } \ No newline at end of file diff --git a/frontend/src/app/Quiz/quiz/quiz.component.ts b/frontend/src/app/Quiz/quiz/quiz.component.ts new file mode 100644 index 0000000..06029a9 --- /dev/null +++ b/frontend/src/app/Quiz/quiz/quiz.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Score } from '../models/score'; +import { Question } from '../models/question'; +import { QuizService } from '../services/quiz.service'; + +@Component({ + selector: 'app-quiz', + standalone: true, + imports: [CommonModule], + templateUrl: './quiz.component.html', + styleUrl: './quiz.component.scss' +}) +export class QuizComponent { + questions: Question[] = []; + selectedAnswers: { [key: number]: string } = {}; + + constructor(private router: Router, private quizService: QuizService) { + const navigation = this.router.getCurrentNavigation(); + if (navigation?.extras?.state) { + this.questions = navigation.extras.state['questions']; + }else { + // You didn't come here through the proper channels, byebye + this.router.navigate(['']); + } + } + + selectAnswer(questionIndex: number, answer: string) { + this.selectedAnswers[questionIndex] = answer; + } + + allQuestionsAnswered(): boolean { + return Object.keys(this.selectedAnswers).length === this.questions.length; + } + + getScore() { + const answers: string[] = [] + for(let i = 0; i < Object.keys(this.selectedAnswers).length; i++) { + answers.push(this.selectedAnswers[i]); + } + this.quizService.getScore(answers) + .then((result:number) => { + const score: Score = { + amount_correct:result, + amount_questions: Object.keys(this.selectedAnswers).length + } + this.router.navigate(['/score'], { state: { score: score },skipLocationChange:true }); + }) + .catch((e) => { + alert("Something went wrong"); + }); + + // const sampleScore: Score = { + // amount_questions: 10, + // amount_correct: 7 + // } + + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/score/score.component.html b/frontend/src/app/Quiz/score/score.component.html new file mode 100644 index 0000000..751e3d0 --- /dev/null +++ b/frontend/src/app/Quiz/score/score.component.html @@ -0,0 +1,11 @@ +
+

Your Score

+

+ {{ score.amount_correct }} / {{ score.amount_questions }} +

+ +
\ No newline at end of file diff --git a/frontend/src/app/Quiz/score/score.component.scss b/frontend/src/app/Quiz/score/score.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/Quiz/score/score.component.ts b/frontend/src/app/Quiz/score/score.component.ts new file mode 100644 index 0000000..bfe4e1b --- /dev/null +++ b/frontend/src/app/Quiz/score/score.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { Score } from '../models/score'; + +@Component({ + selector: 'app-score', + standalone: true, + imports: [], + templateUrl: './score.component.html', + styleUrl: './score.component.scss' +}) +export class ScoreComponent { + + score !: Score; + + constructor(private router: Router) { + const navigation = this.router.getCurrentNavigation(); + if (navigation?.extras?.state) { + this.score = navigation.extras.state['score']; + }else { + // You didn't come here through the proper channels, byebye + this.router.navigate(['']); + } + } + + playAgain() { + this.router.navigate(['']); + } +} diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts new file mode 100644 index 0000000..ca5e2b8 --- /dev/null +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@angular/core'; +import { QuizParams } from '../models/quiz-params'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { + catchError, + filter, + firstValueFrom, + interval, + lastValueFrom, + of, + startWith, + switchMap, + take, + takeWhile, +} from 'rxjs'; +import { Question } from '../models/question'; +import { Answers } from '../models/answers'; +import { StorageService } from './storage.service'; + +@Injectable({ + providedIn: 'root', +}) +export class QuizService { + private apiUrl = 'http://localhost:9090/quiz'; + + constructor(private http: HttpClient, private storageService: StorageService) {} + + public async startSession(quizParams: QuizParams) { + const sessionIdObj = await firstValueFrom(this.http.get(this.apiUrl + '/questions' + this.convertParamsToUrl(quizParams))); + this.storageService.setSession(sessionIdObj.sessionId); + const questionsObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); + return this.cleanQuestionStrings(questionsObj.questions) as Question[]; + } + + public pollQuestions(sessionId: string) { + return interval(2000).pipe( + startWith(0), + switchMap(() => this.http.get(this.apiUrl + `/status/${sessionId}`)), + // takeWhile((response) => response === null), + filter((response) => response !== null), + take(1) + ); + } + + private cleanQuestionStrings(questions: Question[]): Question[] { + for(let i = 0; i < questions.length; i++) { + questions[i].question_decoded = this.decodeString(questions[i].question); + questions[i].answers_decoded = []; + for(let j = 0; j < questions[i].answers.length; j++){ + questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j])); + } + } + return questions; + } + + /** Puts special characters back into the string, like ' and " */ + private decodeString(text: string) { + const txt = document.createElement("textarea"); + txt.innerHTML = text; + const decodedValue = txt.value; + txt.remove(); // Clean up the temporary element + return decodedValue; + } + + public async getScore(answers: string[]) { + const answersJson: Answers = { + sessionId: this.storageService.getSession()!, + answers: answers + } + + const headers = new HttpHeaders({'Content-Type': 'application/json'}); + const scoreObj = await firstValueFrom(this.http.post(this.apiUrl+'/score', answersJson, { headers })); + return scoreObj.score; + } + + private convertParamsToUrl(quizParams: QuizParams) { + let url = `?amount=${quizParams.amount}`; + const catIndex = this.getCategories().indexOf(quizParams.category); + url += catIndex !== 0 ? `&category=${catIndex}` : ''; + + const diff = quizParams.difficulty.toLowerCase(); + url += diff !== 'any difficulty' ? `&category=${diff}` : ''; + + const typeIndex = this.getTypes().indexOf(quizParams.type); + url += + typeIndex !== 0 + ? typeIndex === 1 + ? '&type=multiple' + : '&type=boolean' + : ''; + return url; + } + + public getCategories(): string[] { + return [ + 'Any Category', + 'General Knowledge', + 'Entertainment: Books', + 'Entertainment: Film', + 'Entertainment: Music', + 'Entertainment: Musicals & Theatres', + 'Entertainment: Television', + 'Entertainment: Video Games', + 'Entertainment: Board Games', + 'Science & Nature', + 'Science: Computers', + 'Science: Mathematics', + 'Mythology', + 'Sports', + 'Geography', + 'History', + 'Politics', + 'Art', + 'Celebrities', + 'Animals', + 'Vehicles', + 'Entertainment: Comics', + 'Science: Gadgets', + 'Entertainment: Japanese Anime & Manga', + 'Entertainment: Cartoon & Animations', + ]; + } + + public getDifficulties(): string[] { + return ['Any Difficulty', 'Easy', 'Medium', 'Hard']; + } + + public getTypes(): string[] { + return ['Any Type', 'Multiple choice', 'True/False']; + } +} diff --git a/frontend/src/app/Quiz/services/storage.service.ts b/frontend/src/app/Quiz/services/storage.service.ts new file mode 100644 index 0000000..61186b2 --- /dev/null +++ b/frontend/src/app/Quiz/services/storage.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from "@angular/core"; + +@Injectable({ + providedIn: 'root', + }) + export class StorageService { + constructor() { + } + + public setSession(sessionId: string): void { + localStorage.setItem("sessionId", sessionId); + } + + public getSession(): string | null { + return localStorage.getItem("sessionId"); + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/setup/setup.component.html b/frontend/src/app/Quiz/setup/setup.component.html new file mode 100644 index 0000000..9cc3b1d --- /dev/null +++ b/frontend/src/app/Quiz/setup/setup.component.html @@ -0,0 +1,35 @@ +
+
+

Quiz App

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/Quiz/setup/setup.component.scss b/frontend/src/app/Quiz/setup/setup.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/Quiz/setup/setup.component.ts b/frontend/src/app/Quiz/setup/setup.component.ts new file mode 100644 index 0000000..fc928ac --- /dev/null +++ b/frontend/src/app/Quiz/setup/setup.component.ts @@ -0,0 +1,60 @@ +import { HttpClient } from '@angular/common/http'; +import { Component } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { QuizService } from '../services/quiz.service'; +import { QuizParams } from '../models/quiz-params'; +import { SpinnerService } from '../../Spinner/services/spinner.service'; +import { CommonModule } from '@angular/common'; +import { Question } from '../models/question'; + +@Component({ + selector: 'app-setup', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './setup.component.html', + styleUrl: './setup.component.scss', +}) +export class SetupComponent { + + + category: string = ""; + difficulty: string = ""; + type: string = ""; + amount: number = 10; + + constructor( + private router: Router, + public quizService: QuizService, + private spinnerService: SpinnerService + ) { + /** Setting default values */ + this.category = this.quizService.getCategories()[0]; + this.difficulty = this.quizService.getDifficulties()[0]; + this.type = this.quizService.getTypes()[0]; + } + + startQuiz() { + const params: QuizParams = { + category: this.category, + type: this.type, + amount: this.amount, + difficulty: this.difficulty, + }; + + this.spinnerService.setActive(true); + this.quizService.startSession(params).then((questions: Question[]) => { + this.gotoQuiz(questions); + }) + .catch((e) => {alert("Something went wrong, please try again");}) + .finally(() => {this.spinnerService.setActive(false);}); + + } + + gotoQuiz(questions: Question[]) { + this.router.navigate(['/quiz'], { + state: { questions: questions }, + skipLocationChange: true, + }); + } +} diff --git a/frontend/src/app/Spinner/services/spinner.service.ts b/frontend/src/app/Spinner/services/spinner.service.ts new file mode 100644 index 0000000..477a7ff --- /dev/null +++ b/frontend/src/app/Spinner/services/spinner.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class SpinnerService { + + private isActive = false; + constructor() { } + + toggleSpinner() { + this.isActive = !this.isActive; + } + + setActive(active: boolean) { + new Promise( + (resolve) => setTimeout(resolve, 0)).then( + _ =>this.isActive = active + ) + } + + isSpinnerActive() { + return this.isActive; + } +} diff --git a/frontend/src/app/Spinner/spinner.module.ts b/frontend/src/app/Spinner/spinner.module.ts new file mode 100644 index 0000000..2a26e39 --- /dev/null +++ b/frontend/src/app/Spinner/spinner.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + + + +@NgModule({ + declarations: [], + imports: [] +}) +export class SpinnerModule { } diff --git a/frontend/src/app/Spinner/spinner/spinner.component.html b/frontend/src/app/Spinner/spinner/spinner.component.html new file mode 100644 index 0000000..944a7cd --- /dev/null +++ b/frontend/src/app/Spinner/spinner/spinner.component.html @@ -0,0 +1,5 @@ +
+
+
+
+
diff --git a/frontend/src/app/Spinner/spinner/spinner.component.scss b/frontend/src/app/Spinner/spinner/spinner.component.scss new file mode 100644 index 0000000..7fdd1bf --- /dev/null +++ b/frontend/src/app/Spinner/spinner/spinner.component.scss @@ -0,0 +1,98 @@ +.lds-spinner { + color: green; + display: inline-block; + position: relative; + width: 80px; + height: 80px; + } + .lds-spinner div { + transform-origin: 40px 40px; + animation: lds-spinner 1.2s linear infinite; + } + .lds-spinner div:after { + content: " "; + display: block; + position: absolute; + top: 3px; + left: 37px; + width: 6px; + height: 18px; + border-radius: 20%; + background: #fff; + } + .lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; + } + .lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; + } + .lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; + } + .lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; + } + .lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; + } + .lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; + } + .lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; + } + .lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; + } + .lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; + } + .lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; + } + .lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; + } + .lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; + } + @keyframes lds-spinner { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } + } + + .spinner { + position: fixed; /* Stay in place */ + z-index: var(--z-spinner); + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +.spinner-content { + width:100%; + height:100%; + display:flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/frontend/src/app/Spinner/spinner/spinner.component.ts b/frontend/src/app/Spinner/spinner/spinner.component.ts new file mode 100644 index 0000000..0962b59 --- /dev/null +++ b/frontend/src/app/Spinner/spinner/spinner.component.ts @@ -0,0 +1,22 @@ +import { animate, style, transition, trigger } from '@angular/animations'; +import { Component } from '@angular/core'; +import { SpinnerService } from '../services/spinner.service'; +import { CommonModule } from '@angular/common'; +import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; + +@Component({ + selector: 'app-spinner', + standalone: true, + imports: [CommonModule], + templateUrl: './spinner.component.html', + styleUrl: './spinner.component.scss', + styles: [], + animations: [ + trigger('fadeInOut', [ + transition(':enter', [style({ opacity:0}), animate("300ms ease-in-out")]), + transition(':leave', [animate("300ms ease-in-out", style({ opacity: 0}))]), + ])] +}) +export class SpinnerComponent { + constructor(public spinnerService: SpinnerService) { } +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 60599ac..5c1583f 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1 +1,4 @@ -

Quiz App

\ No newline at end of file + + + + \ No newline at end of file diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index b759ba8..57d5d9b 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,10 +1,11 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { SpinnerComponent } from './Spinner/spinner/spinner.component'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [RouterOutlet, SpinnerComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index a1e7d6f..746ac02 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -2,7 +2,11 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { provideHttpClient } from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient()] }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index dc39edb..e9f8430 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,3 +1,7 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', loadChildren: () => import('./Quiz/quiz.module').then(m => m.QuizModule) }, // Lazy load QuizModule + // { path: '**', redirectTo: '/quiz' } // Handle unmatched routes + +]; diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 90d4ee0..7b1e05d 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1 +1,27 @@ /* You can add global styles to this file, and also import other style files */ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +html, body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + display: flex; + } + + app-root { + flex: 1; /* Makes the AppComponent fill the available space */ + // display:flex; + // justify-content: center; + // align-items: center; + } + + :root { + // --z-base: 0; + // --z-dropdown: 100; + // --z-modal: 200; + // --z-tooltip: 300; + --z-spinner: 1800; +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..8314e1b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}" + ], + theme: { + extend: {}, + }, + plugins: [], +} + From 675bdc7f8e33b7b1fe96aab78d8c6eb510bee457 Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Fri, 1 Nov 2024 17:02:24 +0100 Subject: [PATCH 05/12] [Updated] Finished Frontend, Added statushandling to backend and frontend [Todo: session token handling at lvl 2] --- .../quiz/controllers/QuizController.java | 9 +- .../com/ninjaws/quiz/models/QuizSettings.java | 5 -- .../com/ninjaws/quiz/services/ApiService.java | 43 ++++----- .../ninjaws/quiz/services/CleanerService.java | 14 ++- .../ninjaws/quiz/services/QueueService.java | 10 ++- .../ninjaws/quiz/services/QuizService.java | 22 +++-- .../services/ResponseHandlingService.java | 83 ++++++++++++++++++ .../quiz/services/SchedulerService.java | 38 ++------ frontend/public/question.ico | Bin 0 -> 10462 bytes frontend/src/app/Quiz/models/session.ts | 7 ++ .../src/app/Quiz/services/cleaner.service.ts | 33 +++++++ .../src/app/Quiz/services/quiz.service.ts | 39 ++++---- .../Quiz/services/response-handler.service.ts | 36 ++++++++ .../src/app/Quiz/setup/setup.component.ts | 6 +- frontend/src/app/app.component.html | 2 - frontend/src/index.html | 2 +- 16 files changed, 247 insertions(+), 102 deletions(-) create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java create mode 100644 frontend/public/question.ico create mode 100644 frontend/src/app/Quiz/models/session.ts create mode 100644 frontend/src/app/Quiz/services/cleaner.service.ts create mode 100644 frontend/src/app/Quiz/services/response-handler.service.ts diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index 758af5c..69b89b8 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -31,8 +31,13 @@ public class QuizController { @RequestParam(required = false) String type ) { QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type); - String jsonString = String.format("{\"sessionId\":\"%s\"}", quizService.createSession(quizSettings)); - return jsonString; + Optional result = quizService.createSession(quizSettings); + if(result.isPresent()) { + String jsonString = String.format("{\"statusCode\":0,\"sessionId\":\"%s\"}", result.get()); + return jsonString; + } + String jsonString = String.format("{\"statusCode\":5}"); + return jsonString; } @GetMapping("/status/{sessionId}") diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java index a0321e5..9ed2ff4 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -59,11 +59,6 @@ public class QuizSettings { /** Inverse of toApiQuery */ public QuizSettings(int amount, Integer category, String difficulty, String type) { - System.out.println(amount); - System.out.print(category); - System.out.print(difficulty); - System.out.print(type); - this.amount = amount; this.category = category == null ? Category.ANY : Category.values()[category]; this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index aa096bb..b91bf88 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -1,21 +1,32 @@ package com.ninjaws.quiz.services; +import java.io.IOException; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; +import com.ninjaws.quiz.models.ApiResponse; import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; import jakarta.validation.Valid; @Service public class ApiService { + @Autowired + private CleanerService cleanerService; + @Autowired + private ResponseHandlingService responseHandler; @Autowired private RestTemplate restTemplate; - private final String apiUrl = "https://opentdb.com/api.php"; + private static final System.Logger logger = System.getLogger(ApiService.class.getName()); + + private final String apiUrl = "https://opentdb.com/api.php"; public ApiService() { } @@ -24,25 +35,17 @@ public class ApiService { return apiUrl + quizSettings.toApiQuery(); } - public String requestQuestions(@Valid QuizSettings quizSettings) { - String url = buildRequestString(quizSettings); - ResponseEntity response = restTemplate.getForEntity(url, String.class); - String jsonData = response.getBody(); - - // try { - // int responseCode = getResponseCode(jsonData); - // /** When the code has expired or you've ran out of questions, update the token and try again */ - // if(responseCode == 3 || responseCode == 4) { - // requestSessionToken(); - // requestQuestions(quizSettings); - // } - // } catch (Exception e) { - // // Failed to get a responseCode, meaning the request failed completely (they always send a response_code) - // } - return jsonData; - } - - public void handleStatusCode(int statusCode) { + public void handleRequest(@Valid Request request) { + String url = buildRequestString(request.getQuizSettings()); + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + ApiResponse apiResponse = cleanerService.extractApiResponse(json); + responseHandler.handleResponse(request.getSessionId(), apiResponse); + + } catch (RestClientException | IOException | IllegalStateException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java index 0e5544c..5385f67 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -1,16 +1,22 @@ package com.ninjaws.quiz.services; +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.ninjaws.quiz.models.QuizSettings; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.models.ApiResponse; @Service public class CleanerService { + @Autowired + private ObjectMapper objectMapper; + public CleanerService() {} - public QuizSettings jsonToQuizSettings(String json){ - QuizSettings quizSettings = new QuizSettings(); - return quizSettings; + public ApiResponse extractApiResponse(String json) throws IOException { + return objectMapper.readValue(json, ApiResponse.class); } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java index 9bda6b1..4568b69 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java @@ -15,10 +15,14 @@ public class QueueService { * Executes them in order, one every 5 seconds (the limit of the external API) * Removes them from the Queue when the request was succesful */ - private final BlockingQueue requests = new LinkedBlockingQueue<>(); + private final BlockingQueue requests = new LinkedBlockingQueue<>(5); - public void addToQueue(Request request) { - requests.add(request); + /** + * Offer automatically doesn't add it to the queue if it is full, so no Exceptions thrown + * @return Whether it's possible to add the request to the queue + */ + public boolean addToQueue(Request request) { + return requests.offer(request); } public Request popFromQueue() { diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index 5d3975f..0b22c30 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -27,12 +27,17 @@ public class QuizService { * @param quizSettings The values set by the user, determining the kind of quiz they want * @return The sessionId */ - public String createSession(QuizSettings quizSettings) { + public Optional createSession(QuizSettings quizSettings) { Session session = new Session(); cacheService.addToCache(session.getId(), session); - queueService.addToQueue(new Request(session.getId(), quizSettings)); + boolean addedToQueue = queueService.addToQueue(new Request(session.getId(), quizSettings)); + /** In this order, because doing the queue before the cache could lead to race conditions (request completion immediately calls cache, but then the item doesn't exist) */ + if(!addedToQueue) { + cacheService.removeFromCache(session.getId()); + return Optional.empty(); + } - return session.getId(); + return Optional.of(session.getId()); } /** @@ -49,9 +54,16 @@ public class QuizService { throw new IllegalArgumentException("Invalid session ID: " + sessionId); } + /** Still waiting for a response */ if (session.getQuestions() == null) { return Optional.empty(); } + + /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ + if(session.getStatusCode() != 0) { + cacheService.removeFromCache(session.getId()); + } + return Optional.of(session); } @@ -76,10 +88,6 @@ public class QuizService { for(int i = 0; i < session.getQuestions().size(); i++) { String correctAnswer = session.getQuestions().get(i).getCorrectAnswer(); String userAnswer = answers.getAnswers().get(i); - // for(String answer : session.getQuestions().get(i).getAnswers()) { - // System.out.print(answer); - // } - if (correctAnswer.equalsIgnoreCase(userAnswer)) { amountCorrect++; } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java new file mode 100644 index 0000000..e642039 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -0,0 +1,83 @@ +package com.ninjaws.quiz.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.Session; + +@Service +public class ResponseHandlingService { + + @Autowired + private CacheService cacheService; + + /** + * If there is no sessionId, then this is a request from the scheduler + * purely to stock in the database No Cache call is required + */ + public void handleResponse(String sessionId, ApiResponse response) throws IllegalStateException { + + Session session = cacheService.getFromCache(sessionId); + if (session == null) { + // Critical system failure: Should never occur + throw new IllegalStateException("Session should be defined!"); + } + + switch (response.getResponseCode()) { + case 0: + // Nothing + updateSessionInCache(session, response); + break; + case 1: + // Not enough questions to satisfy the request. + updateSessionInCache(session, response); + break; + case 2: + // Invalid parameters + updateSessionInCache(session, response); + break; + case 3: + // If it expired. Shouldn't happen since the backup database is full within 40 minutes, and the code lasts for 6 hours + // Return a statuscode 1 to signal an issue, just in case a user hits this situation (should not happen) + session.setStatusCode(1); + updateSessionInCache(session, response); + case 4: + // It returns nothing, the previous batch was the last one + // Return a statuscode 1 to the user + session.setStatusCode(1); + updateSessionInCache(session, response); + break; + case 5: + // Should be prevented by the scheduler + default: + break; + } + } + + /** + * If the request was made by the scheduler during queue-downtime, there is no session + */ + public void switchInternal(ApiResponse response) { + switch (response.getResponseCode()) { + case 0: + // TODO: Store in DB + break; + case 4: + // Signal DataService that we have extracted all the data. Stop gathering, switch to caching-mode + // TODO: Signal DB that it's full, start db-swapping process + break; + case 5: + // Should be prevented by the scheduler + default: + break; + } + } + + private void updateSessionInCache(Session session, ApiResponse response) { + session.setStatusCode(response.getResponseCode()); + session.setQuestions(response.getResults()); + cacheService.addToCache(session.getId(), session); + } + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java index 4813f68..a57f3c9 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -1,17 +1,10 @@ package com.ninjaws.quiz.services; -import java.io.IOException; -import java.util.List; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.models.ApiResponse; -import com.ninjaws.quiz.models.Question; import com.ninjaws.quiz.models.Request; -import com.ninjaws.quiz.models.Session; @Service public class SchedulerService { @@ -20,40 +13,21 @@ public class SchedulerService { private QueueService queueService; @Autowired private ApiService apiService; - @Autowired - private CacheService cacheService; - @Autowired - private ObjectMapper objectMapper; - - private static final System.Logger logger = System.getLogger(SchedulerService.class.getName()); public SchedulerService() {} /** * Every 5 seconds: - * - Take the top request from the queue (removes it) - * - Perform the request - * - Wait for the data - * - Find the matching session from the cache - * - Add the data to the session in the cache + * - Check if there is a request in queue + * - Yes: Send it to the apiService + * - No: Have the apiService do bulk gathering for the DB */ @Scheduled(fixedDelay = 5000) protected void processQueue() { Request request = queueService.popFromQueue(); if (request != null) { - String json = apiService.requestQuestions(request.getQuizSettings()); - try { - List questions = extractQuestions(json); - Session session = cacheService.getFromCache(request.getSessionId()); - session.setQuestions(questions); - cacheService.addToCache(request.getSessionId(), session); - } catch (IOException | NullPointerException e) { - logger.log(System.Logger.Level.ERROR, "Critical failure", e); - } + apiService.handleRequest(request); } - } - - private List extractQuestions(String json) throws IOException { - return objectMapper.readValue(json, ApiResponse.class).getResults(); - } + } + } diff --git a/frontend/public/question.ico b/frontend/public/question.ico new file mode 100644 index 0000000000000000000000000000000000000000..a27a1978a9c14373712b03787ac656357c601168 GIT binary patch literal 10462 zcmdU#O^6&t6vyAV>iB`Khmecth>D7!VnB?dY%YRV6+DUu4I00q7a-*%u6NK6_YrL;A_zhiy@bN`{JG^`qPreUN?cLkB@E`D3>(f@VZ;6- zDb51d6=>49>agLy0){q<>MHH1VZUnJU;zrlzz%X%^0vRU85(Um4tZ$tGYn=F^!29{>z1O`7%?#WDn4Z0{1mz3!A1W7em8YP)b#_?xa+G1}xuClO-pELM&g<8RQ=O z#kX>BaBhv|Wu*DwJG;eqUA7wTyE!>xmT~uEdGnz8_@g?0{A!(HZLNNN=(|f=o6B=) z>%O@A*~obrgF$@wE)1M!TFdss*sDJu`p!=yGKjw1EF+Dj;V~6|#*vx#$t7kG{-G=e z#*)}#`47sew451pOzC?0zV8?_%%DQweop4OQr1;D*N$$#dwt)+lD>D+n4u^R&iOpgah+xTm%}$kzVn4) zmJ4Z4a*X>eXKTay9_rqs@Lo9GYnl0N37l^t-a_Nwp8Ai6nE(3vMM^|ht?-5gh};rC$OK03sw zU&WS4w6}-!(OHHvh~Atm2c;iNo^u0z!h49`p6~H);tuR{Iaiq(#6}JlTl%n6iyvMC zpZtD)MS=aE-k$H#cClZ3tv)w{*vZIpv3uQ)mh1P!em4t`)z4N}Kec7ycg{b9?Wf1Kwz+N=)npLG<*ApT@z(PR4;{Nke54Z1I!L0B**c5}>f_%_II z{TXz+Z;G8s$O2d<&LD9z$TBqsgDg{LFu+n8hA~)5$1nm*X&G{`l%7FY()Y?$ z+eEW0H;Jk&H;86fHu3-PSb7z{kg{f)bO4ed=RE2V>T4dMI}~_??oi+nxSoN*BroO?PT`g~NL*AJr~l#xU5bmuRpKmhPxA0Dcu%Bx literal 0 HcmV?d00001 diff --git a/frontend/src/app/Quiz/models/session.ts b/frontend/src/app/Quiz/models/session.ts new file mode 100644 index 0000000..64801c1 --- /dev/null +++ b/frontend/src/app/Quiz/models/session.ts @@ -0,0 +1,7 @@ +import { Question } from "./question"; + +export interface Session { + id: string; + statusCode: number; + questions: Question[]; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/services/cleaner.service.ts b/frontend/src/app/Quiz/services/cleaner.service.ts new file mode 100644 index 0000000..8dbf81b --- /dev/null +++ b/frontend/src/app/Quiz/services/cleaner.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { Question } from '../models/question'; + +@Injectable({ + providedIn: 'root' +}) +export class CleanerService { + + constructor() { } + + /** + * Sets decoded strings for the questions and the answers, so the special characters will be shown instead of their codes + */ + public cleanQuestionStrings(questions: Question[]): Question[] { + for(let i = 0; i < questions.length; i++) { + questions[i].question_decoded = this.decodeString(questions[i].question); + questions[i].answers_decoded = []; + for(let j = 0; j < questions[i].answers.length; j++){ + questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j])); + } + } + return questions; + } + + /** Puts special characters back into the string, like ' and " */ + private decodeString(text: string) { + const txt = document.createElement("textarea"); + txt.innerHTML = text; + const decodedValue = txt.value; + txt.remove(); // Clean up the temporary element + return decodedValue; + } +} diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts index ca5e2b8..67d1da2 100644 --- a/frontend/src/app/Quiz/services/quiz.service.ts +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -16,6 +16,8 @@ import { import { Question } from '../models/question'; import { Answers } from '../models/answers'; import { StorageService } from './storage.service'; +import { CleanerService } from './cleaner.service'; +import { ResponseHandlerService } from './response-handler.service'; @Injectable({ providedIn: 'root', @@ -23,13 +25,22 @@ import { StorageService } from './storage.service'; export class QuizService { private apiUrl = 'http://localhost:9090/quiz'; - constructor(private http: HttpClient, private storageService: StorageService) {} + constructor( + private http: HttpClient, + private storageService: StorageService, + private cleanerService: CleanerService, + private reponseHandler: ResponseHandlerService) {} - public async startSession(quizParams: QuizParams) { + public async startSession(quizParams: QuizParams): Promise { const sessionIdObj = await firstValueFrom(this.http.get(this.apiUrl + '/questions' + this.convertParamsToUrl(quizParams))); + /** No need to poll if the session is not created. Likely means the queue is full */ + if(sessionIdObj.statusCode !== 0) { + return this.reponseHandler.handleResponse(sessionIdObj.statusCode); + } this.storageService.setSession(sessionIdObj.sessionId); - const questionsObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); - return this.cleanQuestionStrings(questionsObj.questions) as Question[]; + const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); + + return this.reponseHandler.handleResponse(sessionIdObj.statusCode, sessionObj); } public pollQuestions(sessionId: string) { @@ -42,26 +53,6 @@ export class QuizService { ); } - private cleanQuestionStrings(questions: Question[]): Question[] { - for(let i = 0; i < questions.length; i++) { - questions[i].question_decoded = this.decodeString(questions[i].question); - questions[i].answers_decoded = []; - for(let j = 0; j < questions[i].answers.length; j++){ - questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j])); - } - } - return questions; - } - - /** Puts special characters back into the string, like ' and " */ - private decodeString(text: string) { - const txt = document.createElement("textarea"); - txt.innerHTML = text; - const decodedValue = txt.value; - txt.remove(); // Clean up the temporary element - return decodedValue; - } - public async getScore(answers: string[]) { const answersJson: Answers = { sessionId: this.storageService.getSession()!, diff --git a/frontend/src/app/Quiz/services/response-handler.service.ts b/frontend/src/app/Quiz/services/response-handler.service.ts new file mode 100644 index 0000000..2c5493b --- /dev/null +++ b/frontend/src/app/Quiz/services/response-handler.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { Session } from '../models/session'; +import { CleanerService } from './cleaner.service'; +import { Question } from '../models/question'; + +@Injectable({ + providedIn: 'root', +}) +export class ResponseHandlerService { + constructor(private cleanerService: CleanerService) {} + + /** + * Session is optional, except for the success state (0) + */ + public handleResponse(statusCode: number, session?: Session): Question[] | null { + switch (statusCode) { + case 0: + // Success + session!.questions = this.cleanerService.cleanQuestionStrings(session!.questions); + return session!.questions; + case 1: + // No Results (requests too much data for this combination of parameters) + alert('Not enough data for this request, please try a different combination'); + return null; + case 2: + // Invalid parameter (should be handled by the backend) + alert('Invalid input'); + return null; + case 5: + // Queue is full, try again later + alert('The server is experiencing a lot of traffic, please try again later'); + return null; + } + return null; + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/setup/setup.component.ts b/frontend/src/app/Quiz/setup/setup.component.ts index fc928ac..34c9056 100644 --- a/frontend/src/app/Quiz/setup/setup.component.ts +++ b/frontend/src/app/Quiz/setup/setup.component.ts @@ -43,8 +43,10 @@ export class SetupComponent { }; this.spinnerService.setActive(true); - this.quizService.startSession(params).then((questions: Question[]) => { - this.gotoQuiz(questions); + this.quizService.startSession(params).then((questions: Question[] | null) => { + if(questions !== null) { + this.gotoQuiz(questions); + } }) .catch((e) => {alert("Something went wrong, please try again");}) .finally(() => {this.spinnerService.setActive(false);}); diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 5c1583f..363b1a0 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,4 +1,2 @@ - - \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html index d51364b..195d95b 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -5,7 +5,7 @@ Quiz - + From 595b83b364263d11ce3ca2e7642606a716c6a517 Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Fri, 1 Nov 2024 19:52:53 +0100 Subject: [PATCH 06/12] [Updated] Dockerfiles so everything runs using docker compose up, added the benchmark and speed tests --- backend/Dockerfile | 17 ++++++-- .../quiz/controllers/QuizController.java | 34 ++++++++++++---- .../com/ninjaws/quiz/services/ApiService.java | 17 ++++++++ .../services/ResponseHandlingService.java | 4 +- .../quiz/services/backupApiServiceTest.txt | 2 +- docker-compose.yml | 11 +++++- frontend/Dockerfile | 2 +- frontend/angular.json | 6 +++ .../src/app/Quiz/services/quiz.service.ts | 3 +- frontend/src/environments/environment.prod.ts | 6 +++ frontend/src/environments/environment.ts | 4 ++ scripts/LoadTestMultiple.sh | 17 ++++++++ scripts/LoadTestOne.sh | 37 ++++++++++++++++++ scripts/benchmarks/basic.png | Bin 0 -> 63314 bytes 14 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 frontend/src/environments/environment.prod.ts create mode 100644 frontend/src/environments/environment.ts create mode 100755 scripts/LoadTestMultiple.sh create mode 100755 scripts/LoadTestOne.sh create mode 100644 scripts/benchmarks/basic.png diff --git a/backend/Dockerfile b/backend/Dockerfile index fc70432..fd634e1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,11 +16,22 @@ ENV USER=dev RUN groupadd -g ${GID} ${USER} \ && useradd -m -u ${UID} -g ${USER} ${USER} USER ${USER} -EXPOSE 8080 +EXPOSE 9090 CMD ["tail","-f","/dev/null"] #---Building +FROM base AS build +COPY . . +RUN mvn clean package -DskipTests +#--Testing +FROM build AS testing +RUN mvn clean test - -#---Production \ No newline at end of file +#---Production +# Pulled again to get rid of maven, to decrease the size +FROM openjdk:17-jdk-slim AS production +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 9090 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index 69b89b8..b9f592e 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -23,28 +23,46 @@ public class QuizController { @Autowired private QuizService quizService; + /** + * Start a session + * @param amount The amount of questsions + * @param category The desired category (or all) + * @param difficulty The desired difficulty (or all) + * @param type The desired type (or both) + * @return The sessionId, or statusCode 5 when the queue is full + */ @GetMapping("/questions") public String startSession( - @RequestParam(required = true) Integer amount, - @RequestParam(required = false) Integer category, - @RequestParam(required = false) String difficulty, - @RequestParam(required = false) String type + @RequestParam(required = true) Integer amount, + @RequestParam(required = false) Integer category, + @RequestParam(required = false) String difficulty, + @RequestParam(required = false) String type ) { QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type); Optional result = quizService.createSession(quizSettings); - if(result.isPresent()) { + if (result.isPresent()) { String jsonString = String.format("{\"statusCode\":0,\"sessionId\":\"%s\"}", result.get()); return jsonString; } - String jsonString = String.format("{\"statusCode\":5}"); - return jsonString; - } + String jsonString = String.format("{\"statusCode\":5}"); + return jsonString; + } + /** + * Checks if the questions have been retrieved + * @param sessionId The session that holds the questions + * @return The Session, or nothing if they are not ready yet + */ @GetMapping("/status/{sessionId}") public Optional checkStatus(@PathVariable String sessionId) { return quizService.checkSession(sessionId); } + /** + * Receive the chosen answers, calculate the score, and return it + * @param answers The chosen answers + * @return The score + */ @PostMapping("/score") public String checkAnswers(@RequestBody Answers answers) { String jsonString = String.format("{\"score\":\"%s\"}", quizService.getScore(answers)); diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index b91bf88..7918f4f 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -48,4 +48,21 @@ public class ApiService { logger.log(System.Logger.Level.ERROR, "Critical failure", e); } } + + /** + * Collect a chuck of 50 items + * Store them in the DataService + */ + public void collectBulk() { + String url = apiUrl + "?amount=50"; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + ApiResponse apiResponse = cleanerService.extractApiResponse(json); + responseHandler.handleResponse(apiResponse); + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java index e642039..af391f0 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -26,7 +26,7 @@ public class ResponseHandlingService { switch (response.getResponseCode()) { case 0: - // Nothing + // Success updateSessionInCache(session, response); break; case 1: @@ -58,7 +58,7 @@ public class ResponseHandlingService { /** * If the request was made by the scheduler during queue-downtime, there is no session */ - public void switchInternal(ApiResponse response) { + public void handleResponse(ApiResponse response) { switch (response.getResponseCode()) { case 0: // TODO: Store in DB diff --git a/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt index 56d112b..1b3f44e 100644 --- a/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt +++ b/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt @@ -30,7 +30,7 @@ public class ApiServiceTest { @Test public void testRequestQuestions() throws Exception { QuizSettings settings = new QuizSettings(); - String jsonResponse = apiService.requestQuestions(settings); + String jsonResponse = apiService.handleRequest(settings); JsonNode jsonNode = objectMapper.readTree(jsonResponse); // Check if the response code is as expected diff --git a/docker-compose.yml b/docker-compose.yml index 9e0016c..16dd208 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,13 @@ services: image: quiz-frontend-prod container_name: quiz-frontend-prod-cont ports: - - "4200:80" \ No newline at end of file + - "4200:80" + backend: + build: + context: ./backend + dockerfile: Dockerfile + target: production + image: quiz-backend-prod + container_name: quiz-backend-prod-cont + ports: + - "9090:9090" \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 619be8d..b59de8b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,7 +22,7 @@ COPY package*.json ./ RUN npm install # Copied here, since the the Development setup uses a bind volume instead COPY . . -RUN npm run build --prod +RUN ng build --configuration production #--- Production FROM nginx:alpine AS production diff --git a/frontend/angular.json b/frontend/angular.json index 6b938b9..c9654d3 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -50,6 +50,12 @@ "maximumError": "4kB" } ], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], "outputHashing": "all" }, "development": { diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts index 67d1da2..5be7448 100644 --- a/frontend/src/app/Quiz/services/quiz.service.ts +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -18,12 +18,13 @@ import { Answers } from '../models/answers'; import { StorageService } from './storage.service'; import { CleanerService } from './cleaner.service'; import { ResponseHandlerService } from './response-handler.service'; +import { environment } from '../../../environments/environment'; @Injectable({ providedIn: 'root', }) export class QuizService { - private apiUrl = 'http://localhost:9090/quiz'; + private apiUrl = environment.apiUrl + '/quiz'; constructor( private http: HttpClient, diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 0000000..b605977 --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + /** Connecting through the docker compose network is not working for now, so I'll have to use the localhost */ + // apiUrl: 'http://backend:9090', + apiUrl: 'http://localhost:9090', + }; \ No newline at end of file diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..53a7aba --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: 'http://localhost:9090' + }; \ No newline at end of file diff --git a/scripts/LoadTestMultiple.sh b/scripts/LoadTestMultiple.sh new file mode 100755 index 0000000..9122900 --- /dev/null +++ b/scripts/LoadTestMultiple.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +start_time=$(date +%s) + +for i in {1..6}; do + ./LoadTestOne.sh & + sleep 0.1 +done + +wait # Wait for all background jobs to finish + +end_time=$(date +%s) # Capture the end time + +# Calculate and print the elapsed time +elapsed_time=$((end_time - start_time)) +echo "Done!" +echo "Total time taken: $elapsed_time seconds" diff --git a/scripts/LoadTestOne.sh b/scripts/LoadTestOne.sh new file mode 100755 index 0000000..1137ee1 --- /dev/null +++ b/scripts/LoadTestOne.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s http://localhost:9090/quiz/questions?amount=5) + +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is not 0." + exit 1 +fi + +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + echo "Failed to get sessionId" + exit 1 +fi + +echo "Received sessionId: $sessionId" + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") + + if [ $? -eq 0 ]; then + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then + echo "$sessionId: done!" + break + fi + else + echo "Status check failed, retrying..." + fi + + sleep 2 +done diff --git a/scripts/benchmarks/basic.png b/scripts/benchmarks/basic.png new file mode 100644 index 0000000000000000000000000000000000000000..8d600d1f989a42d398cb50857a3e2c981c2559f6 GIT binary patch literal 63314 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 KU?s8-gZ~fduNdh7 literal 0 HcmV?d00001 From ddc421b4b319e7d3d43ab59c1c0989b6dbe61fc5 Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Fri, 1 Nov 2024 22:22:27 +0100 Subject: [PATCH 07/12] [Added] Start of the DataService (and data collecting system) --- backend/Dockerfile | 7 +- backend/pom.xml | 8 +- .../com/ninjaws/quiz/models/Question.java | 10 ++ .../quiz/repositories/QuestionRepository.java | 8 ++ .../ninjaws/quiz/services/DataService.java | 27 +++++ .../services/ResponseHandlingService.java | 3 + .../quiz/services/SchedulerService.java | 2 + .../ninjaws/quiz/services/StorageService.java | 5 - .../src/main/resources/application.properties | 12 +- .../repositories/QuestRepositoryTest.java | 91 +++++++++++++++ .../quiz/services/CacheServiceTest.java | 59 ++++++++++ .../quiz/services/CleanerServiceTest.java | 58 ++++++++++ .../quiz/services/DataServiceTest.java | 105 ++++++++++++++++++ .../quiz/services/QueueServiceTest.java | 63 +++++++++++ 14 files changed, 443 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/DataService.java delete mode 100644 backend/src/main/java/com/ninjaws/quiz/services/StorageService.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java diff --git a/backend/Dockerfile b/backend/Dockerfile index fd634e1..14463aa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,11 +22,8 @@ CMD ["tail","-f","/dev/null"] #---Building FROM base AS build COPY . . -RUN mvn clean package -DskipTests - -#--Testing -FROM build AS testing -RUN mvn clean test +RUN mvn clean package +# Add -DskipTests for slightly faster build, but no testing #---Production # Pulled again to get rid of maven, to decrease the size diff --git a/backend/pom.xml b/backend/pom.xml index ac725c2..a1fe9a5 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -32,10 +32,10 @@ 3.1.8 - + org.springframework.boot spring-boot-starter-web @@ -47,11 +47,11 @@ runtime true - + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Question.java b/backend/src/main/java/com/ninjaws/quiz/models/Question.java index 36ee786..6c0a32d 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/Question.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/Question.java @@ -7,10 +7,20 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.*; import lombok.Getter; +import lombok.Setter; +@Entity +@Table(name = "question") @Getter +@Setter public class Question { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String type; private String difficulty; private String category; diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java new file mode 100644 index 0000000..31e331a --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java @@ -0,0 +1,8 @@ +package com.ninjaws.quiz.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ninjaws.quiz.models.Question; + +public interface QuestionRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java new file mode 100644 index 0000000..ceefcd0 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java @@ -0,0 +1,27 @@ +package com.ninjaws.quiz.services; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.Question; +import com.ninjaws.quiz.repositories.QuestionRepository; + +@Service +public class DataService { + @Autowired + private QuestionRepository questionRepository; + + public List getAllQuestions() { + return questionRepository.findAll(); + } + + public Question saveQuestion(Question question) { + return questionRepository.save(question); + } + + public List saveQuestions(List questions) { + return questionRepository.saveAll(questions); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java index af391f0..07abae9 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -11,6 +11,8 @@ public class ResponseHandlingService { @Autowired private CacheService cacheService; + @Autowired + private DataService dataService; /** * If there is no sessionId, then this is a request from the scheduler @@ -62,6 +64,7 @@ public class ResponseHandlingService { switch (response.getResponseCode()) { case 0: // TODO: Store in DB + dataService.saveQuestions(response.getResults()); break; case 4: // Signal DataService that we have extracted all the data. Stop gathering, switch to caching-mode diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java index a57f3c9..41b7dc4 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -27,6 +27,8 @@ public class SchedulerService { Request request = queueService.popFromQueue(); if (request != null) { apiService.handleRequest(request); + } else { + apiService.collectBulk(); } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/StorageService.java b/backend/src/main/java/com/ninjaws/quiz/services/StorageService.java deleted file mode 100644 index d670143..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/services/StorageService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.ninjaws.quiz.services; - -public class StorageService { - -} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 8d23e20..5fa94c6 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,2 +1,12 @@ spring.application.name=quiz -server.port=9090 \ No newline at end of file +server.port=9090 + +# H2 Database configuration +spring.h2.console.enabled=true +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java b/backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java new file mode 100644 index 0000000..a11b441 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java @@ -0,0 +1,91 @@ +package com.ninjaws.quiz.repositories; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.annotation.Rollback; + +import com.ninjaws.quiz.models.Question; + +@DataJpaTest +public class QuestRepositoryTest { + @Autowired + private QuestionRepository questionRepository; + + private Question question; + + @BeforeEach + public void setUp() { + question = new Question(); + question.setType("multiple choice"); + question.setDifficulty("easy"); + question.setCategory("general knowledge"); + question.setQuestion("What is the capital of France?"); + question.setCorrectAnswer("Paris"); + question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + } + + @Test + @Rollback(false) + public void testSaveQuestion() { + Question savedQuestion = questionRepository.save(question); + assertThat(savedQuestion).isNotNull(); + assertThat(savedQuestion.getId()).isGreaterThan(1); + } + + @Test + public void testFindQuestionById() { + questionRepository.save(question); + Optional foundQuestion = questionRepository.findById(question.getId()); + assertThat(foundQuestion).isPresent(); + assertThat(foundQuestion.get().getQuestion()).isEqualTo(question.getQuestion()); + } + + @Test + public void testFindAllQuestions() { + questionRepository.save(question); + List questions = questionRepository.findAll(); + assertThat(questions).isNotEmpty(); + } + + @Test + public void testDeleteQuestion() { + questionRepository.save(question); + Long id = question.getId(); + questionRepository.deleteById(id); + Optional deletedQuestion = questionRepository.findById(id); + assertThat(deletedQuestion).isNotPresent(); + } + + @Test + @Rollback(false) + public void testSaveQuestions() { + Question question1 = new Question(); + question1.setType("multiple choice"); + question1.setDifficulty("easy"); + question1.setCategory("general knowledge"); + question1.setQuestion("What is the capital of France?"); + question1.setCorrectAnswer("Paris"); + question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + + Question question2 = new Question(); + question2.setType("multiple choice"); + question2.setDifficulty("medium"); + question2.setCategory("science"); + question2.setQuestion("What is the chemical symbol for water?"); + question2.setCorrectAnswer("H2O"); + question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); + + List savedQuestions = questionRepository.saveAll(Arrays.asList(question1, question2)); + + assertThat(savedQuestions).hasSize(2); + assertThat(savedQuestions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); + assertThat(savedQuestions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); + } +} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java new file mode 100644 index 0000000..63570e8 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java @@ -0,0 +1,59 @@ +package com.ninjaws.quiz.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.ninjaws.quiz.models.Session; + +public class CacheServiceTest { + + private CacheService cacheService; + + @BeforeEach + public void setUp() { + cacheService = new CacheService(); + } + + @Test + public void testAddToCache() { + String key = "session1"; + Session session = new Session(); + + cacheService.addToCache(key, session); + + assertNotNull(cacheService.getFromCache(key)); + assertEquals(session, cacheService.getFromCache(key)); + } + + @Test + public void testGetFromCache() { + String key = "session1"; + Session session = new Session(); + + cacheService.addToCache(key, session); + + Session retrievedSession = cacheService.getFromCache(key); + assertNotNull(retrievedSession); + assertEquals(session, retrievedSession); + } + + @Test + public void testRemoveFromCache() { + String key = "session1"; + Session session = new Session(); + + cacheService.addToCache(key, session); + cacheService.removeFromCache(key); + + assertNull(cacheService.getFromCache(key)); + } + + @Test + public void testGetFromCacheWithNonExistentKey() { + assertNull(cacheService.getFromCache("nonExistentKey")); + } +} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java new file mode 100644 index 0000000..8c0f904 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java @@ -0,0 +1,58 @@ +package com.ninjaws.quiz.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.Question; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +public class CleanerServiceTest { + + @InjectMocks + private CleanerService cleanerService; + + @Mock + private ObjectMapper objectMapper; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testExtractApiResponse() throws IOException { + String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input + + Question question = new Question(); + question.setType("multiple choice"); + question.setDifficulty("easy"); + question.setCategory("general knowledge"); + question.setQuestion("What is the capital of France?"); + question.setCorrectAnswer("Paris"); + question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + + ApiResponse expectedResponse = new ApiResponse(); + expectedResponse.setResponseCode(200); + expectedResponse.setResults(Arrays.asList(question)); + when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); + + ApiResponse actualResponse = cleanerService.extractApiResponse(json); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); + assertThat(actualResponse.getResults()).isNotEmpty(); + assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); + verify(objectMapper, times(1)).readValue(json, ApiResponse.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java new file mode 100644 index 0000000..2fe0683 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java @@ -0,0 +1,105 @@ +package com.ninjaws.quiz.services; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.ninjaws.quiz.models.Question; +import com.ninjaws.quiz.repositories.QuestionRepository; + +public class DataServiceTest { + + @InjectMocks + private DataService dataService; + + @Mock + private QuestionRepository questionRepository; + + private Question question; + + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + question = new Question(); + question.setType("multiple choice"); + question.setDifficulty("easy"); + question.setCategory("general knowledge"); + question.setQuestion("What is the capital of France?"); + question.setCorrectAnswer("Paris"); + question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + } + + @Test + public void testSaveQuestion() { + when(questionRepository.save(any(Question.class))).thenReturn(question); + + Question savedQuestion = dataService.saveQuestion(question); + assertThat(savedQuestion).isNotNull(); + assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); + verify(questionRepository, times(1)).save(question); + } + + @Test + public void testGetAllQuestions() { + when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); + + List questions = dataService.getAllQuestions(); + assertThat(questions).isNotEmpty(); + assertThat(questions.size()).isEqualTo(1); + assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); + verify(questionRepository, times(1)).findAll(); + } + + @Test + public void testFindQuestionById() { + when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); + + Optional foundQuestion = questionRepository.findById(1L); + assertThat(foundQuestion).isPresent(); + assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); + verify(questionRepository, times(1)).findById(1L); + } + + @Test + public void testSaveQuestions() { + Question question1 = new Question(); + question1.setType("multiple choice"); + question1.setDifficulty("easy"); + question1.setCategory("general knowledge"); + question1.setQuestion("What is the capital of France?"); + question1.setCorrectAnswer("Paris"); + question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + + Question question2 = new Question(); + question2.setType("multiple choice"); + question2.setDifficulty("medium"); + question2.setCategory("science"); + question2.setQuestion("What is the chemical symbol for water?"); + question2.setCorrectAnswer("H2O"); + question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); + + when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); + + List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); + + assertThat(questions).hasSize(2); + assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); + assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); + verify(questionRepository, times(1)).saveAll(anyList()); + } +} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java new file mode 100644 index 0000000..e921c04 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java @@ -0,0 +1,63 @@ +package com.ninjaws.quiz.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; + +public class QueueServiceTest { + + private QueueService queueService; + + @BeforeEach + public void setUp() { + queueService = new QueueService(); + } + + @Test + public void testAddToQueue() { + QuizSettings quizSettings = new QuizSettings(); + Request request = new Request("session1", quizSettings); + + boolean added = queueService.addToQueue(request); + assertTrue(added); + + for (int i = 0; i < 4; i++) { + added = queueService.addToQueue(new Request("session" + (i + 2), new QuizSettings())); + assertTrue(added); + } + + added = queueService.addToQueue(new Request("session6", new QuizSettings())); + assertFalse(added); + } + + @Test + public void testPopFromQueue() { + QuizSettings quizSettings1 = new QuizSettings(); + QuizSettings quizSettings2 = new QuizSettings(); + + Request request1 = new Request("session1", quizSettings1); + Request request2 = new Request("session2", quizSettings2); + + queueService.addToQueue(request1); + queueService.addToQueue(request2); + + Request poppedRequest = queueService.popFromQueue(); + assertEquals(request1, poppedRequest); + + poppedRequest = queueService.popFromQueue(); + assertEquals(request2, poppedRequest); + + assertNull(queueService.popFromQueue()); + } + + @Test + public void testPopFromEmptyQueue() { + assertNull(queueService.popFromQueue()); + } +} \ No newline at end of file From 121dff910dab4410617780e4572b4e272e57af5d Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Sat, 2 Nov 2024 20:02:36 +0100 Subject: [PATCH 08/12] [Added] Functionality for getting data from the database [NOTE: swapping seems to difficult for now, might have to go for a cache approach] --- backend/pom.xml | 5 + .../quiz/components/StartupRunner.java | 27 +++ .../ninjaws/quiz/config/DataSourceConfig.java | 46 ++++ .../com/ninjaws/quiz/entities/Category.java | 25 +++ .../ninjaws/quiz/entities/QuestionEntity.java | 36 ++++ .../com/ninjaws/quiz/models/ApiResponse.java | 2 +- .../ninjaws/quiz/models/CategoryResponse.java | 14 ++ .../{Question.java => QuestionDTO.java} | 14 +- .../com/ninjaws/quiz/models/QuizSettings.java | 50 +++-- .../java/com/ninjaws/quiz/models/Session.java | 2 +- .../quiz/repositories/CategoryRepository.java | 11 + .../quiz/repositories/QuestionRepository.java | 22 +- .../com/ninjaws/quiz/services/ApiService.java | 25 ++- .../ninjaws/quiz/services/CleanerService.java | 67 +++++- .../ninjaws/quiz/services/DataService.java | 46 +++- .../quiz/services/DatabaseScheduler.java | 20 ++ .../ninjaws/quiz/services/QuizService.java | 21 ++ .../services/ResponseHandlingService.java | 10 +- .../src/main/resources/application.properties | 34 ++- .../repositories/QuestRepositoryTest.java | 91 -------- .../quiz/services/CleanerServiceTest.java | 58 ----- .../quiz/services/CleanerServiceTest.txt | 107 ++++++++++ .../quiz/services/DataServiceTest.java | 105 --------- .../ninjaws/quiz/services/DataServiceTest.txt | 202 ++++++++++++++++++ .../src/app/Quiz/services/quiz.service.ts | 2 +- scripts/LoadTestMultiple.sh | 2 +- scripts/LoadTestOne-WithParams.sh | 46 ++++ scripts/LoadTestOne.sh | 3 +- .../benchmarks/{basic.png => 1. Queue.png} | Bin .../2. DB and Queue - No Params.png | Bin 0 -> 69550 bytes 30 files changed, 791 insertions(+), 302 deletions(-) create mode 100644 backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/entities/Category.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java rename backend/src/main/java/com/ninjaws/quiz/models/{Question.java => QuestionDTO.java} (88%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java delete mode 100644 backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java create mode 100644 backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt create mode 100755 scripts/LoadTestOne-WithParams.sh rename scripts/benchmarks/{basic.png => 1. Queue.png} (100%) create mode 100644 scripts/benchmarks/2. DB and Queue - No Params.png diff --git a/backend/pom.xml b/backend/pom.xml index a1fe9a5..2de204e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -72,6 +72,11 @@ org.springframework.boot spring-boot-starter-validation + + org.apache.commons + commons-text + 1.10.0 + diff --git a/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java new file mode 100644 index 0000000..1885a81 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java @@ -0,0 +1,27 @@ +package com.ninjaws.quiz.components; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Sets up the database, and adds the categories + * + * Fills up the primary database + * + * Then, at night, creates a new database, adds categories, and then starts filling it up + * When full, swaps the databases, and wipes the old one + */ +public class StartupRunner implements CommandLineRunner { + private final JdbcTemplate primaryJdbcTemplate; + + public StartupRunner(JdbcTemplate primaryJdbcTemplate) { + this.primaryJdbcTemplate = primaryJdbcTemplate; + } + + @Override + public void run(String... args) { + // Initialization logic here + primaryJdbcTemplate.execute("CREATE TABLE IF NOT EXISTS test_table (id INT PRIMARY KEY, name VARCHAR(255))"); + primaryJdbcTemplate.update("INSERT INTO test_table (id, name) VALUES (?, ?)", 1, "Sample Data"); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java new file mode 100644 index 0000000..8645af9 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java @@ -0,0 +1,46 @@ +package com.ninjaws.quiz.config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; + +public class DataSourceConfig { + @Primary + @Bean(name = "primaryDataSourceProperties") + @ConfigurationProperties("spring.datasource.primary") + public DataSourceProperties primaryDataSourceProperties() { + return new DataSourceProperties(); + } + + @Primary + @Bean(name = "primaryDataSource") + public DataSource primaryDataSource() { + return primaryDataSourceProperties().initializeDataSourceBuilder().build(); + } + + @Bean(name = "secondaryDataSourceProperties") + @ConfigurationProperties("spring.datasource.secondary") + public DataSourceProperties secondaryDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean(name = "secondaryDataSource") + public DataSource secondaryDataSource() { + return secondaryDataSourceProperties().initializeDataSourceBuilder().build(); + } + + @Bean + public JdbcTemplate primaryJdbcTemplate(@Qualifier("primaryDataSource") DataSource ds) { + return new JdbcTemplate(ds); + } + + @Bean + public JdbcTemplate secondaryJdbcTemplate(@Qualifier("secondaryDataSource") DataSource ds) { + return new JdbcTemplate(ds); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/Category.java b/backend/src/main/java/com/ninjaws/quiz/entities/Category.java new file mode 100644 index 0000000..3e20685 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/Category.java @@ -0,0 +1,25 @@ +package com.ninjaws.quiz.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "category") +@Getter +@Setter +public class Category { + @Id + private Integer id; + private String name; + + public Category() {} + + public Category(int id, String name) { + this.id = id; + this.name = name; + } + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java new file mode 100644 index 0000000..753254d --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java @@ -0,0 +1,36 @@ +package com.ninjaws.quiz.entities; + +import java.util.List; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "question") +@Getter +@Setter +public class QuestionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "category", nullable = false) + private Category category; + + private String difficulty; + private String type; + private String question; + + //* Hidden from the user */ + private String correctAnswer; + private List incorrectAnswers; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java b/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java index 776d0e0..9715b25 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java @@ -17,5 +17,5 @@ public class ApiResponse { @JsonProperty("response_code") private int responseCode; @JsonProperty("results") - private List results; + private List results; } diff --git a/backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java b/backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java new file mode 100644 index 0000000..2644a4b --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.models; + +import java.util.List; + +import com.ninjaws.quiz.entities.Category; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CategoryResponse { + List trivia_categories; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Question.java b/backend/src/main/java/com/ninjaws/quiz/models/QuestionDTO.java similarity index 88% rename from backend/src/main/java/com/ninjaws/quiz/models/Question.java rename to backend/src/main/java/com/ninjaws/quiz/models/QuestionDTO.java index 6c0a32d..4127152 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/Question.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuestionDTO.java @@ -7,25 +7,17 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; -@Entity -@Table(name = "question") @Getter @Setter -public class Question { +public class QuestionDTO { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String type; - private String difficulty; private String category; + private String difficulty; + private String type; private String question; - //* Hidden from the user */ private String correctAnswer; diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java index 9ed2ff4..cb82d5e 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -48,21 +48,25 @@ public class QuizSettings { @Max(50) private int amount = 10; - private Type type = Type.ANY; + // private Type type = Type.ANY; - private Difficulty difficulty = Difficulty.ANY; - - private Category category = Category.ANY; + // private Difficulty difficulty = Difficulty.ANY; + private Integer category; + private String difficulty; + private String type; public QuizSettings(){} /** Inverse of toApiQuery */ - public QuizSettings(int amount, Integer category, String difficulty, String type) { + public QuizSettings(int amount, Integer category, String difficulty, String type) { this.amount = amount; - this.category = category == null ? Category.ANY : Category.values()[category]; - this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); - this.type = type == null ? Type.ANY : type.equals("boolean") ? Type.BOOLEAN : Type.MULTIPLE; + this.category = category; + this.difficulty = difficulty; + this.type = type; + // this.category = category == null ? Category.ANY : Category.values()[category]; + // this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); + // this.type = type == null ? Type.ANY : type.equals("boolean") ? Type.BOOLEAN : Type.MULTIPLE; } /** @@ -77,19 +81,37 @@ public class QuizSettings { * Converts the class to a format that can be provided as arguments for the api request * @return The resulting request arguments as String */ + // public String toApiQuery() { + // StringBuilder query = new StringBuilder("?amount=" + amount); + + // if (category != null && category != Category.ANY) { + // query.append("&category=").append(category.getApiValue()); + // } + + // if (difficulty != null && difficulty != Difficulty.ANY) { + // query.append("&difficulty=").append(difficulty.getApiValue()); + // } + + // if (type != null && type != Type.ANY) { + // query.append("&type=").append(type.getApiValue()); + // } + + // return query.toString(); + // } + public String toApiQuery() { StringBuilder query = new StringBuilder("?amount=" + amount); - if (category != null && category != Category.ANY) { - query.append("&category=").append(category.getApiValue()); + if (category != null) { + query.append("&category=").append(category); } - if (difficulty != null && difficulty != Difficulty.ANY) { - query.append("&difficulty=").append(difficulty.getApiValue()); + if (difficulty != null ) { + query.append("&difficulty=").append(difficulty); } - if (type != null && type != Type.ANY) { - query.append("&type=").append(type.getApiValue()); + if (type != null ) { + query.append("&type=").append(type); } return query.toString(); diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Session.java b/backend/src/main/java/com/ninjaws/quiz/models/Session.java index 757467d..2916d6c 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/Session.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/Session.java @@ -11,7 +11,7 @@ import lombok.Setter; public class Session { private final String id; private int statusCode = 0; - private List questions; + private List questions; public Session() { this.id = UUID.randomUUID().toString(); diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java new file mode 100644 index 0000000..e9bf67d --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java @@ -0,0 +1,11 @@ +package com.ninjaws.quiz.repositories; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ninjaws.quiz.entities.Category; + +public interface CategoryRepository extends JpaRepository{ + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java index 31e331a..4ec9de2 100644 --- a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java @@ -1,8 +1,26 @@ package com.ninjaws.quiz.repositories; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -import com.ninjaws.quiz.models.Question; +import com.ninjaws.quiz.entities.QuestionEntity; -public interface QuestionRepository extends JpaRepository { +public interface QuestionRepository extends JpaRepository{//, JpaSpecificationExecutor { + + @Query(value = "SELECT * FROM question AS q " + + "WHERE (?1 IS NULL OR q.category = ?1) " + + "AND (?2 IS NULL OR q.difficulty = ?2) " + + "AND (?3 IS NULL OR q.type = ?3) " + + "ORDER BY RAND() LIMIT ?4", nativeQuery = true) + List lookupQuestions(Integer categoryId, String difficulty, String type, int limit); + + // public List getQuestionsBy(String type, String category, String difficulty, int limit) { + // Specification spec = Specification.where(QuestionSpecification.hasType(type)) + // .and(QuestionSpecification.hasCategory(category)) + // .and(QuestionSpecification.hasDifficulty(difficulty)); + + // return productRepository.findAll(spec); + // } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index 7918f4f..219e6fb 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -9,6 +9,7 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.CategoryResponse; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Request; @@ -26,13 +27,13 @@ public class ApiService { private static final System.Logger logger = System.getLogger(ApiService.class.getName()); - private final String apiUrl = "https://opentdb.com/api.php"; + private final String apiUrl = "https://opentdb.com"; public ApiService() { } private String buildRequestString(QuizSettings quizSettings) { - return apiUrl + quizSettings.toApiQuery(); + return apiUrl + "/api.php" + quizSettings.toApiQuery(); } public void handleRequest(@Valid Request request) { @@ -54,7 +55,9 @@ public class ApiService { * Store them in the DataService */ public void collectBulk() { - String url = apiUrl + "?amount=50"; + // TODO: Make this happen only once, when a new database is generated + getCategories(); + String url = apiUrl + "/api.php?amount=50"; try { ResponseEntity response = restTemplate.getForEntity(url, String.class); String json = response.getBody(); @@ -64,5 +67,21 @@ public class ApiService { } catch (RestClientException | IOException e) { logger.log(System.Logger.Level.ERROR, "Critical failure", e); } + + } + + + public void getCategories() { + String url = apiUrl + "/api_category.php"; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + CategoryResponse categoryResponse = cleanerService.extractCategoryResponse(json); + responseHandler.handleCategoryResponse(categoryResponse); + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java index 5385f67..9c08359 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -1,22 +1,87 @@ package com.ninjaws.quiz.services; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.lang3.StringEscapeUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionEntity; import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.CategoryResponse; +import com.ninjaws.quiz.models.QuestionDTO; +import com.ninjaws.quiz.repositories.CategoryRepository; @Service public class CleanerService { @Autowired private ObjectMapper objectMapper; + @Autowired + private CategoryRepository categoryRepository; - public CleanerService() {} + public CleanerService() { + } public ApiResponse extractApiResponse(String json) throws IOException { return objectMapper.readValue(json, ApiResponse.class); } + + public CategoryResponse extractCategoryResponse(String json) throws IOException { + return objectMapper.readValue(json, CategoryResponse.class); + } + + public List questionListEntitytoDTO(List entities) { + List dtos = new ArrayList<>(); + for (int i = 0; i < entities.size(); i++) { + QuestionEntity entity = entities.get(i); + dtos.add(questionEntityToDTO(entity)); + } + return dtos; + } + + public QuestionDTO questionEntityToDTO(QuestionEntity entity) { + QuestionDTO dto = new QuestionDTO(); + dto.setQuestion(entity.getQuestion()); + dto.setCorrectAnswer(entity.getCorrectAnswer()); + dto.setIncorrectAnswer(entity.getIncorrectAnswers()); + dto.setCategory(entity.getCategory().getName()); + dto.setType(entity.getType()); + dto.setDifficulty(entity.getDifficulty()); + return dto; + } + + public List questionListDTOtoEntity(List dtos) { + List entities = new ArrayList<>(); + for (int i = 0; i < dtos.size(); i++) { + QuestionDTO dto = dtos.get(i); + entities.add(questionDTOtoEntity(dto)); + } + return entities; + } + + public QuestionEntity questionDTOtoEntity(QuestionDTO dto) { + QuestionEntity entity = new QuestionEntity(); + Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); + entity.setCategory(category); + entity.setDifficulty(dto.getDifficulty()); + entity.setType(dto.getType()); + entity.setQuestion(dto.getQuestion()); + entity.setCorrectAnswer(dto.getCorrectAnswer()); + entity.setIncorrectAnswers(dto.getIncorrectAnswers()); + return entity; + } + + /** + * For some reason, the categories come with special characters, + * But the categories from the questions dont + * So this has to be manually fixed if they are to be stored in the db (otherwise namematching doesnt work) + */ + private String decodeString(String text){ + return StringEscapeUtils.unescapeHtml4(text); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java index ceefcd0..adb2469 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java @@ -5,23 +5,61 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.ninjaws.quiz.models.Question; +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionEntity; +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.repositories.CategoryRepository; import com.ninjaws.quiz.repositories.QuestionRepository; @Service public class DataService { + @Autowired private QuestionRepository questionRepository; - public List getAllQuestions() { + @Autowired + private CategoryRepository categoryRepository; + + public List getAllQuestions() { return questionRepository.findAll(); } - public Question saveQuestion(Question question) { + public QuestionEntity saveQuestion(QuestionEntity question) { return questionRepository.save(question); } - public List saveQuestions(List questions) { + public List saveQuestions(List questions) { return questionRepository.saveAll(questions); } + + // public List getRandomQuestions(String type, String category, String difficulty, int limit) { + // return questionRepository.findRandomQuestions(type, limit); + // } + + public List lookupQuestions(QuizSettings quizSettings) { + // Long category = Long.valueOf(quizSettings.getCategory().getApiValue() == 1 ? null : quizSettings.getCategory().getApiValue()); + // String difficulty = quizSettings.getDifficulty() == QuizSettings.Difficulty.ANY ? null : quizSettings.getDifficulty().getApiValue(); + // String type = quizSettings.getType() == QuizSettings.Type.ANY ? null : quizSettings.getType().getApiValue(); + // System.out.println(category); + // System.out.println(difficulty); + // System.out.println(type); + + return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount()); + } + + /** + * Tries to get questions that match the specified QuizSettings + * @return The questions if the query was possible, and an empty array if not + */ + // public List getByQuizSettings(QuizSettings quizSettings) { + // Specification spec = Specification.where(QuestionSpecification.hasType(type)) + // .and(QuestionSpecification.hasCategory(category)) + // .and(QuestionSpecification.hasDifficulty(difficulty)); + + // return productRepository.findAll(spec); + // } + + public List saveCategories(List categories) { + return categoryRepository.saveAll(categories); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java b/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java new file mode 100644 index 0000000..83686c1 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java @@ -0,0 +1,20 @@ +package com.ninjaws.quiz.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +public class DatabaseScheduler { + @Autowired + private JdbcTemplate secondaryJdbcTemplate; + + @Scheduled(fixedRate = 60000) // Runs every 60 seconds + public void createAndFillSecondaryDatabase() { + // Create tables and insert data into the secondary database + secondaryJdbcTemplate. + .execute("CREATE TABLE IF NOT EXISTS sample_table (id INT PRIMARY KEY, name VARCHAR(255))"); + secondaryJdbcTemplate.update("INSERT INTO sample_table (id, name) VALUES (?, ?)", 1, "Sample Data"); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index 0b22c30..d511850 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -1,10 +1,12 @@ package com.ninjaws.quiz.services; +import java.util.List; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import com.ninjaws.quiz.entities.QuestionEntity; import com.ninjaws.quiz.models.Answers; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Request; @@ -18,6 +20,10 @@ public class QuizService { private CacheService cacheService; @Autowired private QueueService queueService; + @Autowired + private DataService dataService; + @Autowired + private CleanerService cleanerService; /** * Actions: @@ -29,7 +35,22 @@ public class QuizService { */ public Optional createSession(QuizSettings quizSettings) { Session session = new Session(); + + /** First, we check if there are already enough items in our db */ + List questionsRetrieved = dataService.lookupQuestions(quizSettings); + if(!questionsRetrieved.isEmpty()) { + session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved)); + } + cacheService.addToCache(session.getId(), session); + + /** + * If the questions were retrieved from the DB, then it doesn't need to be added to the queue + */ + if(!questionsRetrieved.isEmpty()) { + return Optional.of(session.getId()); + } + boolean addedToQueue = queueService.addToQueue(new Request(session.getId(), quizSettings)); /** In this order, because doing the queue before the cache could lead to race conditions (request completion immediately calls cache, but then the item doesn't exist) */ if(!addedToQueue) { diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java index 07abae9..96b2dd0 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.CategoryResponse; import com.ninjaws.quiz.models.Session; @Service @@ -13,6 +14,8 @@ public class ResponseHandlingService { private CacheService cacheService; @Autowired private DataService dataService; + @Autowired + private CleanerService cleanerService; /** * If there is no sessionId, then this is a request from the scheduler @@ -30,6 +33,7 @@ public class ResponseHandlingService { case 0: // Success updateSessionInCache(session, response); + dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); break; case 1: // Not enough questions to satisfy the request. @@ -63,8 +67,7 @@ public class ResponseHandlingService { public void handleResponse(ApiResponse response) { switch (response.getResponseCode()) { case 0: - // TODO: Store in DB - dataService.saveQuestions(response.getResults()); + dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); break; case 4: // Signal DataService that we have extracted all the data. Stop gathering, switch to caching-mode @@ -83,4 +86,7 @@ public class ResponseHandlingService { cacheService.addToCache(session.getId(), session); } + public void handleCategoryResponse(CategoryResponse categoryResponse) { + dataService.saveCategories(categoryResponse.getTrivia_categories()); + } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 5fa94c6..f7d952d 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -3,10 +3,32 @@ server.port=9090 # H2 Database configuration spring.h2.console.enabled=true -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= +# spring.h2.console.path=/console -spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file + +# spring.datasource.url=jdbc:h2:mem:testdb +# spring.datasource.driverClassName=org.h2.Driver +# spring.datasource.username=sa +# spring.datasource.password= + +# spring.jpa.hibernate.ddl-auto=create-drop +# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + + +# Primary database configuration +spring.datasource.primary.url=jdbc:h2:mem:testdb +# ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.primary.driver-class-name=org.h2.Driver +spring.datasource.primary.username=sa +spring.datasource.primary.password= +spring.jpa.primary.hibernate.ddl-auto=create-drop +spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect + +# Secondary database configuration +spring.datasource.secondary.url=jdbc:h2:mem:secondarydb +# ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.secondary.driver-class-name=org.h2.Driver +spring.datasource.secondary.username=sa +spring.datasource.secondary.password= +spring.jpa.secondary.hibernate.ddl-auto=create-drop +spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java b/backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java deleted file mode 100644 index a11b441..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/repositories/QuestRepositoryTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.ninjaws.quiz.repositories; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.annotation.Rollback; - -import com.ninjaws.quiz.models.Question; - -@DataJpaTest -public class QuestRepositoryTest { - @Autowired - private QuestionRepository questionRepository; - - private Question question; - - @BeforeEach - public void setUp() { - question = new Question(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory("general knowledge"); - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - } - - @Test - @Rollback(false) - public void testSaveQuestion() { - Question savedQuestion = questionRepository.save(question); - assertThat(savedQuestion).isNotNull(); - assertThat(savedQuestion.getId()).isGreaterThan(1); - } - - @Test - public void testFindQuestionById() { - questionRepository.save(question); - Optional foundQuestion = questionRepository.findById(question.getId()); - assertThat(foundQuestion).isPresent(); - assertThat(foundQuestion.get().getQuestion()).isEqualTo(question.getQuestion()); - } - - @Test - public void testFindAllQuestions() { - questionRepository.save(question); - List questions = questionRepository.findAll(); - assertThat(questions).isNotEmpty(); - } - - @Test - public void testDeleteQuestion() { - questionRepository.save(question); - Long id = question.getId(); - questionRepository.deleteById(id); - Optional deletedQuestion = questionRepository.findById(id); - assertThat(deletedQuestion).isNotPresent(); - } - - @Test - @Rollback(false) - public void testSaveQuestions() { - Question question1 = new Question(); - question1.setType("multiple choice"); - question1.setDifficulty("easy"); - question1.setCategory("general knowledge"); - question1.setQuestion("What is the capital of France?"); - question1.setCorrectAnswer("Paris"); - question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - Question question2 = new Question(); - question2.setType("multiple choice"); - question2.setDifficulty("medium"); - question2.setCategory("science"); - question2.setQuestion("What is the chemical symbol for water?"); - question2.setCorrectAnswer("H2O"); - question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); - - List savedQuestions = questionRepository.saveAll(Arrays.asList(question1, question2)); - - assertThat(savedQuestions).hasSize(2); - assertThat(savedQuestions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(savedQuestions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); - } -} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java deleted file mode 100644 index 8c0f904..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.ninjaws.quiz.services; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.models.ApiResponse; -import com.ninjaws.quiz.models.Question; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import java.io.IOException; -import java.util.Arrays; -import java.util.List; - -public class CleanerServiceTest { - - @InjectMocks - private CleanerService cleanerService; - - @Mock - private ObjectMapper objectMapper; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - public void testExtractApiResponse() throws IOException { - String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input - - Question question = new Question(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory("general knowledge"); - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - ApiResponse expectedResponse = new ApiResponse(); - expectedResponse.setResponseCode(200); - expectedResponse.setResults(Arrays.asList(question)); - when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); - - ApiResponse actualResponse = cleanerService.extractApiResponse(json); - assertThat(actualResponse).isNotNull(); - assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); - assertThat(actualResponse.getResults()).isNotEmpty(); - assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); - verify(objectMapper, times(1)).readValue(json, ApiResponse.class); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt new file mode 100644 index 0000000..bdb7919 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt @@ -0,0 +1,107 @@ +package com.ninjaws.quiz.services; + +import java.io.IOException; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.QuestionDTO; + +// public class CleanerServiceTest { + +// @InjectMocks +// private CleanerService cleanerService; + +// @Mock +// private ObjectMapper objectMapper; + +// @BeforeEach +// public void setUp() { +// MockitoAnnotations.openMocks(this); +// } + +// @Test +// public void testExtractApiResponse() throws IOException { +// String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input + +// Question question = new Question(); +// question.setType("multiple choice"); +// question.setDifficulty("easy"); +// question.setCategory("general knowledge"); +// question.setQuestion("What is the capital of France?"); +// question.setCorrectAnswer("Paris"); +// question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + +// ApiResponse expectedResponse = new ApiResponse(); +// expectedResponse.setResponseCode(200); +// expectedResponse.setResults(Arrays.asList(question)); +// when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); + +// ApiResponse actualResponse = cleanerService.extractApiResponse(json); +// assertThat(actualResponse).isNotNull(); +// assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); +// assertThat(actualResponse.getResults()).isNotEmpty(); +// assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); +// verify(objectMapper, times(1)).readValue(json, ApiResponse.class); +// } +// } + +@ExtendWith(MockitoExtension.class) +public class CleanerServiceTest { + + @InjectMocks + private CleanerService cleanerService; + + @Mock + private ObjectMapper objectMapper; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testExtractApiResponse() throws IOException { + String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input + + // Create a Category object for the question + Category category = new Category(); + category.setId(1L); + category.setName("General Knowledge"); + + // Create the Question object with the category + QuestionDTO question = new QuestionDTO(); + question.setType("multiple choice"); + question.setDifficulty("easy"); + question.setCategory(category); // Set the Category object here + question.setQuestion("What is the capital of France?"); + question.setCorrectAnswer("Paris"); + question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + + ApiResponse expectedResponse = new ApiResponse(); + expectedResponse.setResponseCode(200); + expectedResponse.setResults(Arrays.asList(question)); + when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); + + ApiResponse actualResponse = cleanerService.extractApiResponse(json); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); + assertThat(actualResponse.getResults()).isNotEmpty(); + assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); + assertThat(actualResponse.getResults().get(0).getCategory().getName()).isEqualTo(category.getName()); // Check category name + verify(objectMapper, times(1)).readValue(json, ApiResponse.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java deleted file mode 100644 index 2fe0683..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.ninjaws.quiz.services; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import com.ninjaws.quiz.models.Question; -import com.ninjaws.quiz.repositories.QuestionRepository; - -public class DataServiceTest { - - @InjectMocks - private DataService dataService; - - @Mock - private QuestionRepository questionRepository; - - private Question question; - - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - - question = new Question(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory("general knowledge"); - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - } - - @Test - public void testSaveQuestion() { - when(questionRepository.save(any(Question.class))).thenReturn(question); - - Question savedQuestion = dataService.saveQuestion(question); - assertThat(savedQuestion).isNotNull(); - assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); - verify(questionRepository, times(1)).save(question); - } - - @Test - public void testGetAllQuestions() { - when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); - - List questions = dataService.getAllQuestions(); - assertThat(questions).isNotEmpty(); - assertThat(questions.size()).isEqualTo(1); - assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - verify(questionRepository, times(1)).findAll(); - } - - @Test - public void testFindQuestionById() { - when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); - - Optional foundQuestion = questionRepository.findById(1L); - assertThat(foundQuestion).isPresent(); - assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); - verify(questionRepository, times(1)).findById(1L); - } - - @Test - public void testSaveQuestions() { - Question question1 = new Question(); - question1.setType("multiple choice"); - question1.setDifficulty("easy"); - question1.setCategory("general knowledge"); - question1.setQuestion("What is the capital of France?"); - question1.setCorrectAnswer("Paris"); - question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - Question question2 = new Question(); - question2.setType("multiple choice"); - question2.setDifficulty("medium"); - question2.setCategory("science"); - question2.setQuestion("What is the chemical symbol for water?"); - question2.setCorrectAnswer("H2O"); - question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); - - when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); - - List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); - - assertThat(questions).hasSize(2); - assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); - verify(questionRepository, times(1)).saveAll(anyList()); - } -} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt new file mode 100644 index 0000000..faa94e2 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt @@ -0,0 +1,202 @@ +package com.ninjaws.quiz.services; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.models.QuestionDTO; +import com.ninjaws.quiz.repositories.QuestionRepository; + +// public class DataServiceTest { + +// @InjectMocks +// private DataService dataService; + +// @Mock +// private QuestionRepository questionRepository; + +// private Question question; + + +// @BeforeEach +// public void setUp() { +// MockitoAnnotations.openMocks(this); + +// question = new Question(); +// question.setType("multiple choice"); +// question.setDifficulty("easy"); +// question.setCategory("general knowledge"); +// question.setQuestion("What is the capital of France?"); +// question.setCorrectAnswer("Paris"); +// question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); +// } + +// @Test +// public void testSaveQuestion() { +// when(questionRepository.save(any(Question.class))).thenReturn(question); + +// Question savedQuestion = dataService.saveQuestion(question); +// assertThat(savedQuestion).isNotNull(); +// assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); +// verify(questionRepository, times(1)).save(question); +// } + +// @Test +// public void testGetAllQuestions() { +// when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); + +// List questions = dataService.getAllQuestions(); +// assertThat(questions).isNotEmpty(); +// assertThat(questions.size()).isEqualTo(1); +// assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); +// verify(questionRepository, times(1)).findAll(); +// } + +// @Test +// public void testFindQuestionById() { +// when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); + +// Optional foundQuestion = questionRepository.findById(1L); +// assertThat(foundQuestion).isPresent(); +// assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); +// verify(questionRepository, times(1)).findById(1L); +// } + +// @Test +// public void testSaveQuestions() { +// Question question1 = new Question(); +// question1.setType("multiple choice"); +// question1.setDifficulty("easy"); +// question1.setCategory("general knowledge"); +// question1.setQuestion("What is the capital of France?"); +// question1.setCorrectAnswer("Paris"); +// question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + +// Question question2 = new Question(); +// question2.setType("multiple choice"); +// question2.setDifficulty("medium"); +// question2.setCategory("science"); +// question2.setQuestion("What is the chemical symbol for water?"); +// question2.setCorrectAnswer("H2O"); +// question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); + +// when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); + +// List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); + +// assertThat(questions).hasSize(2); +// assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); +// assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); +// verify(questionRepository, times(1)).saveAll(anyList()); +// } +// } + +@ExtendWith(MockitoExtension.class) +public class DataServiceTest { + + @InjectMocks + private DataService dataService; + + @Mock + private QuestionRepository questionRepository; + + private QuestionDTO question; + private Category category; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + category = new Category(); + category.setId(1L); + category.setName("General Knowledge"); + + question = new QuestionDTO(); + question.setType("multiple choice"); + question.setDifficulty("easy"); + question.setCategory(category); // Set the Category object here + question.setQuestion("What is the capital of France?"); + question.setCorrectAnswer("Paris"); + question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + } + + @Test + public void testSaveQuestion() { + when(questionRepository.save(any(QuestionDTO.class))).thenReturn(question); + + QuestionDTO savedQuestion = dataService.saveQuestion(question); + assertThat(savedQuestion).isNotNull(); + assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); + assertThat(savedQuestion.getCategory().getName()).isEqualTo("General Knowledge"); // Check category name + verify(questionRepository, times(1)).save(question); + } + + @Test + public void testGetAllQuestions() { + when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); + + List questions = dataService.getAllQuestions(); + assertThat(questions).isNotEmpty(); + assertThat(questions.size()).isEqualTo(1); + assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); + assertThat(questions.get(0).getCategory().getName()).isEqualTo("General Knowledge"); // Check category name + verify(questionRepository, times(1)).findAll(); + } + + @Test + public void testFindQuestionById() { + when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); + + Optional foundQuestion = questionRepository.findById(1L); + assertThat(foundQuestion).isPresent(); + assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); + assertThat(foundQuestion.get().getCategory().getName()).isEqualTo("General Knowledge"); // Check category name + verify(questionRepository, times(1)).findById(1L); + } + + @Test + public void testSaveQuestions() { + QuestionDTO question1 = new QuestionDTO(); + question1.setType("multiple choice"); + question1.setDifficulty("easy"); + question1.setCategory(category); // Set the Category object here + question1.setQuestion("What is the capital of France?"); + question1.setCorrectAnswer("Paris"); + question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); + + QuestionDTO question2 = new QuestionDTO(); + Category category2 = new Category(); + category2.setId(2L); + category2.setName("Science"); + question2.setType("multiple choice"); + question2.setDifficulty("medium"); + question2.setCategory(category2); // Set the Category object here + question2.setQuestion("What is the chemical symbol for water?"); + question2.setCorrectAnswer("H2O"); + question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); + + when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); + + List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); + + assertThat(questions).hasSize(2); + assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); + assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); + verify(questionRepository, times(1)).saveAll(anyList()); + } +} diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts index 5be7448..184570f 100644 --- a/frontend/src/app/Quiz/services/quiz.service.ts +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -71,7 +71,7 @@ export class QuizService { url += catIndex !== 0 ? `&category=${catIndex}` : ''; const diff = quizParams.difficulty.toLowerCase(); - url += diff !== 'any difficulty' ? `&category=${diff}` : ''; + url += diff !== 'any difficulty' ? `&difficulty=${diff}` : ''; const typeIndex = this.getTypes().indexOf(quizParams.type); url += diff --git a/scripts/LoadTestMultiple.sh b/scripts/LoadTestMultiple.sh index 9122900..352f191 100755 --- a/scripts/LoadTestMultiple.sh +++ b/scripts/LoadTestMultiple.sh @@ -3,7 +3,7 @@ start_time=$(date +%s) for i in {1..6}; do - ./LoadTestOne.sh & + ./LoadTestOne-WithParams.sh & sleep 0.1 done diff --git a/scripts/LoadTestOne-WithParams.sh b/scripts/LoadTestOne-WithParams.sh new file mode 100755 index 0000000..f23ce00 --- /dev/null +++ b/scripts/LoadTestOne-WithParams.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=25&category=27&difficulty=medium&type=boolean") + +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is not 0." + exit 1 +fi + +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + echo "Failed to get sessionId" + exit 1 +fi + +echo "Received sessionId: $sessionId" + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") + + if [ $? -eq 0 ]; then + + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is $statusCode" + exit 1 + fi + + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then +# echo "$statusCode" + echo "$sessionId: done! status: $statusCode" + break + fi + else + echo "Status check failed, retrying..." + fi + + sleep 2 +done diff --git a/scripts/LoadTestOne.sh b/scripts/LoadTestOne.sh index 1137ee1..feb32eb 100755 --- a/scripts/LoadTestOne.sh +++ b/scripts/LoadTestOne.sh @@ -30,7 +30,8 @@ while true; do break fi else - echo "Status check failed, retrying..." + echo "Status check failed, exiting..." + exit 1 fi sleep 2 diff --git a/scripts/benchmarks/basic.png b/scripts/benchmarks/1. Queue.png similarity index 100% rename from scripts/benchmarks/basic.png rename to scripts/benchmarks/1. Queue.png diff --git a/scripts/benchmarks/2. DB and Queue - No Params.png b/scripts/benchmarks/2. DB and Queue - No Params.png new file mode 100644 index 0000000000000000000000000000000000000000..d2a3811bde77f04a2c0bbdc9d512b61410a48c53 GIT binary patch literal 69550 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

Uv61U}PcArwG_YUI|l9C+;I_ame*qX7o|Bynv z7j1>-NeRlc_GDO+KD_G~noTA(vfILLZLkoYtrHViVUhHB!TPpP0_1Anu*?uJZr4{@0ik*(KzUt>1BUhhFLQ3x(`_5K(bcsEX3QIkL&3YaoOC$C*kMQ6ft(V@#UtuhS;(~ zhS{QdY&sBh_laZbw+pekpPcMV+NoA!k;KYbcg0AX8=C7-o6;S1WJ0$-o+LJA5NAL` zU0di#GQB@Dp5P!b74;$o!O+-gRG$>uZ+fz~l*0S)I>kYzQj+t##yu5@*FrhGERaU@ zsBca1#AbMG#UotLi4ybqaHQTxm|Tv9=`7E`y)0%)^EuN-J5S)O?l%U`hCZJ=BYIUb z#L9JXz7S&bzIe@D9(^Xu6d~3YT!sdFH$jNVm9Bt(DwB) zL-H)r&6-A2?~d~I`Ne)V3i7ehOw#sDZ3|$2*>`63Bt3YQ_6tn6dS}Jw8We!H@i_|R z)(5QeT#L|uq{#f5M(JrOOXMHTebzCh95-naHAQ+d)t#%7+_d2Tvja1QxM1t0;SOi~ z*Q0I|r=+P*Ec2pNPl;zp9_$y>0tDWy20jNNH{FUo7|-&wR7ntNiC3tFgAvVWJ~U(NoUef za4o}1VoBo9?2Vg05QXvB!y%4s{dSX(3A`7I%>Rg3 zWVL6Xgphq;L}<^&E2`-gKUo{&l}jm=KSo}-Q-q7$+f#Ql082UDspBN zN{M~v)c4TE7dm*RR7;ffsSrF%ujBJn_tzf|zys;;96%#TNNM zun@5QwuB#x1T{(67`i@TDxQ*VDEueYoAtr zJyJBM|8jz2n22<^*1z4Y{yNx11Et;3w=svj6NAsMGe4oWxrgufrg%P1tf?c_+moU> z03X`1cc$m%q7!$mHMS+4N-4^#4Ff~VN~Q_9-ahfkw#?-dvt4cC@thE%o+xjlxufe| zdLn;%KbJ9jH6ZxfCa+eg^11=Y-7PT6S;>w&|1o3nxH^1v8mkF?R7zCN+MjA?`m9zl z2<1H%qUB}wQ z8Ball%mx>jOzepP8Vnx7SW5m&Kxt3m^;~C-GA{)<6?yQ`N5Pk({!5fB_S zZ%D}4R8e(r;At7SGiYx^g15E``|FG(WR~_-2a%Bjp>fUa&~1!v4%IY@(Ky?rK~@0q z?lsf%`8J2w>p@}xAqSs~s%C9?p@pu%U>uYx1nzo*J?3UyFRoedeFpehukwC03`y%C zub8ed@Rx1cG{0K@4;hc$K$VxbEcJ`Jx)HpntDT#4tpcz#q~l(-+m}uMQMRbz=g36x zG?HwzawX2To~xUMc!xCklBr~Y{{u#!(x7RJ_C&UM#9gWG;V0<^0>wI0+cZ?E2Q=fH zde6Par^}cZ+tG1jbJpR@XBvhv8*YTsT&>&1F=!5~NHjiDy+xmvF zfr#KLFA`)2N2t~gj;O)ylw19Irq>st`8*)OSa-{K+tZ8W=EFLVjeAK%N36TL>cYTq z_+ILSkQ98Kn^9~wkm`{pgmf(pdY`IJapfOP86AERo_}!x{}3m!L+A&d97>WRa5P0t zo&!lgu{v;(Zazq~L>!cjDuc*umzH5+=7zXzjF5nrPQWcxJ=q@CNB?!j#|hK*s?21} zu!+{AGD9xwFOZZgPM|mJWRtl_se7_X!R7KiM2VQZ&zKkCM!Qb zSLR`-EQP3o;@b4n?6 zN#~QvHulW&H%GPWIjiw)Wm%tAu>~7#~q_9(V5}06XUoS{d)F;*k zrfNnE|6`EplR8#L=sF_bd?ZWbM7oJ0o4;6QBQ>&EB4fMOyR4Wt5R1T@Q55yGkw|GE zwH48v=|6EPdnSaK79>R4Q+7PrAQx4A84VoE?fIar$W4`FT}W6IME~~O3AOrosT76B zlFr<}MF}}Y&Nv;dVaW}Xt4r}0yDi1$t-8OEy0iAC@_fXk(Aqdl8(c$P>gE7}wgTM~-sR>3XO7 zSt(#z>rF_wp)%9@v7&U0zy^)XJ5%cY_xiU=!^{3K=Q}D}m$vOq`jPHl9VqT~!X8>4 zNh1;&a=QwLz`sWDJ_zNs>3Awoj1q1LBl9A676V7|Uv`Ss6T)RWY~na!nZOJz)WbEB zp(8oWg`Cw1a5BsS>z^{j(!Xkm$DYDgSNI5CHbv;)dl9tHfA%kY@x$-Z8tDCT0D@g262>h31U-3GivzEy;_q;CZx2g$z~V}8HT+LFZoYCwM$UykG~VRC9=62tCRp?4 z;CnDZd2M11SD{Y>{-LV`3G^MufyN$8^7F;9T_DZ?LejsW>3?>j9#Poj1;h8x)CKYq zMbj$>|HkahOer|3;S(7dVIv~(P4N~k9jc+m&-Z+DUOAC-a3nUvSG(Rj9oe=tc@^Yg z#?N>Eg`VM0n!v#=D9$mK^`PWcoH$>4s$b{gV%Dm4^eTnw?C^Us!2O~tKNMc|exI?- zjyi)+67+>&>bzmlgPG?AJZ%gW8t|hPa~e~7SNi*$T2n4Cl}P|*YvTT-E4x9K2~H1} zpcH&{xhY4=?0Nd}9PkPMH)2ls)a7kI6d zMFNuOxAT)SD!x%GHAa5peRKBd#bsaUcDSnZ{N#QPXAxGE#b?FLP6-7QmJr%Iu#%g-%J2Z$bqOY)pkWWb+L7qN@pCS;CwOtTQc^)0FJKntk|g^~!~2Oj7o2V^#kQOzZBut{5X z9N3O8QZANnqz?{|4Lnr9FYVI{iovfFra{{kDh}g%qXc_#8Hv&HA0C3H+rfZAfBbpj zj2H90OH8kdEHWHf5=-ymdEcLTG1}0(%|ihpWLKHWH|x(CD7mZ(e4m~Bk-a;K8>@ic zB6EA5u5HELaJCz)px9c~9B`lF{q9AwY7tiGtzQopsYa>EX#h@=*aZ0*&do7Eg!$r1$4WWX; zs*KEfZS@2+{Wo(Xf2u{0miS9m4C)08>Ky*u2HD`CxZ#^!HnVn5Hwfa&NuplSvAH#N zyMk?$8k9Rk=QXr7XS^VeoG{cJG$VjMLdHC4TX~JM6f$IlDUUCZ8M0?_Q2rG;wDXhK z_HsLZ^J1RAs??{+W~1r-QlFD^(d64Y!Fc~zBe#hp>#+^>cL(oJMd0hiq)?*+;LpnMNrMj$iC}r&z_tTF15jvNm zl!aX~D#xQEA;E=p&7#dHKh|Nm?GO1$1~0f*YZfRP2gvDazumiHUZb*-3$MEC1I%Nl=yN= zwGywT6Y7eX{DItFTQE^3l%PS9&w#h#Ne+rm-A!}jDZzjVM2}3Dj+E!$N4FeRa=9m7 zsYy&glon@S__1$>Ue-lwOR6q<_622dBFGkMr7q#beHf z5U|GMTW&PGo<9hXeAwP`QkC>})v>7FJPYsA&kiM{k8ue&BzPbw&+x3ru_&@?$r;MI z=4H+vTOlLXyggQV|s=A{ggAQpb&PZ#@PuJ2rJiu$t6u5vuM&e&$F zch6?$z{ATy5fECP8t#Ggwk9ucZNHjqToT*IAD1!>SS@2nz&Gv{T%FMkDS*Sa8#J#= zn&U1ZcbIhgoM)p z@u1%^+52&bDBA)@L?*v5n`kb#&8ol{UCa&9-@s~`A)g~V5mIV=kVh;75LO1>cMo*$ z4q|9Gdnyyue^|wjU0HX9zKMNWJ+tJ#KY%{|Lvj+6=QE|_FKa{khcJh#xL;HpM~xn> ztzAb4g;~?(9tJqlo@mYzeeZcIKgLqsFYv@8Ii%n%E;# z>D8N{B8ZJVR4$5D0v(D@fAKo3-Z~QQgNgUw^LqZn&Y7Q;WcdoSG$tNGu$vkGIT(5| z7B9%xrnpzkhhI?}NSF#SD&08(J3f+QGejrVal)B1^y#N(_(+EN6N!$r*Fu-R$A=~r zLq*VmH8LU#Mm?NeHW-+etG_UxFcEwEs&=XL{H|}IXdMJ>t#}0E_v}jIPuh4F{FNqM zys)0zKk)(OTi?!pBK^k@?*QZQF@k7|Ut?6)w69A&K-lzl)igF!0JNkw7NL%K>#*i5 z;-$JJwm2g+_4JnWgcYSEs@soEvAV*7urGgD%jH4xk+tYHyud$_X3hoY>sbS-z$s1z z8|qIQm12HJoN`Np5g>vm~z zRKGM&M*wwwxSBT2bWn2G`_)p#W$Y5Z=T-a4j8nY=s|a{8ab0xDOf(|8g~4=WIa=9` zT9KF8b9-+0rz{chbWoXeQTnqJA4Vo?2u-3}_tB|pSR@$@xh{J+7%ZyjwTm~KpW z>9+f|ZfeBd?zH!*3M%FWMqHL9oQ8Q(HlJ_d;?H}`!b4avS99~=$KbUPjFH6kt3 zciS^MJ7WqrP&0(9BXaE3+j2Gk#7dy13Xib37D_9809T$=lc~QaO|?@98Bi{HCUtYL z+)oA$w{bh3#Xe0wYggo2ztD9#=%05HBdG{z;A-7IwkN#EtkIBDOcQhCf91?mg}E<` zsuA=4Q67BpuPPpd0;Axfna<(wn^_oFPfZmyFOT6|ADIp*e82cyo}->HwM-z|;OlRN#SlH4z%}nCRtm^b2QCGk0UGMTd8? z(|O5i@|Uq{3E@P9_m5wkG=H5?$^&B&c?WyMpvV!3$i)?s*n6SrpRQG~_l1}A`8-Kd zlXhh<+bh{6_GZD~$I1ybwnmSXY9yqs{d_wLgEJ}8)-&)j&n*A79mx}Kt!9qpAr{*o z-5aAjU0;hO`tqeud|6+k#9@+Q4_IaJ3Vb#O&0|v6UL7;# zA$vblQ&1ppiXFS4(J<#w9u$TiXY?j!o3Y+|TTS<}tBK_TPl>jf?#y^pSVTuuSWXeL zp1V%_$cIT><;y0_qCOna53fp}9K+rJ(pebdO;0_P`%a|>?}nE*Ht!5WNuCJ#M1O?> zGz&?uK$SQRT<%$}RUNWF@y^y7{ld`($HhJxGgOtsM07PJ^0l`6YbIvwD z$7`|Oc>SI-&>h%HNke47Y$44~ZWMhALuR1!^?{pk^C=0Eee*2iMF!7Can%wyFNU`= zF&j8VFY5d+G#}_HQbpE=_nHthB4RUt8yM8ml67v`ez730fRhK-AkA%Ln8Ax~%g23M zDvfsaMV7?d#ppXIYkunx2?c8`a%G}?VYv4Np1|spCzl!Av{@KiGP=RbQ&}KgXe-2N9=&qfrUT{hdGul1KM-`~eak8-ib516HCaAD+|nfN}~rtLjj4o$FkMy2kdEay+W+TkGy& z)~;0t!-)T$GvRv0Ur{MdF_He&He&ud5QJRC{tj5Fd0_bomJoD-x!)_aPp@}Z%Y*%|9x^pZR5 zQyab>~%+}V{Tgdh#8x)q%jt{$q9COjdMj5o3bX{%tpxb6GY=0m>o znJg>Yh^^4~K)rGBPB$`DkEa%=!x(upR91FIHD=Az01WN<6l2?A+_~vvoqk85(0S1` z*Ce7GH_)mfF$CG8=Z|zDfe?WG;m+qQQA+%$+s2*?4wyi{vfObd7Y?v$T^DU7TEX@XGc1VNeZmZ9j)r= ztHt(KS_q3h%qTjlQGkx9ZL@rZs^Mk}WcMAq#$T54V~oQ#3hoi_-#+CjKYM za6yLlJuTGCXa^4V>BQvUzdjl1i9FDaiVJ_%iP0!5O#oX|3;{2CWyo!&gs(IghNZO) zrurN0U=`GWV$HB+@!<3h)oz9HFzwiJzvZ!=h5@ecmix$ zVxI3X4lF>&yjW6Bq|09>!g|WnDU#<_$OS!UZH^4DeVZ$TVT0X!1W}a(ZgS{)HdTYN ztZ}yI;#)qLjM)^{BQBU_qM5);dQqM`gdFcW^_B_2bUlzYqb=C@6)76|z@zx1Qp!aR zPB^RHu)Dr^PoBDJ9h1Dnl7Sb@B9$n2U(Q=!B;qr~$~H2})e!kVE3EEeXrT;9jc7ZA zA!I6P?8=6zrupx1a|?m%t}x!ZfA1+e?!6{6$59z<#HTCpzF1)?+&Sm41&Z{xYB4Om zu_dA6LXq;W%+hjV`wq@%@s=Scv!;I?a2&HIBu^v;k3-hAn2J!QZH$`0Tgoy@x=0gb z4egEcVUon@L6G{v_+?)bdh%kQh^`oeHhyhHx9-@ShiBN;*T)(rJYEj|&OS`G!gErj zm!OBndyb8qSmc052qsL1x(}x3@;tzadMn>~*5~1FCvE{dJFH3@cHyZRfx23Bn2g;D z!{7}4ToD|M6H%e>%}I5ii_abnZ_N98LMu~`WZYtXb)JgUG81X89xkT~YF}Ov4ezqU zEH6Y_#Z}@m*8yuf~Zi)bMYwUs$4kkI-^FAIH9Ne=OktNhD5IH>J=x(DyM9{i z9w$e3;WDJfgN4-{M2_J=nS1z&?5#ryGl+3y?d0AuKQ|JjN?MCnDCc6Vsi|&%v=SpY zmlTCFs-c(@c^e+|huFMB_zF`^_H3FJ=UKCxVOB4>Lr2@0nh8p07;^Votu%*!aftaOsuJ?3ePw&a9|Fr11j@q@PcOfYhJT z1XytH_8?jfVBGvsUiq9{t>G+{bW3H;18#(zIF|1hFur{8i14*f!)#|Y>M_a7A)j5^ z09~gtTd@TrT4V|b5bhu|B;w8iyPJkHfP(Po9&Z}1iQ!q*+;G)_;qOusBv!Elt}!WN z!y1}s`kGE0O&#;UwO{XR=5iMmyF5v&5ug35YNiVzUd)$J6yKeF``hO^MQrGQ)NrX4 zWT0<&6aZC6uRfG*xQ^Onr)ujaCUOjG49KFUFw;l{bRG<<#sqjSt)1khhNigg*Bte= z5Op;N^MOLPWW#{!*y*$k;Zcg5K1Zhn0T)>^7ZVV7rvTEP#8FfV-I2S=2dt+JVKwMV zB8L%2x0vA)n_}n#$bRx?ZzAuq#g(fJ=mSt`!f^(Lvt@&9ZvW3fpEQ-|Hfwk>Bks-A zH@tuHc>I1oUi$#h=#ArsL%l`xfxZTNS8`%&w&g>FeFj;Q^Y98{a~&k-nECL>Na0Z* zNYnON_y|VTnGql71OPa>ftZtvJfLvWZ@M?h!OHYMZpH=`5zqq|fsuBn`%^FWo%N|J zkFs2#2|w4`szq5h=MhF|f*xCo{hN!lf)|n`cyyj%6ft7~E%${xy*^Dsr)r zUqe1K(Z|8jpLCEBlJ)1^RKw@^wqyLKf4+dWEBDTsK@XB^KJP8d>nO-E)gXQ9C;$gl z0xSGZqaBs~uy7s%!K04GA>UqZQE;!$KxQI5%RjvRP6U0|h}1xn^|8`>6bd(DqORd} zPIfp)B6}ZWY-sv-7_U9PNG;mxA9AeL5;3Po;(N$6S6U(d^(ZrJ0W5oC`+^xiNqT5# zcTPc*Ldl;7zUvN2a6~gYewKIgIAQUnCzsWcQi~Q?jAimv-7ApLZl&f&A*61fdlI`# zC;H#Z%u$U(xANj{vD(#I!0H$J>z`H%YY?aN;syo4G)X4KtcfHiSkt*il%^xfOi$P* zO2Fz;nr4fBP;S2?(N5K8FR-7DNCA>q07kh8^lFcyP)guI`*`R{uQDUH%aVV7Z#HBT z@4Tu42mln;cSa#SKXe~g#u>2m)KSV)W6GSY{H^M@;SV^f>?>;^PoS+iS@e{HcDRS6 zLR(#duW(eL>P7i2XG-DB$h4x?3u=l4$gB!qmqZa zd~4dnD-wU+8(9n~-{58~IL(nd1Z3+F&Wtb3sdzs;SX5Ufx%PFcCp6Kjz(HGh>%Xhhz!wbioAyV3YU-mHDm z^Ix2JEx$CdQF1CllxCf27lfi>tH6cA*pav0JCXFarE@_OCi-@G$5058kVPnW z{^b&b^K91bm77Q7kD^Cyc`-whGXG%KH!Up#7O>)7wBT}~re6)MXhgfbj;LAoK6bwvXthUu+}V)$CmEpg32_JQl z6Gi^SKsV6Uc|-LI=R)N2R!o|iPR7C^k4*(HRx65e7HP^|}So=nXSzBM-VeHVs( z%f&SG>c)9V%-!WY_@B<$j-2X_O6$AAX9nuBW?Mr$Gxi^x^7Jb^AV7bF6na1lWt;JP zq0`Zz9fT8k46L;DAh%Bsz0dmFN{{fhsbCRmfR*YF+)4jiX&jRW+K4$*XQg-tjjeT{ z={hH+2HMY}F2pe>f3>!9=p`&C2%Nz-w&P4rv6k)G5|L<+Vg@Hyk!p~0FB1h=9S5J$ z)Ha+B6O_zPak@|7RR&|a^nFu>)(<+_+j`G8rmVv-gj530ab{%k;?*7s7%KF%`^05jm@HM#K&# z+_jm>b6o3Tr*m5Rud4lL*&hPW^2H~>MJ6^%be(gu2Kfz&kxO$N8B>dI!U6-UEQkp# zC=H>OfG$Kt1w9uO`9Pti8F*Y0kFB^t*PnSHl5HNkZ}T&Q>F*>-T1Ic^ipfZZ1f0e} z`GMoYp)RdPRCG3~t6|`1&C;isUW1YFrN|ULVC8)Ah2r3&k)5{V0wXhL3EkmWZ8#?^ zxgK(U2W1l*NJVo!o@=$r``W_~EX}XFC;}Mie;=WiBoyM^bvS_nJ|C+CZNRAgyS*1eaUK~M1u)31q1KNe~IIp(;XiO^#cx$xszRXkmHE54@? zKPpgG{=dB=FfmT`{EzXs5&2_Na@jIhV*_c95lQHLZOxy6$A!^qqWY~^=Lv^%CwTpn zvH-AiQC77x=6H$@6gZK;Kw901CJuO179u+MCId(lV~{F77!kZNY%*Gf3d_1X=|PY( zCqzap*H?33fJQxf2OBhom4Gi_b)aKHD!y14Nu?#Vf*8oo*ig4InQApM*Gzt{U3)dY zcj44}9DF;HdHRawZ@s?~`%<<17TdHK z=;eZ}5sE!k;eqNR23YHxKGq8mq0@<|V$CN!i9U=^j?_-`r=}mOo(@Vu3X*^{ClX>S zJ6TaLsQwqk%8en4{F+(ElU0CWxY>istu3N^IZD^tZNj^Ji@rbMIg7K$_FSy0x5)@V zx)kM4y*7wIGMCPII9(d zAIJ9iBa|KU0w7K^iq+hqNQw$EYX;ZjqSu?CFT6(65tIF?YlbzF@T>N+6f3z^3s}(Z zLH9RRO~I?N1@ECz0AfA?@@-h$g@_-k%yS2}-T7qTOVJMP?%Gt}hT^zYiH~^r z)U1nWpL5NEvc;jMc@5kWgOHvb2l6*V{bT4m6HcD&Z#TqORh84~05QUO>}OKeN3!U% zO5zWKk)XU5lIwL})jxgY3%sCt;gGm>;7r&X^wrWz*aHF~UZM5-NGdr|8-=sOHdT#C z%Zi)S{?ZR>C`U;$$J??JWrEM;^ofhZ}LJx*x23t`b?x zrmAlWHJjrNIkOY*zzvH)cKWKxxY>fbIRtV=l+EFafM;gtA|B^bkd0>XR#Xl5sIZa#e#9l8RaA^APykS`B678=9H?Co zSJM^JeYQKG=zyCs8p4;xOZb+dQ8}(WnCW?gw^;kzl;km2blCU=&%azdDtjZaxrMGI zwxKiO3=el5YO8laE6DxKq0(JvzkB1D!IcBvh+==%wt?U(w;*`DrLJsR zT48;e{pW)&=^Ky=6hNIp80?m3*LSQHJ5}YSBmaaAOppo z-n;{6{NC(;!i31=_j3E??tdH8p|;ZZX|VrksayK%y|e#)bPUoCT=;)|!Lrdi{_i1R zt^WCu|7B89ut=?#Wd~=B>?sAe^YR3^bE`342O!^>eT8)92<)~ab?Fyp0y+MFl_V|K zcLu;%ZK)2cnpT2!M1I4s#cY_BqQw}FQ4h2mLoNsKB#j6;u@v&wqgn4axP`;;FNa z?_RRcAyQ&ss}tJhf=R6{@^SrV!_1VN1CtV4X>BA>qaDKX0`I&4A?dFO3wu7VFG!Wo zekZH{+PD5cK524a!xNceM~gco1td_5-l&JZ5~e6Mr4hg2W%P&jdVsUzJZNA0OdMUR zl~WXZr-Z(lG<0*-=?A@~r>E~xk;cMG*b&X*41W|N#3EQM_$K^4*b82GA0k+lF85c~ z;zPb#+7o@Ry-OfmpzeeCMYw78T}JByrcZUF^r@*q={B+a?H z6pHa`uJ3kdIcte7KTvB!G^3!Yll+0Um9LdDeYM@*if2Tyd)6iqhdG6#E#f zQagj2%Sqe6GupG$BlLuMNgSC4vk!G=|NmAE6aU7ny{VXxk01OeC75Dh!3JRE$+7%O z$Vvv|{+G6v#)m~A?vIFNPw_8yeJ|YkfeDI@VI0s9WU{$C(r*v}>{;BR?_ zs+Gg*is9eKTkc&+T)%7k3T{K}|5?d&@!;bZI_K`_KXLWc*3$B+Naq43{otWmnNT%I z4vC9xB;Q08Wf;^+is4X*jf%VxOP7v;BYw&jURR24%ES23f>EhM?;r{Cs$^XAAB#Hn z^KwCPkgPjOBDqB(_5`m5qLB%f(JF(6afLh2bkt!U6_9|Be!R`)+D0I*{2zq97#(Gm z0~5h3_eORGA}ATk>cP$za)dj39%zbZoB1M`#nYvgLs*`X5QV!R3)%Yz(xxEenEL4> zbG|NkaOFVTHYCR~!kOvrAly;|D5n(JQMaW+0&G~W2I$ULDiviBR zn@$4!?ZgzR!4!8iXgN(m7br=fin=hVf?S=srXs@$r-#L5bszLzZ!E2=Jt4&?{V9Bsb3 zW!2&Kdm*YqJOUhIy|}^zo`55d_lQTB3Tk=Cz0ou88`t>~QX8+RyGy?HzdFWiQ17t& zH&6D};>ZBkSz^7lz;oI-_2czX+X+E#ztrIAl;ig7=N8<$FK38xnwy-hSnfN_{4HdE zt*~-U%pACPAQOh$U!jkohP#q{kpZ&vkYi*}>yMR#3cJ6leFukFV=VhsgIk%g?-z(c zlIx*GQxoz>z%I3(t^m9_y9(rvB7_zfy3>K5git?sJ3IBFVQ+V;Zi{i&%y3cAx{>6?i6vBZLn)c83jD-M+X6$FNbx#BmnZ2;>T0ZH_BW?tUEOvp&0Ttnl+k zvf|zJry$YN*q{X`xfsXEQh%jC2nTnr`_XDgw63JLIu!L6d5O?}Ucx!O8D|;F>;U#s zP}N?UbhIH~8`6-CvIHyt)&z-XXNuRA>-N=9hE(7aO?vsH>?j0fh^$18g~V-xN|iKTYbSJuHo7sLn+Zl$W@L($|sXa1*#JJV{8x(%d~T= zu^hEn*%aG3gSRm^|J@59Yn!?iX@xbi)sE4#gP>X}wsbMbpsMd(P_)3Acnd0!*TNIs zG9}jSgJk(LBUMbR15e4{GmZ}JIQM*Ds|n#O8e!R~q=C1&GI7aONmnKnK)iTLV#ZB~ zv}ka1b@#d-hA{u@fWL0xqw25S=cmSGFanT@unnb4ndH>4#Pflp-W@B4PE3=ipia}5 zkm;4NGe;!K!bw*3xyA7KP9bCj<&%TtQA0qW*HG%VS6+^^y=36R#gd_58)H2%S&hAB zD)$nOPE^I1l)iqN8> zKm^TVRoh1>+zLY9|34Ue%cwS+zHb+I3GQw!6nA$h1zKo}7Wd*5*Wi%i#a&w{?oixG z@!%Afmf)^Ic6!PET+e>5d%yetjRh-{8&Cdw{U8y~?>`C=tQERzz*+aZyi++7!G1k}wgEPA1A z6_ixcTgBrqqJ-9U6k%^N-Nog%1)7@ghaCr;b4m!DFZFdy#Od&pd|&Mn=6gyPn7rt0 z{4B!tXw&bLo2FUXcP@*F0giW}DKy`UKb!NO-jIKq@gy=99O6I`MM7iSM4D9lPM`hu zfj{Li4wsjm4n143xw4<{XE0I5W96@#Gxu4ZRukUEu5I(xZ-Yt%Q$&#qYa;aYYc8zb zCgf2$N^UXT89m1t9rJkG-?83~Np4y5(zx?&Ox2u1!n(R_qT}VxW4bSq+=O^UKa!&u z)+5}J?&n@1k(NHe5C6f~63rC;TEI5r+ko^iB4gEZ52o;grUj<0A?fKB@BSI&rAR2C zroV_F^(9=s(QqqFc;68g1}zUOq4Q^Ryq{89>MT`w*j;t$-~kUSY(LRUfTY^+T15PE zmoUy48(Py-@cyF}2eT1>GOdco`tv`G_J_ziTrUNEH7Ym0Lo2IBI2fTvE7k;_szY08 zPUMkS!9{)_bNcSbSetJM##)!pj#%V-{h3O?@-#@ksJ(UEC{ywt?J5dK6~$EI;AaSN zdwnxr2%kynBht{4i85Nz;x_}u2kxg(RMLcwZ@g1~R6l-kcxVU=3>-}9s{q0Zr|l;glc3CLGFcrRlUz>f3@p&PHgrfPZcZ3#@Z=gl{9cMCJk}=Nr5F!<1=N; zhR~)mmz2C5XMk`E(=85@KAXuXVf_p(1*it{GmK{`)D00W!5<0ejybJN*}e4o?bkr) zek{N=`#xRQS|zx8VE$~^AZ4*^^`?n#ejBG~eD|e)br9?enrvXoU~)aVq%Q=`K%fbs>wz9!F zu08bJt1{bqFOH2D!bPS?9%?`MlAiXB!g5vlXp_g3#u@%H&`vrEz>?~+nN0G>|}LK%63i=do3 zz2kYtBHq`%R=#7c!Zv||2cmKs^y7|Jbg5>F6$HNvO236jS3W|;&vmL4UyzF~bD!Yf zXw|CRKry#&Nc-#ry^LLJCP{Zg=9Tliye=6QPvAHojp%p2hZ6*kQ2jvL?<@K6bn{Oiv68 zG8vSlSn>n+x^*r;<;hmqnaI&#c|r!YjE9Yzy5PebJBT*jC6#r}5A)fL=qrX+1>Ije z7bTx5Zy?I^)-Wrow~*zSq|YVA!W&LcgU0u2lu`>XXvznC10(Edf|Knu$KS5&Y%xl; z2f8lE&5+XXW+u1{14jVR(wLGkWL+}&1DN(R*~mJ`$T)`b9;MhEb7h_KPLN@%-*vw8 zi1$eCaI@R}<>NDW7l!ZPxHBxuI92ulJ&*(3Da6L*16DrnlPlZEx1PQ@$z2S896_6I zkyG$xx^$9~eM!zi&1!g~t5`x-I)RE>!nvm*{_Ej}#auV{oT(b}s#11F0@PVY*bu;r z(_r>^XvhX2j#Z_?oTvP>MBUUq%I^L~X-NEJB6+6dPO5ykJt5DqK-f`9?_hq8(ZNnEH?9aRR61I3lYZR) z%;CL0Sf{8{~@Xr+U@cPvhOc;IoR>3HB}!bRrgb?f8cNd&h%Na~!B-xqtc(ynh|mB6Q1_|PX*T31wc8hHC= zY4R23eH-t+w#Wj@HMeAp@i)K$M&He5=*n(Rt8p{+BgeawIg&yeI>=Oz`<8&V6SHXo zyixIhI-;dr;Skxa{^5D+mcvyT_fw>H>28RR5&Gq7TZEwK;|Kl89s6LAAs<;o6P}Z` zs85j0xw~1rKi;0UTd@sMD@uzM*~**YiGFVzWd@%k#&Lt52RE;WRm+}FoFjJ&9_JH+ zazJ12m)1nB^k;m28bBckTSYWzTpT)@dF)v!$N613LP9}Vd={jt%b6~w8(OR0EM2MS zp2K#|Exd6`0u{_W!PCiqA#b`W5`ed7H7%^>77ITIr?`F9a+?!dFXi-vNR`hxW?PDG zc;YfJka>xY94U`LN4if+8foDM+T!4~DuvQ1^3k8;?7o$jWMjZvP7*1YUy?3O+K+<0 z?*`wQ(m_`{=IY{eq&ZuS7>U{W<55ha%-rmHMfQN$2Wsze<>-EBRy)p71p3MH;=FQI zsIZ!mW_Tm&def7jm2YHM6O3Ev+s+xqYam=NbiB21P8)tn>+dP#g=>KK{RV@1#ROAl z%#(@Q)D}97OaIf^+u-OyIa*&}!0iQs(0v!sTFZ#+A zUXS#{;X|UsWI`Rz(c+@U!kNeOtU?>f6+1!b7gdp?Q2eLI9d&d8mw4K%g(Dn9k1B6g zr_46aSovyHSkJVG_-Z8S=|xqLh5@(izGH|k%kroKOUewz@lyM$@syEUDT-XrrzEA?MDute?^JHKoS zdpsP2>vi~>bIE2jQKA`~pAE5GF3<16zq;dS1CgDEU+)jh&Wl?~=!FeRO~J>paU1jC##|(2eiT)SAj7 zGO1UvTdK)FD&iy%8Yk#*1$osL*lCBUt;FQEb2`)qgs=U`MM2}4;J#0AeHGjXA8T98 z_&DY+5d_GTSY&xDbyFlQAFx->l1o_hc!WYFfCMl#1JrlAu|O}5heSo2MP;`J^LAeb zzdM@n9gC{&`?B^YRL8#6k8_K<^1SKKN(b)0TlI0rnF#l1-D=cirXO(++%B5y-cuH~ zDG1rFzVT`uhFqf5K2Y*OqTcJ*IwUd;Xp|cJqR0!9^8byhQ%*fCcy5U$%CNuD4 zjy+&a%c1j5MoIW1!cI*rOcV+_9@rh%wd7)0ywEu`zHyx<`s_DljE;W@!x_?(SfXgq zRKVN)7INL1QutHkbckhtv%)l6QR*SixceQ6;fHsFBgS)xlrCI+4a^@j_o4`5bhd*- zU=w)=!UO0H=v-@k9;U40%kgh7hM!v#;p~6Ff+*5+;f#Fux(DOY0ktUfFPSk>a3gcQXs&AA7BO;I=sIg>ixJakb_lJs2&bzK@3g=c~J zU!B7cO6G%L`YZwl{h}-Qie)GGG<~kCJvSng-rJR3FbLvZ`r^Ekfl{=JTff+JM)0?; z3Fhqdm-|4maIV$JtC_6h2Ho(*c*BNktiarhkGxdNScN}Z4TgO{-RQ*@Q74M;#Av7?&*r4}3Dwv4$C=_yP|*oTNN#mD zHVSLjjho_$H8Ku)+`w+0QicN)WN#Bo+{(7F`&-O%7A-I1g&5Cb?CsK>A4O_^^=gXF zrZ4XNV=IFn?V--)T1?DZ7A=xwki@NcR=mQIXkZhRbS$$X?_z2C(=&zCK$}n3RRPFA za{`}Vw6z_yFa_Ucm$^6Ks3w9c?TxCvo({<{qjri0AFLatMS}_J(5m>PUSqKd6CsakJT~_BwgI4K&NKGSaf=$u$7ue7Sx>b-HqM3iemfdhv|zI zx)FrPJ7?!<39P7;d~Hs7Vq;mn*xvBqv~IM?y)3RO-A^}hD*othnhZEA-M4P!yt}e` zs4Mwo&h3RFse_|_D+*}O_sY_5b@>*Nk#L^Ftu@hY1M$WJ(pOBcJQT!&B<}8$p@(Im zu?BH(cUvR(`$zVTB3d-!&QX1oaRZEmFUQBn{Gk(*L6(Xu)6tWN)5HblSOgg;geZbO zI14<1O};EchjKoF4dhzsQO%R2FXx!K+W}fl&m{IAO)cghE5mN1;Paebky0v2VLR7; z;;5-VR4IUx$)q68DIU*?@BOgG*2Yn#Oq20|=qz!-7$;Npxn)}m*10t=c2D-7h({yi zKZqxM%8oDbgEn{=7d-hnGAdg_LjyyGd)BFnURhvix0mZ_Z-rshZ8hxqG){7wY7{>__&!cqg?o^*$HReTP}4=AVC zFp)VX`KIvk>~6gB%@FV*Citn+!PjosLr>a;G1|qjhBeXTkDswj1E{4%2$2SfH_+Eq zaxd$z_^UbON|3IKVLePsE99s1ts-;k#uAMG;Bot6?+^H%_D0`{3ckPKO~OnZ+TK)9 z{2QYUAx96r8Vh{RH~ECd4I}N6UYx_=%eREpln{@=1w;+x>^DD2SUx|JPkYJ;81y(s zJ^&_W;|x2nJpCyQ9EHxji71{Qm8D1WwbNvjDQ3sC;W&7cUL7;`f!@ql zys54wJX$+zo^skWXt91+sxplInk5NN!90I;xqhS{exZbp5pru;be6UB2a3zb*+2D7 zz}>ul*R&y=)xA4i0tc`C>{&0FN9#QlUH*+4g286`*$)39cvjdxv}hjbN%`i`@%QdR z6NArLgKNgI&r^y|8W+HG;j-a`4>a8b8 z{hp?IPiEA9j3}G>cA2Zg^)#l+tbQqBB-GE>5q6sIUO?s|dYPyf^ypc3k2%!nVqtGO z>1u-@fGrI2EcgX)_N0k!YMx*U``+D!kH%U!70A%iWaxu980taD>P_GslIgL1_@3-b zB1L@gWtoVwz{(Vr^P?&aPuTT2;TVK9G%8){>Ul0+INuHcPYkVeLDgHC}oD4)w1U;kyoKvm8@LS5{-}nuk&4WQ)z-WQ7eqY80-W>&X+!HWDdxj7>zhlz;EA%eD>fx&d zwwLB)N4+pe)A7WFD-ldi+DLtbFv)A<;N#^7A4-TI&0*9#Ujo#sHCp&7w>NG+jvJSL zL?`kV_aZ=#K_>j2`1^cUIRa_n>fs`sn7aiT>%UFH? zl@PBR$iPJ3Hm-%5-wb%LV2*C`GH@`58SbQsMepxNj7e>^+gm-@aT>Y5^Qu@l$9Y0` zv*NO!dE!b)i(_ko#wz#>jM-_(a%^1ZhNH;@9x!d=b($R=rRYA1f)0{4q@c1e{wnmNeFR}%c97Obth8e5U z1>0;sJ|9|$F9%Zr>vikozf;XBl(_Wy)9v-;A&S!(V&Fs%ENMh`@Xi|rjX;e95O7if$Z)gkE%z*UK!}!ZpobM8WU55^hV^{;{bXBT^@Clq+BWJ4*E%3*Kw7rRP zxqS$)#531^DT^F=3`!!ye>G7UDcfU=0OOpOK0b8dwj2b6^%`ziJ8cmdRnI=AGcn%$aE=- z;sIkDCb3Pard&(31u+bmb|F^gFDJN>_5@dHr;kSx*&?EN@zAcEY3e&nXWi*olf@h? zY&NTrzV-QUP}dYz^)sd51!`aPn)S%}lhX}ruwj`7YC_7QLarC#XQEeP=@ynH;Q&vP zmI543w4R`F@SgyT54IsfS2r?G=shoi|W8Y#qv&Rf`*YBnr&h0y& zLVR<2IqU|dPDM{EovczdDJXAU#j<10dE-H!#~rqS&*#D0s%gXozIAM?I_Ntf)m}D@zi^i7`E2@W$FX>lcJam#U zsSE%Iv!-Nx?A9kX9WTD1JSICt@*A#K(KJ~zyjmK`t9c8?jsZKs$$myx2OrAt+@?c) z{L$*iqN$KJ4}72w&0!$P1D{vFPuqcLXmMIda@l81<#zI4M^Bx+R~;voXQ4AK{=C0NZJ$3{ zRpfq}U7>B}Lk!969>n6q*V01jk>v)MkQ544DwSaN#TbRY24mxbX~a*AD0?dLotzK0 z&rOXZfub++`HRL?B^V!1U<91B4>`;5e~!uORMWR9ub7oyoS61=#JQ9cqxn`Dn$%^< z?#nLoIIZTAE#OEiBeur4c3x{nb%wdol#-IEq2NO)ML9=%WH*-u6?9bVdfc(Aic5am z8$A9lfo9W`&GU%C38&|Dro_7R#7;ZUb)bf8RS8cqyf*@860{M=c=+P{QvG`TZGPiV z`timw(Y72V;u-w^#C9SP|yiWDCoxVD_RY<=Ot@>K!WK+TGHh^wG& zlZ=jb;VG<|>q;pnM!{(*y7os+6>d22NIV)gI$ClOB{L5laxbCT(0j5RCXH^$r(vR+{1F31BDqg=R({+J9Qi({)VC` zEng@f|A9prcb+JyqQ551w-sJHGK$bIh$kD{Y@@3S;5u+^`d9S0FfKg0tD~Q-&Qs4( zMf~{9;dc2AGXs`qZl3@cugEPXOoTt_Ja30R+y(kIm zZ5o*Tx=kz8Cm+jDBk%{|*W3hPz<2Ulw)-m>kT2`sIcKOcBO7`=jhI3Bs|v+;{_{>* z{kCyv$?w{rF3=9H(&$>5m!nU%_G~#lM3^ zfe$awL#i#X`GJizl5Q~*t429f3w>PRHXKEvhpUb-VULgHtM!Ac>aifH_m*mYE3!^2 zp`ETIwklF$TMS}9YF--j*nXexW=wMExp(0#J(I`>esg)86}1!Eq^Xok4)5u3xuc#P zEpbYNQp-=a@tx$v=q8fSunw2HQ~8VVoRNg}!8)hY7%s z7Hb$~P0}!t4BtgGc~I_wUA83YcuA-7tbPLuS-&fxO9;n0u9r?yzJsh+s@Z=fho($| zZbYy-OW8hD(N_gc~1A5g55a8~{U8w#Ub{$H^n{o2XeI&yCT>cqbw zz$J_SKM2sR_!_I~1=nG=iG;Lo8adjkj^6ADglX=18rMYYO+QA-lPj4nTF%as!#s@B;HskgofWxk8WPTX$(1)Tux`C|1U(b;;^6B5ltJ?dZCT} z3OeGmtzU6+z!P4H{5UeWJWF%aR}eD0H>V@k*P>PJh*OY50dE*?vc-azmB zaN+h$H&|aP_JVF3Fy0>h5vayTci)H9JOw%*J`QL`x^dEp=j3`IQibsS@>@s}OO&co z=!3!8OT;n6cwDPMo4BA((98tE`vmKIjAD)iO&*WJkJFQl7ea)Ef?a(u&QVUGN6vDD?b`?|1@AB9C z43P>{Hac%{?_w08>Av7wpM9c(^#Q|I4BRV9ZTg_jN^ICAvCoCo^zPFwDhF3w-G02K zIrjW@_OX+IC;Bl!D18o$bZn3p*A3#cl9}p52eRbMTRoMMSiObMZJ;ogvPxU$sa! z&(03MWgD|jgbKPesqfdS4Qb?g?sqR62l^ceTpEG`e8g95G3Pc<)xtnhX<&utKhHp5 zZdq<57tX|;w`)fHW2+6lNo~?;h=7~*wGwY)Qcn2_{qQFp!PL|dxTrSYv$(%Zi5eQ! zFrU#S-r_+EAV7#I{eI_&j7(E8%gl`BIFq}%qf$o~fJ zT{Q+BcD#UsFP=wuktMXJr7!PkknJW&@%-gq@%n0fySfkK-Rng855hCURZeo)>8fR9 zVh9(@jGyK5q~_()U_q7H${0~j{Q>w;(4HG)NFYbjEWI32i|B;oy>FPcM-f1r&_oDy zk~(b?2C%zyrp|ID@+Dzwx)1kEh0!T}!9LP2>GTrners(zxoiG}h&6e95cKicv@4aS z5!+=lI=N&<7Gi=$k){|Fz;ht$q1~+J%GAk;et=MioQEb>JjC(h#UpRYQJ_t8=rZ7* zA+Lh2)sN)xmaloq{ulh5CVoYS|D-0VQCagpxDS(0Ed(URNCxyoZMo+gtNG%5(-_?~ znR>FzK9Sq+XR(E2)*iqj!M-Z-QR^NnK#|wrfX9iy5GRgtT42Lyq{Qbh1<4tXm?#~> zBrF!`r%Cf&1K++0)eJ>onBcDnja|WbyV{-)%72o2Ep}Eu$JEFGIPrb^rdt;_z=zezsXZ1z}B6hJ{#Y&pyoQ^ z%fj<*(Qh|46JnF5L^NjZgA2&SA9(yyPi zqu0jEal)rUmt+Z=f%RgGC6bGpo>l7tts|u85;*&OPvwg-8g!Y)u0~lTAyNA?XW|F- zVpLFbW}@!-ZB(D(DX&HQt-bAPv-_tNV-$Q+APp{NsuA8FxtBs^N(+ z2CI2lUi1PF9^5PYL{o`4NeWhs!dYSD>f%8XcFFsnyp zq-?#oVkkTk^daOeg^22{uZBDPde&=>0i-7I<@~*4VDUB)k>u}W%KV!eQ38H4O5kR< z^X?f*C~RZcv*X11XaobD1^s-Gw$+%y@ArDS9#{{9Y^P^j!Ei&8(EXa^3%>FcXzfZ~ z2r$(&&bJV@KjCE=HBpP4FCWij;(XeX5UZ!=c8Lntx;QQV*1B}jJZ_{;``+(w_)$jM{ggC`2>7oPp zPKJrvjWnT!v^NR+rA@%y-pyHVYxn|cRC;5&d12%ZX5T)x5`0{5s-1T6aGVa%IdIg< zUkO`$)!2;8C$6QeKKn!T8o>>NQiU9#LhXBKa!(VEXa+kdq;t2sO)?~`lx?j{UiSuP z9YYlhr1>EfG0kon8gGINsAANfX3PuXsa=@eZwTv#q1srZ@ruOUCOXNj9!%3rO0*Kf z&wLHdtH1En(vHe(|Bw2``Qtz8R|Z@{5_r;U!;?tD&&HnR%tOSKrVZzDmpB#!OsVRA z9Z;2}Bm3Y?y7jh2nkyU}HzCaD*8QWz-nH zptKYLB0aMHLuU5mJ#6p7gbMf_%Rau{dZw6)t*~jrjFBbVh}Y9GQ_}P0n_xH~U`qRk znsp$=RKY%rAkNxk?irR+cyA$zH#|&M8*eQ}gkBln#q|pv4S^B6-5xR8eazi{$L;VI zXFJ!i&>Hp>v~osmq$hrvFrMJ)&)*t7=q?zDMk*{bDzD_@? zL;%*^?$YGGa|{|gbXSw4Mu5hP<9ScC<|YQC9mw#nv}!?8wiqrt`5Z$NX7u)Df6*;M z*gHlmK70N-`(FPJ6qj8Ak2h$l=x|@;;fV?@`Rm}vA)&tX%Hz4%#kzPl#|%$mxB;ZJ z)#%X7t!`pW5&U!SL`WZe<)rw(950Ht|2SR%-jD9P*mp;V-4{~bExah&crEsrwmXvh zOfeWW)JN#a6JF>G{CIlUn&bApo<23>idU&qk+-j^J*c~2hqTvgR$iMwXY7tGnZ~|L z$!FsptM4=Sl!uXs@g)_M9|saV3w)ns%v-(=|34wq>zX9;E?a%ym&Yg1bLdC-WwR&t>81U8Cpe@RRkfi03tAz41$SVR6W__8fEyn3&f$^a(n zKTozHeoHJSf7{l_bV}Dr2iaIZT8NRp5jA6_Yw*POu|=wtcQqy|{(aEHWGr}G6TKVj z)m-%{IY`O}$-4vfL?3r$&Y(Qp#Z3$Mfr#sLj`O=Q=_Ult;a9l>sqG@#^bla0tKZQU zr2Z5>C-X9Uo6NgWwEBv!Gzk~Sxpk_2%=NE89{Z2m#MG7Dz#H#+X9CG(-J||;BKRoA4EK17-EK*B zm~W6qAs^sEvwdy7PQZxZV3JCy?D6#OprIMPPWC#o!)PmWux5`RzSER%olMDBn$GS2 z4y1HCGBU7(Hv+BiY5MuQ(ve=cH}ZR%Z}h7$)lznaukDB{^U-s3n~?f_Dv~eeL(^qg zXE3S>%?i*6-H2oC(Vbo5qv*BhWtE(l@p34v9RR-v2evvsdS(5*0*al4Bm9^CG^M)2!pX@ z{-PwY?2-00u75z1Z<0_oivo+(wa#;afS+>pOn~h;A|DRN)|?`df77KX91A~bTL)Dx zgk4SZxw+8((2QJZ1@os?OUJCru_C9~oBkSaER3imxLq8+N{z!*xo3-w_^$e^;Bt2T z;F&TvAhP3SdDFP*@{q;ghc_rbqgHLbS|yVjB2}B))sApVsM3DS+f?Q(`k>ReQN^Jo zE>%#Bya#??zG4wZ6K8t=al7Mko8VR=ZNZX}2yo2e3^c(AR5G{+!0s?CwhQ+f*ZpCD zyNxH872e`Vz^{>1+n5&^7-Q#tCJ)D!C4g<|uJ`q(dV(u+sf@ANeYw(dv&Y-0Z?Oiim=z zRxxZaK7-?OmzXF@Wk}^p^kZ?vaa-Yh426Rgxzg4;U8$Vi8%ZLQ4e8@}Z_M*IFqf-s z`d0yw%j<tiHv$5dKQ`#l!NS zSO^(I`!^OEpi=%)Zj2_umpeGZMU);G$PI#@o;f>VEUe(4_hi362l~@QoO!% z$ANq!ag3~x36Cw4*1_~2P^vj(I24@dY9&L$&o!CI);jCarKe-|jZ*HwdY(uNw34Ds zc#<(tXq2b}@)KiFsxYXHRy1rtf%Qe{Z@=hoH%o%g-I}~?L#qvU7(y3E&l3-#A>h^cYU;X(f^!?d+kU+*itORe&bBZ1Jmt5=q^hr3nog+FiNpO>kqoUQR=Ji&} z$s!e`CexLw4BzpQdXn?z^Xdu6yNK)_{Ac(5xPeL)bi7{<-Tm6fFB^`A) zdX@3AqEQjzg`_6kgwv-k<&Po}UWb-RB;3dJ&!>OQKq2B_@$Wdo2`ZszsLi|`<&w^b z)*cwl?a|=D#}f{(vI14mUfRu`m<-dHC13JB)4=9AJn6BSM`|%{`0kc@#WlHvBn)aF zLryMZ)E4Yt!@VJmCdS6dZZ6psQb)GgUXA3k%ej^Dv5#%9$gWWBevrB9twFQr8Ho*pA!Fxkm7RQ?i=*zs0Dmow0iC7);-qDQwPs>w3cQexHxU0d^MYXGA`4 z$%ve`;0T{X;wk^5VLeC2f4kr1UqMJ&xj}stQ805Ifw%F^Y2A-{r|sW3OSb{C)u%Bv zwo7Rm`BbMqnC;7J{b#)n;7a|w($_Y-9}SX9aDPH`Shq|RcIx5JZLDN73_JcPd!T~@ z^2%xO9p}1_0Ka^SQ@8&&q?2{jtS^{gxiTEwu8HUsJbWjaLRn5PgD5q92nk_juQl)Q{P-ixS+hKo#jLM?V)vVVNG|%@+4Y zYT^&EALF$L?$PvrlW?@r!TJAiudZQf4Vp?1=J7gW3Kl;!`bk13t9gKjZSRS)ry4jJ zrK!sW<9Z4#OSFao{W|d>o7MUBRtWU+gm7A9Sn#h{&9$Ydj^jn?ZL%hes5j)gO`v*M z0d2%yH~|ymxja1Z1pXH8J*Yjs@qX)jtn()4>G73LL2DJE*ayjtW>12JsPu()Ja>j# zaKQiDxl-c!Z_X8#@bg)-x0YNRFW!k5Cbpf+yg-H#6}#&f8^@6m-vuWjR(rgWPr0Gb1vc`x>d%Qoy$UT zW?G%W#+o=fxW!TytUvFnt*4dJvJeT*zR-=EpwFJjT3n%Q5f@%gulWN_PcIbyC-C^| zIjebmYV!&2SS!rg*f;q-)iGQA;OgKvsU|%7_UJh1jy%1lIo5|kz(M;Jx=A`?t;V>6 zf_L`3&Mz@4h%;s@!>zo`J86#__N5C;=Wn8Zq7`F*(kU|9^~-_y!Il2+=WT0k$=AMT z37zdh8X&lQ9P{zh7;y5JWgr~sl9hZyqJK@xM`vg&4`!eyfgd$ZHal{go2XCwI6aF_l>^?r-P-dy`a9H0WSRhV>>>U8d_9 zTptcvIb%~h0^Mcp_llj4Ey*6fd}IA7;{xR>juwNq5K`hqdxziARdh(aCM2e;jbLtRxn~d z&hWPj%CS}BsLopqNTgk=IlDZ5ttHZZqCZ@NHEk!LJN{8J$^q#e1;>U`CK?PdbN|;! zW=&6znEFi zkgdcr>p5$9FRz*9zQx-(OJt9oJ^#H{hMhtFM0eb(?{|8+d`UcE`qK+OMV2OWk|DRr z?_p;=Qch-n^xYdYKBfEjU7Iie-LP_E_;7^J=-I{JIPxeS4alLWBXu(O&N+8U5H}Gj+ajJ8y5QD+U`8Q>Fhb5^* zI8W%&f3f;yocp00bM$CW0vJ%7$M7AgyH7zGlU7mmF+Xiz&cmjyR?%jGS2!CPp#QOS zNXfua{Q(WWbg)QqYyJ-?d*bj9Q1%=yME}GNlsQtZZb!#D>3$cw5~oLoR{eqMFzToC zp?}sZGg1Z6zY0wR+ckU>dVeG{@9qDQ%w$Lk8dKam_6)qUq+;Yi0Xy5V!K0B+_P2-f zV))>q(yEO0zr`Ji3Qol_4jHXvtVMYyqinck@NDKyLZnIG{}F8XI!-nJ*&wty zzfRbyqBa6b9i$$%PB0Q5WC*&Ay8fR&v!*vU6G!6%4YASF$No3P>T!6b6?H@?3~(yBu~hp8#d(8Chj0& zjW&7?x__+SAs;T1^^Mfj5XGeW^iyOFFMdPw7DApq`r$#c05oR9+_HYk{&q}$D>*cX zHxi^4Z({Tku?53xf)1b&S&2CRI&ut60-2()bS~0$5M-b_Npj9suEK5<3mXz^et;(_ zX%dGOQwp>#7*>JUFgL&BXnF;DLl}YkY+>XjqOLaaJD648Sr(()EcGdQh|I?G@cBisO=Y(yoont)4=@&dVf{%|O*;ypw-Z%@0Nn}1@V$O&>E=~!S~ z22-D}a)$>@Meu#hhMo9MaMM<{rxsYy2;NAAW9RYKagcOhCaXO1zsc2s%a!1?c}EIx zXefDnQ9oOLJL2At7j4TN5FKgRXn1f-;DMS7l46S;#%tj!yssC`-Xi49Z~0W_JhIK3(LBJz6i2`=15$B#IQAFxeYKsZHO>oC1U2~c0%E0ocOFLvt)#An1SzzZib_Q z#`op?iZQrGpYOx3&Gr`Li#4Q#kh$X95CBAvT-uqbiVz4wZPz}}0T}|4v)}&GgMRcQ zx5@OHP%Uc9xpk*iG+*~^}`7{>79H%!Y4pmjVKnppsE7uTp>F=j-VEJ)PP5T0>U z8S0_5&&6})sqep#GlTIR0IhnrlY76e20hX;TGOzf-5*Fr7z#0^#~50`Ql=45gqP|EO_y^}dohHwKg zhn&-oP#wX@83B}NiqHIGB?@!pa>-ti5qW2v3P6*&$$Z}PmWvWCSBU)Ryz*xDQ}Umm zl?cpeAI8}0#Uq_RYQir><{k?kdpD2!N zP#v!GV8)l?G(?eeDjbw6ZO1Y$*-ytFT<_6rB}~C@qqQ-bvC}{`)ZG>@q>&(O_mCX2 z?e3VS87(X!*U}E;s*=$nlUtMw1*7gTDiK5BS%U&k$(YV3WTVLYugtwIf=A>li~_kb zql{`WvL%DblQbxU&_gi3AsltuD_W{XTI6r*YycZbJE4e|RJru%$S>1cQI5(~*WtkJ z-BYRXw3h>XU{!qi_1AY9VK(6eTX0pad+`QkqepLl0Fgi41;ytl<=Yj>a%@cBv2^+m zC!O91z!m@f-Z_R(5nLZ1nBFm%)}&B=^X2P?Bbe*%dAhSYB{>e9Pf1Nefc62`GVzSiu!Ht!l}2z z@5MRj;D>_M>x$4&=KkN8joq5|Q{MHP>yn8PEFXZy^X9609w%+@1b;#$@Ne|^Yu$Oo zuAA`_VwS7niJ5K6yBKVUXo&8%6N9hWn`=q5oj$mq=E>qSEOwV&rjh9y&z8xS<9P9; zpc)HkDYYRddD#%Uz!%b{pp5=HB|pa~sxzNhs%&uAIB>S9CK_2DkbpG3%RW7;b^R{2 z;0z6}hjlq(Bz*-*q3xuBqxsb-KEe*bD+bdk@cb;NG6|j;jcAZ7uVa&n$rFe#-dcdU zr0q`wfKZDMs_h|IHQoWi~ORvKswX^1RuRua@{PGUPH=T<~_^PrN&A9t+5 z6Dw1tAC+;2u%|ZIhwy$<`SV$BUwYA7@AP;bcuLj^-praD9|4@W^PRUS@UJt+8gUbX zz9SC^w4Z^-*d6Ek5IVSE~$ndVHXT1!Rz-zBYKu@Uz75AlPEJUlpklPpGaeb>P64z=LjbgeS((MVVjk#vj0J?Okmo7py{{IRR8~>MD<$3bHJ0+KEl>KAWIw+LY7^;`%Nx zjs@{1^WLDoEUs}apbPV+B8N?JLUU9V9uDuOPk9+WKQi+qw!w3do}4RV z{jv6xK2`WwMayu%i}N%gDSbvbx_A7ve2-$wa+1q$78}0Q#ceTKT#i6QS|B zd>O_LT4|*6d^FfXFLri}?V6nN^1Xs{jSatP>nB9dtGQO+t~0UU*V2DRIc4nu&q2Lf!!%w`33fx#FXN=&mb$u%*k;ND5+H@b0O;}+YMK*WQ>~UGnabc+U~QC?dcRM zM>bqTKaa0Flhc&2T%7bu|8$;re=^7C@B$aY-LH|{ z2z{Q&3%X*!cw1yGAu25@NWT+h8A#>rJEV!ot7h>jUl!4EEeY!&2wP7rVG0w)R+vf^L`^|nc-KR#=v_f^$i7+q$B|kIIz(4&FNXx z1T{v)U!=w-7A)$WdYi)G0FH@OD+nhr?IwNs{Vm0z-n6bB_Z+-M z0p0EnH!G5X1d=um%H)2wfM4y_;4kB3^_ba`LC%s9O?`yi;LS`Y@Ey4~LfGY@16673 zJjDIv#S-2BL)=*hRoSoop6>2W=?3Xo!~rMr=k?v`3egQPU5C@r0f4pBn7Q(4ko zi#WGF`+4`?=iTqjIWuSG+kkaV^xkCKP5?DDzOtb%wCb5atZwFs@$lXo8D<_xs-l zB5!}Q8<(ilA0B3BR~$oif5Aj+YJ#%dR4cQucowK_7S;41ZgPm5dm0h#gPKf~OEVSy z?TVCnC}JNI=@$Vkhw9C2n5Qj82luf|avQ;6OyKe0OW@^BC%`bF%cVVn#rBGu`9WZa zipe6KT2C9nXy_go# zvVKxXD)!KUoPjAML(0e^>+5eqqX>ISF#G6!#JtzsIG!#LQc*o9vmjWcCegN`KHEtE zt88=I#Y$05v|dezeP~+aNpbUMisg`cJ*EbTfHqxUeTXR$fI^DNOH(AkmU8N{A4&Kb z&PYvwH50tE$~cjYFVu5T@z=txq${VAM=zT;P2wxJ zA2+Ho-$cufMctDG`vRBATJfZdz`_p)yru$5Inb-qg9O97MUw~Y8h<}_V=Bbh= z#be_0Wm(B)NRO8@D?YZ)^}Sr{yb&xBS;J?;*md2hyyBcW{w*SkuQsv-xNXEO;`z!qIKuju5R<<7!)ZaiKjj)86B_EL zdICs#47Y6NJb^nzDGE&@PU^PR(Q8nV37u~cDGulfWusWEMe=@6$3g3bLUGO@iETqB zA$rmDqmY*2*m5A!<~LaxlDAj;y# zZn(4Do4zo`KHoN1?NDFS2{xkwh`6;?n_x0!=x3dwQ~ea%Q$qViL0^M3L6Vj$$xq`v z+wLo^Wc$83Hx~DCehs=XE?nZ^l*xqix7ZVulW*?~oXmQvIm6>B?HbVw<^te^jwHuN zIy7RyR|`L3Dtx`(q3_*kIQ^=E2yW5CF5T@usX;Yc)p@PXE_qR)E0ggvT$YbE>L)}n zT=O1jescFS3ZGL9phJD}lKTL*D7H;U^@V~7e75@Nu|u9s7Xb`cYtyAM1u7mjO-=v- zHrEzkH8s+hlEmU&A!ry*82({h)EA)6->j?JH0j}oM0;mbEhy<5Qt9-((*DA$) z3OsLyE)N-!H+LY7a7YKxv4DbNz1M9S>A^UUq9&2YmeRAGu=o0B$zV=w`vgUB)ZA2* z>Am1V2AdAsZVJ#hf{(kPUrKQvsg#R{Qmv3Tq?yOWp9{44|G5HiNch*@u!C=zZujfo z`&P3po<@7%1$2U^(9JG)jbTG@cJ{2fdfbs`l z&0cQG%qMy8DQqpvMRIAkU9OY%1$b~F=K;li@MDQl5nm=O^PXk>1n)bd-!4}>QYhC@ zC^TZ|HG<#{?RWWYFafi5AyYDQVW6K~T^XzYHJZdGa}wPk?|j5%uj_iz`il3S(B-j6 zVjq$Ka$a4Xtm;&FCWUme+ZYsozN;{f0y*!8tT|ipJA!N3UPtO%#7JyPxRPptXpLG6 zhL(b{y@hFZjKgk(x8ellS4g<&aDEKYqJB_@$KxZ*SRc#Bis7Suz&q^($41?GP?pv3 zS_+HRmL0IXbRO^pH(n7_;vXR#-45eZQ4NrD=UjP?SA~c4!3c^$iorlFE2a0<%mQO# zlwPC@dK{iwVU4^c~SWfU*D9t%Fb5K^~Q#emRJ`CZKd?i%@P;jNx^0c z;e^XzsNUt<8uMKg?&t1wXG^g^Y_&(_-k)B3`NYbBwFIrL9KP)8D?-sSH5w~}dp z-II?fyXjK?4**RNLgVop&S0R&qHYt>LbCW>+vW_y{Ha!GA`UIKAHY5qDw1qcl1 z6@7Dxek`=_l^IvSSRXIsizeqOx0j*}PQAw0rT3F9cUYsKRr#c(XG9&llraCk+%Yl#y}iF|+Hi>2MF2#jR()zf z{#j#ltd`K_V`K9#<;S>P(Wqq=?_oZ|qf%2((I+UbnC73`GZz#L@Gi$W(7m!f9oJL) z3U&6fB|I}zXblso<-&~JAK3?Xl5bhwY13mfc#LS@v&C%hR#_E`RYGK9NL5jFMZV z7tZj?l`(>?+2pyq;32|~my}khBP+zq^4=cjY3@D4gs1=D?lWI!ka5hv4GW#jas%$JWIg>|nmjq_8qbe;sFa@IGeI;6 zzhvX;avHcUx5jYA5J--L2BL@LBOmZ@XcAvQO}ch+rM~=1SJTnNzevwlnT=5AD$zJO zNj{|Ord*6}+JCXs@q4+e6%uwQr;H|87s)NFZzE@hy5h^py4vQ?=_9qoKl65Qf^TXA zddnS9NbP^2kbCj2x%#W(HUcu5M7f+zThHp#h&Ddz$RyYfxYM7aEhO4_?~!#GDm;`X z0-5^QJcx|Pq^|?7f;qgw=GQG-+Wfp1J9dYq(80Y41+{EQ2<7%sIxOCdhB(hPzyErk z;C@Be&-irW9;0|y=EQ0ej4*yjc`?T9XKoe)U&h!7p}yeL)Bwu&_vilnb>*j=7ktj` zce{Yej;kO&v`X81&#rw4{cD5g4V;_2 zynj}Uz>h8bq_w#CfAWf~|9f7s<0GucvJx6d2GR#N2_GpFUP>fGC1ZlU8%JXT9-n1w zQ@~pj?p9Z`+jWi)iBl3DmR;9zX_$BQd18GZnl>-Zqlr2mPzQX-SxjajX;79EP5Yj6 z3h1l{8O(O~7!P2tiUg)E1~FQerb+Z7ZzH{KzAkpn6}*(YG+{dN-DbOpN^2=wZtz>= zoVaGnnO*!)^Rf>}d1-w#`13Dh!sV`?dg9#-`Xtwom*{ex>INDj$wLj;^lAUVInMGs zHcR4=Nh`Pa{&8AmJk8mS_thd<$xqTkX1d}A)Loc(#d~lR?l5nE==zJ>9dQR`22FZa z5B5gXG(bM7=wCI9<&1Zk@Vbz?h`rbfl*vi{)VcBX34QF%rt3yVuRL2vOnrB~oABpMNGn~G#wNtSV6gWt_EGEKpLR{;%&4pGf-%#EJ<#OCR zZ^W<)B0g>8OY^cDse%cY1QcY<&>g0+-b3s8fRecm{6Zf;I6_FeOe}-52Q1e6t{) z>sw6yEUlb-%xT4;Clvrw|*@DwsHQm5?g#D^#fd4eGgZ0LL z%zMu~L}udGt`68ONj@wW3+P)@vej}%&8I}dVuDa77#v=P?H0~f3_kn}kvhcPi|)gD z#^9;5SO5k!N1snfN4`y1sUtfR`5`C>?uQ~J4l1_f^@ex{p-Uq%F99q<5cKVVU3bA!Z2 z)XldfIYVF3?oIb*@ro3?lq*}VR#(D1=Vh*O+=vSb@Q6W=Q(}co;a`C0paol_!x!1_ zF6d6?(wO*B=q?zH50LZR07MZg9S5x>f-5~M-Xb3ySLl13CdG&rL})uVo3Mwx$2pEQ zF(2iuBFz5KnNqeOTvq#5tlB)`CBgDWNv2E;G?-2=NROTPpDcvFnfJdSND^(}zd#VI z+~f^BM&tM5KNNp%LTj)=-^2IH_+?mq1+m;uuC|m`q!83Pqnj*cd=xy7G#&rOF?3kQ zBs+XNM@?@mJJQ`2bd11fUw;K=)>0j?(i8y(xhbCSs3m>W-b_ga-7>4RZ*7vxsypM2y;7biaIAE4 zKCgGn$hwgvsey z=UrwG?J*!5xf6j0J%t>&vM)yqO)0imJao9!2yn^SkUGKY0ADxQ20wDVBl3E1+1ke; zR@oh64RK(Kkb?`V4@TDeP)UK}^ zjXO8K`#GS`Y>XN0QL7`Wqv1N(!Xb6k09aK*qpD2eE`Q|08W=i!llO(M%D=1`1@0At zE33e(ZP9y)9C;#uwb6>*R7K#wCHAM04ts?NGjlVW^0Ni2sjsKPi;{^)8@!2kVs*CQm9=aS{{mJda{ddfur{vw?JC{2Q+H!V$G`Gsz3Ms5Pw(`pINiSgcH^oJ7`eDUU)*9JB;$ z*@JGo(rF^#yi;OEBOa1S8IW zSXUzVc_IGtTUr^u^<5U?i__FUYS(gfXgMO>9A5aD>Tq6Ssa63TGHcB$H2T#$zi)%{ z`8vai$L!YbuOJ!X(8RQ__{7-&Oua~d^2y6?xT^mA^Gl4eE&U&qC+gkSry3Y zth#d$_Hf6u)aty|?aP`w@`uoH`4aRYJ>ej5xg?DGjc6bkjw%96FA{wu9Uyb(SnO;Z;5T=ENQOY>!@v<}W z6hEdGq(%b~vT>s*GQm*)iWq$}S7vIg?aODmtbUA`u^QVh0a>ZX41GZd_R!IC1xQoj zAU3J{FR6<`jiL8}L(yn^1;Oz&T-~c?0vs(b1ip($FGk-bxo+H^vHRQ++>ChpO@|}u zK(ZcCA5cGtMqr#pe>fId9BR_E>|bmw0)0>}6b-_>>@w+_uoio`5&;wwSn$}!3<+8v zkhH38u0*kee6&XAaa+QR@Be}N_3Me_?HZtGaf<~7Bb?4@z`ulWekxJwn(@LGzvIU+ zj7x_Yco1jAAqhrMjOE36XWVFMG?{`}5HiP|8xf!rj#$u+?p&e{emXpg#G!+3W_2dpqjd*Q#ywMJP?o(sdc$_)uKrzM znehW0Z>B@zs$8}38Zls+>FjQi7;pH-biNq9K8jX&p0*b9dih^O|ZbO+TXbP9zxR+Jd1uaQU0yQ|yDgR_`cDn>Zz%uc*1ARi`k8gR9o=mqZHeB1kU4TnnYf~sm za6%{Ba0d0?bd3-5-{@LRHV_F^@$@!WZEdndjLk}D+_$wxtuwsM7k6jlBWw}kB|$x{ z-!qnGS*oS(5>=j9Y$ANi9bWCXx}A`oIU?t{E3Z> zqprwCkmt?(0JvbRuYj?E9!(%cfHBRBt{-@7j+F*$EUvJHkP)T6CJAR82WAyN}NF8u2!YvQo zF47}qqH@)Gi~_vU4cM}OfU6NP*cMdm;>mGGWu(y!QOh)pk2*io9%@pLX3Gf$TRnhW zBnAXujQ_=vqHX?gq>zGBj6U=dLyqmoun@55M78D}>)xycBDQWWx^%5A8tcth4te`; za(74Rk$zS@2j+Urq4(IiX!QR{fSNhW`;1nfFZDz#CU$ffh`ahrs51XZxSdGcl2OV9 z)ABplYp1zP_jbEu&h?dK-F4g>dr^ZuuGkfE-=!D@qYC2z{B?_QS^LQw)tD5S+7ZJP z2Lb|7VM##_j3?!FDrUVA5DxkT)bTLEGMGuYSyJ5eQ` z@ok&s7}4{IhT*pyYKW>4IueLVgJFBH`s+7|d>0ZeF9q%$>2)Q-f~0FQ0BdwQVUOra zO||G=^UUZaa6uK1DD(Yt{j|?SZOVd@Bp?HGNSY1ms&0!!|MehLABFAq%A;P&l{zjo_(CPgRBk2B{5i4Q@KnP zTftm%417{F?bF(y^A-DJWPDRF=BW*+nMhY055)aN^`$TSJ_0Tf zJ>T{U!LSo#l~R>RvI1I<0;BgwN;_gAOS`%Gr+N+OSNk6BId9Vw=5X%}24w~xMtMyR zi7N)^YCOh&oNAxaThTj1M!{2|uNu7hR(yTB(PvBER`41|^HIC493r`-YRV>6wG9<{ zm$eF)rCoMW%QZ^pI$-2ArQpw$;8lPxflyFM_LBCSDesMP`v#kWkh58i=={U@;{ZySmkLj@{gMc=_^%0G+r z(qFlaUn5lzO)aos2uC{XJwDYfr#Xc0t}pP|IDsHEXE_a5M}W#ZUacF4z^JAM;+q|4jDsa)$b{i$>i&$Tq6o2S|^C7kzh{Tf2vrtMObwBIFAo zk{6R7aGeI8i9wH#0M~y;Vth90{3qdFe5XSiY~Q;%ocZ`{w)2%ddSc&5X|Da1uAuCt z{TwK1E!dy-z?T^UcbS8VMNLy4atKYZ;eL^Kor`Y+kq@9H7XXp3K`au_FbiV{kXx?W zeAl+c*YKDR9$P+)M1{$ON#j2l2Ns)?A=MxqYUzc|#-HjB5?KEaBr#`SnkWSo_L_UB zybG?`zZEE|r9s1zF;k#%^q&!C@e#Q!+Kq{1(sC`r;iA)o6@mW^%Ech%o4OZfdHS`X zmYe(LN$?J5iXFN81Bwgf@OE_@3o@_7Vtrmkap^gIB-a-Mt~poj;h?uyMX~X$!SEA} z@7XME>bugW@b7g~E5IbEzT!{11ExN|t%={q5IwJo`3kU2_SYd55*cp!*T4LpWQ7*Z z`1c|3F9H(^26FFzVM7Xz#&W{{M(YZGRv7&|?z3D$8@TtvZrvtIfl7jEKcoUXl~w(V zdam&SqcJQ|8R;=e-f!#?9G{Ovu7rJOQ)sE!Vr%>N$K`R|I{idvjasJ&EpHzamG&&H+ z|Em{_*WW1kwT1@D>a`FAu+h*GWBL7Ou8wpkAg?1|vp03ak z;``S`^_$EG4OX;LiKJ3>UPjr5-{;NZ;hP<0k;rxker}Z8uDd15owggN?(hG4rcj>D zcdGw>riy3dcY=-V$~!5|WE zjSb&$5K#&H5|k<~1G~J(on|&eJwf@GXg&&7(HzMwW&2vi^d`MGFuI;^N{4f=FPDRl zw4lP0lomutrqALox7cU8dxv6VtQch6FwYv7o8j9`Ah9SBZ&iaxD#|R&*o&7o)_6YVMGV=}(4b5=mxRn5~Iz~y$9BpKL~*EHPuS=^&IZEZpn{2$U_CtL}3asvLeNz`(nIM zHOd*|aAMwgf{D72L(>7rW=a1>RhtsM%-%fDxxtr;hsy{3p8_ad&ymgsgR{q80{4)6 zr$z8n%QwJ%>^v~m5^*}2HgvVJqfyx%3G54E0%h>N)l&!E77RXD3V-o#J|G}LLj86e zj0V^UlU^1-eJG~`HEC9$N+ZAgAd~T)&^f*Qbz5m zt)htI*fT##&WQ_x&-X-v!(gNfV!tsSFpXrSiJtKFBg_vNrZ^kwYvbOBGM}dh%}o+B zF2ABD-TIfIMi@M}V;z5uW9QOA@XU*Fz&Ghq&9LAM>a*y{rUaJF1kXc!){6fd$%CL8 z7b83<^R4qF@}XAR?o~S>CWw)SdYAtZtJ0cB6JM;OW>t0C8Plgk1lZBvU!XdOL)zW& z;BIyE*`;ARW=!M_pHU{<+?m$Y`oOHC@!o*^Gs7o$xvI1>%AZpI7xbp6=j-)Sf8vxW z*MaUaJR2s~L?h8+JJDUuRAe)|tpWb@kktGah0!(iO&FI96(7M;vATyo&|7?77bpIXUy_%%%w~xOxfl zaK7!PyxP@513M&h=}Q-_3H2m#TH$Qp_FY?kIoLvBT=v4bP3a6bJRkcm{7eDZs5O3% z7X4jLIZOxtbDgYRk*9U2G2Q~=6wPxN#WT{Qm|FcVU!JLg&=)6M@7oox+OU(MFTrJx z?#d(~6eMNeTNN%Zr;7{zO2M5!V|H@_hZ?PwR|E!xiCrrcI18 zk32VQNM5>*>_-ET8tVOp2_ITTwd|fU)ICzWw#mx`#~kpfkj?w3d?T}AMvfFqrJXDp z=I|?*XfJjk$$e!x0=_d6zYqqBl)9xG{qaJEOjW7%#;WoBSKrM!s!c&OwdvfuAI-vA z#WEEI0i)hn-+!{yjn5OAw&6bLAzwf*b#q+V<5LxgX(s<*}GD){kmm6a$7&D zhG!?QgIMor*s|$?9ony6`&=o&l)Y5b7rV=4!Tm%+j)y{0b&Gwx|H{gIrl-(P&HKtL z<;^jJ&bGX)?|#aAMZ@htGdY<@<*I8|PIisRXBW&<>>HLFX)3_ie)Gv~o`nZm4B?lk zKJ?)y0y~%q4tO|rfO@k=p8sDc2B$(2YvZ@|Q!MJzW1fSaVlp+kc%()G^E0CQPb?u8BrLGz0hC$B2Un#BEzNXF;*eq<+ig=qaLsmLZrD zu$arXWOv~+{$leI&(Fo7_EwUze^eu>ps5=?Ow4NqtDX2F4?>{Pj0Uy~t|wulcQ9~? z0EM}QFU>ixI532b^Twk|WNeUL8OztXH^@+6_<1TqoXBQK2a#q+%&Ce6qOlLzlURu(q7rmgUgWja80R(1o^u<6 zcUSO?t`ExYntd`E&Hti4DK>#rKGf+1v)oEiD&6$iC=asE9uHAKrphaARSr(j=^9!vT&-RDVeEBjd$yD$oNdjBo{i}Sv+u85Zky?aPrghz!SHyy@SGz&FG32KVR zg*9iv3<_xssBqB;s;jW`~Gh>kr{Kc?EA(* zUm(eKbjt}S&y#qbDf3S!IZ%b+Vrv^}Ozb=6`j@@KD;Bs={Ji)N@F6o(w3Six1*LEG z&EJ$FMC;!uMa;XxYSEj%{3eP932gsjmoEzq?jAo&xKN9WBpcM3SS5P5|%| zLKmxK*r6iIV5!a4*TFz<^qp>0CiH5pJ4f#d?D_Y{+jEe{u12ZsjRXcIl-7C-2s%Ak z>tp&QJaEtnww*ROl!HpFeu@i(gE_0voGBOLr8QqQMpnge?)q!>>- zZRhQl?9~Vd6RpkFOhie5x}aG=m>r_-zV5B!U^;N8?IiFrV~7LgV$c| z+`CU;Kugr&$;^56$@(vKXAkjjbqD=||1Hpjlr8UrlgOuZ83XDSr>8cpMD<&(j*ea& z3XDh=MsM&G>YR)X=ByFT0UEP7QP&GkQ4Lti8q^3JF(&3>`6qt&K-~v4`(V#6;4`6j z*zw#03EJDy*L&gr8y&Dt0k4QgB?5 zFl~!Ood=c+u&t4Y#`rdAI*IZ=qKqp*!cA_MC67*N++qlQb7~%b$jj1q{lhiF{#Vxs z0vMFj44+NcT|ag&XX5rKdwDahO2wOkzFpA84k39dl5Od(1%I$Q>3I8rHIb;zpKRM+ z;VN);&E=aioEcIDABxkU#F3yoS!NcqUOZ})PH3BrzJIwjj9&wSJt4VA8j>w=+OCx( zTMG{4F~VWeD(Jcwi|`Qn_8)zpWI)mX`f60??8`g$59n~!qqyxWU~MFN^yf5Bl)9RC zsz}t?WWMP5;AAe5b|KSSotm(RG~tJ}ElRhNPqk!(e$!gBBK1o8$L}nsx?q~9|9|4o z^j3W1Dt*9+XMFJT!2RK&#d(Mv{j)gd+rtoS*EI@5sG~|d97+p)5PSo>me&mV}3tqop(w37sl zHNN0TnWcKKVqTb8IVf3!rAsCUv>(v|96`EKt+9jRaxhQ_6Ws#gXiac*bw)W!e5slC z@FSL&%I~12(Y7Jt?HZ8Ak|WDNJ8t~lFEXz&7N$_&ke&NjA=U)bTeh8CPTK6a1=F~u zN}@czVhJeF*EH~Wn=zEI6KJa6h)L2W7h`*tY0-4=puR>1iHYTBu!(ZcRznz zyNFaQ4Gi()TaCeTzfs7gqW%fY=9;1D>t;6ihBW~v_0jbAJT@~<`a)~kBPodzFe$RM zER;f@-}9076_&&yIch6n4=&j46|c ztEyGE>pf6zvN@MNNo}R1&1uL4D4?!T>jDu_wGy2bA}9RndNg*1F8AfeuuJ1k>Wdx>d3XQycBUECxdz6Uv}#r2C)q2Ixj|_{=#H z6lpyfyj^c4Anmzx`^t=(G}Mb>yKsw%I6tSK7PC_^V_lO@$Jo2IRT=AJl!1;O{58Af z-^KH23?t2T;+um=whe;bwgP`UXgwT0+Mkee;*stx2PMv4rA@9kV;ohc7g1-{SeZKdV z7J0D?{ls_3`AW70H5bm0E#850tq4H_U!zI$^(f&{mJfne2X8H(lrCH=bM^w9TcpCAI zfePA!)>;QYz55x0hr8iL8!=1P5D4Ff+`My?c-S3lJc?m-iBkHfH`Cjo+b9+Xf64KA zc0G-S&xoG+1A(11!cc^~azft&hB31)nMFd^h%}Ozk5BZ;v$Jr1PaD)S>o?Bw+wVwC zfqp^(Jft8_e;SU3sH|5;Mv-MLVmTx^{G&ef69rrBP4pA^jz$M_o5cMT7Btk9KOPBa zZj)raiG@7r;T9lifqm>+F7i`A|01%{se`A_yFs%P*#oTSeK%Ur-DDD1+dq6AgdgMG zro5eVEl@sF`8|4N<5x*+QEYCC9Wc%h!fP6Pnb5vwkad7&qlS9_+0d<$B}BV8BeU4m zT*0S5ebkR9e5IMR$x-6u&U(&;=xz_KU6F{Fjn{6dUAVdW4Wnj$cZ&$ZwU7>1+;|HT znmRDt%=#&CSrNa8ZgzeqaBrmARs{w8GP=z)>!Mvr@)S=QLdKr?-C^ZQJCFm+9NjfO zdfBjMqJDbtP3-e=onN!GBjUmI#;qwc&hS~D%X@=qg`ql8!oZQ^%zBGe8hJjW1f$21 zL2Z`K=pa|rPG3|ws0szn5gN>8W{y8v+s1GbVN=>jZa1pPlN-n08n(9+^J7v0-;8n3 zu#9w)K-ha|yyIL3h>40Zs-p`%An-6R#znyJQbhey-hJ&%&>`lHJZHz zxtc0g#bSSFsU%|*#-E^@*619@%MGk-3<`Xh1EkqGdKd4Is+#DxgC4f}EweCpnS|0x zP7ypFT-~gQI=sJOK0{Lk!uNxUI)hquow&(zEAXx%x+(BjW{8W>$bi zu+mB(l`?x_Rq00xt-#=o{@}zaYqz-_vs@xFx`qKMWxIn@^l>s&g~Xq(_$-ND2s$I* zBbgXj3OjXzW?H4PeNTlVX2 zXoOaUEzE%OK&uIzx!a8XWGzr^J=psLZ`W!@((O4gyhQtw@`{bRjV^Z7CS&u0`3Ciq zp9Dd*LUvNVt-@EtaKj3!MqA>(bk8|`qEHM6SRbz`q^%xhbQUd!jTw*l#^w_?GOMyZ z&{lvP4nF6|`X1xpT;NwPtlO3>4l{|ZT8a#XRM}AzDGPS^DSacmb!d z8tngVj$A%AZ&a2;cHj)Q*A2+ z4TXrv-^`QEYLFQA5h=jBOfhQQO#+tHb9Kx+4Ib^N*LW1EWa1*b$Elp2uQu#kAZ53*ndBO;ACo$R`2egHheF?=LL4o2s-X618mzAV*P-zrK3# zH)mjapamp?pU1ox^#Xlvq|WD(H)bFby%M7VR}DNUk(!OQ&6(pMia73~Jzr$WHOhp` zWyBM7hgy#pdb`C4o@hbTeWbDSG`6nxhq&&pc4yYj#S^D1bkg4*#XV<0|7tZ|!(%@~ z3Hb)zUkYpbjABX<0X}ZcwLp2M^^}J7y&+o#HeJW)K{=vIYIHleIL3blw!SL?4`AZ^J#G@ntcAx+qcRDXdmc*IH!|84;G*>KN#G$W{(Ed&i9AxpQ-!Urv=>u?TJhZ`JgFsl*ScM;EXG+YSw{k9~Px zY2k0uBqha*cv+AKQ~M5LP4z!35zSpsslBJ9LKL9&K*4E+5RQxEoz*i_ePBLxFnAfU zD%#m;<*7a5LAbs9EJ@;*C3wWmjjzg8Sv{20*Ipc;Ul2;QBhgp4O=w_EoMgGUOfm^R zvZ)5X@kMhR8~q7`7WTdvbV#rpI`cyr_g(;r1bNQTaY{^*CAkam9r8UMcY_1KO|!j0 zL5?=3?~{(P$hhHFq1i$L9GEvZkv7yA0`&DQ`fU6dq+YY>w=}=)1A7;BBIRkQ%<{g8 zJ9_-MDQ4x}8NoN{nokTAEt)h1;}?Cd;i$Xr=A2Q_=+}aX0^BDa4X9i7iX^8cjI!D? zF|jgeSYe#`Fu#>7YC-nr%S?ID32!Q9^qY0|LwS=F^gwWNzD2e#mx-oiq1{{Ge)Y`y zsQ~{QYsik=jMrwRVc30_0?*sT<^ZR9#w?SO*S@9VMgDy2D6}tT{Ny|?eu)naD_1Ut zU4HxKhx<*;{szcwdI*#kAFvfHllj`V?j{vBh!k| zJV`B4MnD@}V<{m){-jxgO~x@bIgOfHgVl?#IBso34T=Z<=F=rlsu|UGJWJOjWg7jZ zE#{W{EF3HWpwV~uWaKD^XW!Umx4+8XU7eTua_Mi}D~g|xL;F4+1AY-`%)+JPxJx@C9MR%G!yEiL)wpCEY0fw!FEox@W3>Ra$!gU zDw>C5YN%R3Uhkh8g-J0^OHK`Urs196Vn%Uw&?|~xvLa5mlp@9A%c*!ksy6RjCx)(@ z%bwMjr(xeM9W4#9=Lyh1KH8 zlP8p;pk?stnB}8@-BUl7ggLm3a$`SNLlZS&r#+kX2K}=(y7bZa9thgwleFZTB* z{0ONPntH@E17VkuY%emVqa^z8E{+`cOxUfJYfCo$j5bx+erFPI`E}Z~mmxQZ22#{6 z!UJ#~YEvVG<=N!eaC73t)1PY{wW!|PNVUPUgO}l~0(L~ytR7wJe_mRY{AxJo3^Rib zS0IyU=ozPv2JpP`9YLE&XW=NjJEk=!<1BSLo(GnP<(T;*<)H3`X5_;_M6=S-<%{h}OH86*`$}GI$?3pSAT*u( zLvaRagxmNeih*KTdK`fxf_uXZN3dX*W@Fh$8h&C$py1(LsDio8S+Ec+&R0qNV?GW zi}rpj05|$8mQ^|(qk{&;>Bpkgt)|O(V!nLnJp@nr14C%n@Iw+QhLv56Bj~8YmJIlf zBn{mio|3bww~nHzPw{FG2?-CK1Ftx`u1+`dpFI7>FBn0PMO&-3tXB`Jr1xh&d-x0E zb@t1LK7DR{#lhqJ12|T_;6z|JHSqq!0WLh^PPLVA=BA73tMhAH@V;Sg0sh*dL3oop z)dIb|mlqS?Ou|YtI#un%5B5F7xQ8#iKtOwwaqWthx;O%>2T}ZE>${z2XY^IwcZUK; zyKeWhF(qoGWiKmo%x;0nrNhRXtH#5AJPBno;b{-tD?r}=48n<*@WtBfcRtTlA|R0Y z)wI?+w4N%jgfMS^s0q|ng&XYQSgfTa&T1>KzNX-QPBLmtw{n5Cq>3IOQM2M7dRD^B z{3yVa^reHE6fl882R5GJH-F_%)T!ENzZvQqc$7|upmuccfa!NG7V7_)qIQU)obIIK z(p^bp-6ep`HwL%EbcmSVDs(P~fW~cK%4b)qeO9kn5p;6qzL%t+X5tl`co#>pBC59@ z0*Zz?58VIYsJN(=ZZf-h^ra>4&d^~?GJHq5k^L5zv{}* zq&bq-Ts;;a`dCoKUChv1U|xcn-?9zC0s87rjoS6nafyDbr^|HfFwrXXP1L?1y|gI$ z`_X>*%974JLc@p8i%&fTfy?^mX-0#f+K#9z2r>QR*nskLupR#W8xtL$d1U-X?obcS=JRiQR7n#a@*b;+6W@R9kxyFEigx&=66x)pX+`22 z%4z+tK-rYxeGCjhtCQ7#z9B6f-*8B9lAi>NV-r@|M;gkDyDZ7OI?&j=BY)LjzMWa< zB3Cn>6Ta_(<#_rGCOKOp)0*JsIbkfa5gf{PyXiV`sGTA*@cB_C4RT(>m*2OGlv$}J z&X32pnN1smMk$q^^v-WhQsRuvkZ`wal2&k(EMIzCr|j^Nm-CQDW}S72W5-H890?Vb2<; zonHI3Ukz7L=LY&CN89nz!r~Nj=eoagH50jes@auHWL|r6cS_GyTAmf`byw#`dGmkE z&@yL9Ba1Pd>F-TWA7z>=xVo^d$Z4!?q-eknuQEs~m<>A}V@r-b|FoM!1nnB~nU1;b zUo^NYa^e0bLCb13%k>h-;CbfcTwQ7Lu-N`jQq3?X%@QI+UhmNoc&*OsJhzu3@2oi%% z`R{-dM%RQ@wM`k!;EG4c`)o&MSxI662rbRr{V!>v)wc@JKgaQzZ^v$lp~B<|?B%Iv z1M`Mh=|e3sjaCv}+0x?{?r~N!p4Ll;Rdhzuh#qlI{BSz*NiVV6mR&X7`c*eKn3noh z{i{FK;kC!qEs|H2(o#!=R3IYUSvg2QT;=uuYVEDVs@nd3ZMtC*(%mT1(%p)5D-F`! zB_Q1;(hW+dbl0L=x?8$?QSa0pXFun+pL4Er-n0JULX@=}bBr;^xaU1S-!9d!#o@BD zK_gV;PxR&MOtQ^%+WmccYQMspgyIFFo*jzGiZU^YO=^?Kp~w-KHbw6!IDA=%-a+JK zay>W=HAG{Lii~jhOpx!0@*@FD;38v5>ag<{2P}ReK5U06c-Uw3D-@~QNIa*t2ET5V zsxrl}Io-J6IrCYZ#Q|(hwkGEVnn_w+jj5ocksI4OL)gbOkQ;kH53FoVoBo1=sZ+>Adn*P6HoLWQM6kp$V&n?8}$6LvQGrg=oiC zpR;w=p#bODwQ9=6csMp)aQ=xMHdqWj~8n;(>GP~UxGMzEJ#KlZ}%F{bp` zB>Ub-bZ575QyU+7c-y^`Ou-y@*^gHZ9Uj3AK>urp0uRTl{4dl&fygAW1CGbR<59Z`_>@pr}$@2!?*P|9+2 z^J1(MOi29NqmyTaZP`q)zzvza8_Q}T>Pg)ewT~BW_VH;uH=050MJp=XUH(G4PM4S)^PAW6Af zdRD48hv})q;inFHC1o|?X9r#h`g5xOQ3LSUgp`6>;=Cdc4jf}4KKGNWsW z1IPa6*O{%C4802tsIE6UX^z)YK91X5-?=G!#krhBB@HT|J#QY4A4LNk3_20Dh-*Bc z8=LUvoZ1FSyRCa3gI&jXRmK=KHhgkX%C~h6Sc0I4Yn)+=4zcA+QFqnPv8Zlr(TJ)s zYzU+tkz@PIq_e9+N!m!=zmfYa@!d1_R;F+kIpBE5qHIEeQIr(Sz`KfPY-3SYGr8O% zrg#>u5@hBg6vuipuUx5I1bj1ItY&bH{U6br!oGjf8?65gy@9#}f{q@4%o^n-RipC4 z3QAKIiBGgNlD}I(AH~gP!VFz3t%6Nce8) zp^_5W<{6uaGE0kU&NXO2_a^T|f54#NLacd-Z{I3(SrA7iRgH#ty$*2!Uuf97CICma z0+K4>e5m_;EJZfKP=s!;-|sXN7@EO-^j&_WqHYT78IYtD5)e7?=O?(8Ha3{a9cvhq zh>8~Yh}LArzZ~5b9vrkVQ#tCiUP_9x$I9lwL&7f7#7g2IVT18HbpHHBm!}j6cGXpk z*=|(C6zHAi+(#-cn$ikX3lOX_5$lD}MDjLbd3BPjZUtFY{5{68xi3zKV!vXJ96fg) zUV+9JVT)1xV)wzP$kqz?c$yVt*WZ+RE}=*0v+?oe42NjAtYCTP0Lb~%fXy7gQ*;}1 zhuowOp1vpPe&)Qlrzq%-1&BCey|)^l2`3;ES@dBD3kh*cb8iRR!69(U4u(hD(PSbX zd~|}g%jb4dq~wF>cR4vRkC6+f;gEB_>FbY-bV+H%AD2AY`lc8BV8XATjgsHF6m&{@ zJz-qX?AZqlp4VT+LDcUd`u3tH@+%QAP9tcO(RvCVbq=&gdS9r0PT!^xk=q{;R9ZgQ zo=Ht&=QshTbK0BlPF!EQ6#1t|eFD{3Ne?3^8iSO4PONw834tSdWe#GFYn{lSi{ZMc zfoP+<;EHm3QywxWc#Oe0lXQ$t7|$obxTZQRJ6@Wnx}*dzF@ZJ=M+FMJ){g0 z-RhQ#hTh~|y<+35PJ-^b;`AthzAGp0AqxkZ%<7FjB8Nyc#eS3i9hGWF&A96hg`0&M zQ8OhtMg$)pDm0*93||4Z4j0Rtf%(6(jY91IOSW-bO#4r^!9TZbBXN_F9uevzd+4Fc zmpT9~BC6oZ+R5%rY2l7>=H?g!)$|v>+2BrnUZq$CM`JQB#TPS7qr5^dxL0qN3EjX+&rSZ%u6 z?cokjBhpZ7+dbFw4sLuG~d1J=3X7==E3WhI=tPrY~ z2#!acl5SXs)`1ON7?yOjf!5P!K#_V`m~kzf6U_UQhb8}S+D)Qq$(57e`bwnj4hX4)GfCvl9klS{YW zk{Hu63T~TD&s$~}=L*c~%o$%Vy26RU)aW*_=touI$2TkgFJXh~f3O$<^gPeXC~x1V zmeTrVdQFXEzxZJ0koQ3XZ_Zmef`E^Du4%VNPvxA z?}5iMfP9<74h;{emmPPxw5wAa_gmxoV8bk|JH4pRjGCMB12I&5`zGA|2k=Ew@W*4X zv{*T})Qs*W1H=W7^c&2(uCJWaW$C>(>A~0YGSxfM8A4GH_GY2sWuuioSY3BLFBXq` zG1G@<+XMoqgszzL6NN$@jtqSgs+*BM{of%0UyA4dfkt}d@1}q(qg$xQC|oz<{Okux z32FK>!}MEp4Ul1rBie%l?c=h}PAl`~TpSOR!g{L^$K!Y(P50V|sqPQ^R_y_YFj)^M zS1i`eOU3p+F{rSvz=@x*2PM4P!H*Y8}sif%*u+;+taYl-@FZY?5 z|Dp-&ZDebq5r5%?klrK)w!goF`UiEOlK(;=^%q=#`UiEuGQD@;RDZtwREO#lhLpg0 z5)7aw;G+k7ZHZo)r3+jH;S;#`_dKq&kv5rKB!>2NKRvphLZUvO7x!#dwYh%075cKX z#KQ5tlw!ADD(wcna;27@H2VD8?YpdEWh<3!PJ^e*JIz9M=;Rh49&7nit7qu!N(Q;k zx!h(>t9oc#!!u67)_|e8^Vwdlo^p`pca&>-+=8DIZ{F4*UCa-=jSflT%Si&U%*#On z-WYciVC?4@9+xlfKIEhA`le>~}5RwvNWi`sw<8%A&{*3T< z;t`cg7H$;*YOMMQ>=~T%_=iF1T4HW67{5CeM zMya^ysvj83CIC!v8cGR8WPeI?wM*7FEd4+1qDU)^nREg0)CAZDHRKAYQ`h>D`~`Ry zWN%@W)C6;^<%o$Gtr&yV5kJ{{-+EdXD0#=pbyxAn4)E0t5+?& z-f!w*`=tES^?_V@J#=Kb*9YV$9j4my8N}a zW?bRvFst-bLr<+={KKlN_}E{PWxX`nUq=O01iB5ctAvPy3?fl3x7BWlO$3=U)htdz~L$m{~=+RPzA~D15zsrj%xMYQ1NJp8NCx#2;ay-`CyCn2K>h zvNx`W<}VkZVia;X2t06jBg7U`c_&M=twc43yMKC4WcPRr$6g5%(V0k6CS)sA5sWa~ z(;v!J-Gyl`^zR!yZDfCL>u9V!lu)yG{6aNA*5E8a>LS(X(lwXhq^zAebm*&?D0 zh~Zht>{w6^t3`~NN{k^yFj6+kol#r0W)#QeWu1*KX^X0P`mwXzLO||C0(3vhq!rNjQ5o&okR-GggBhU+a zTq$c_aXtFHtOPtt3+5;~sluf5SIg&H1-I}!+L85+i0)S~S94KW(F1w%$JBq}q;Hm_ z<^vz9mngVotrdO_mH?9kQ59HmGbbO$HFzBvM)6jsK#oGr zU_ut20=XQT+$Qi`;AukCX;-G%b`!q0(}|lx>%~dpu%9KxZr%M5-0E?@+gRlg`YnyJ*j z^QV}Rbrs9yJw>3o0AP;DY9>k>MHmkk&xg9eJUfsBFFV}RGR<-akA4Q@F6qtW4_HhLS$ejKpKIz@|11TYy-^*Z={sdE$k(0mq=j+z$)0xB z7P7B(3h}UuqOIK+%mPMaWS<=qR%V{c8?pEx!rPA4sZb-rLm4VHFXPU>E9lL~l~pkC zd=R5KncHr;5V2H60Y5LCE&W?sH9_r`tXKL=5qy8(RM~ZFNZWTzK?l$~ok*|xQ*ZWs zRMf0}yy^U<5M7YWz==!Sr~SiT-jz(9bh?T^xfP{2?=69H0$a6U^5!gS^@EU}N4k{2 z5im>xNzmIP_Z1b;sH0zXXCD2-BO1Y#3-5PW+ON{CV&`sd4Xz8|8&fKP%6VFd6y-=Y z&P>Z7lL)U~(><7b>)Sp(J;?R(Rw8Dz_UMC?ge3+)5tz5|;!_{Q(3|q-2h{ucXl+n&xr|e^H>zSCw%Lz{_uz13{bGo;+o_@21HC2yugPTaM)A%`ZDA> zm4eQl0tOMrl%`a&^WWd-(L0Vc03|z-pG?q!^J*gIyvt=T7Gq(--uN`D>jS(8jK~cz zM0}NR@z;VL*~fVS@Xl0)R0O?x90BV>7k>D8=#+yPeK4X`hUnr8{Rn@i8v7mCMx+*vjspl;8IOr-j^1T`C!tlP|&MNEZ~Xd zf`P-{D=I2jb8PX*dOMgqRgi3EU|T*+jR_va!S$p%F%?!h=c;+&B;#u)Bs~Tgn-mHn zGx`vEJRIIUF9QwO=|kt6!LY1H439Y&r){PWhRLvTmRLSHBp59WB{~H1rtNUAbis4^ zrx`j?cPlS^DZ%;Mc2|1h^+~&q(SZH+G7fny%TWX^GD1*j(+`K(Q}6QrCh}!e&@&d? zZ}g{|LWJJlmvASSX+a5nOIzS_He5U;W__VR~pV z%6#t zi>#~$9^(8eIMfJ@zh-V`18RxhqfGJCSA(91R>j!+wp6chX-FyXFrnJws>gC9xE6y>J?9Jh|{*<8@WuJ^@24X{d!D7Bv zCV;po^g{qfm>@Lu;OosT^theRq>&UfpDDs#Z#jKSaUJ$G%Rp?~4XwQOpT1G~U%nBm z(bHA5e!OZkCzv&_7l=}%k(LCjcn4a+6MIqDYXleq{djP0nw|2E;!y(r8IEm z;_S|_LxQi6l=pP9Ggozbwo6Io6H2i@-#nqH6MF9B>cW`4<3XegRTH+XrXB*{0$pve zys>yAK`JI}Iv_~D(aVQmZ29n z3q!|QP&pNGCIWo_XG}uyOq`lC3G3EcVXbG#hF@XlN={8@Lw-8F<%{?mfK`V7jw^~y z!|b9Kv0=SrEu-wtk1NJoXSr7D_Ufw>KQ>9ye(Oxh5BHd3SYU$`C6^qSLaXmLqrlF- z7%p%}=x{DJdEs_!lcT_1xJeUND9cA))D*%M$jm4j&VRi^t7goR1;lIXk(hP@GLhjK zAQPqfGvkJ&l(ml}jM_c!(m}j^n8&p;dl}8a%h$Z$hY|lnGIs3Ovmb4M{E9V7|D z3rs{ajnRG?NBYl9_QWR7NH$MR9GlupTG@B4ESiu314xYSSEDQG%|sTEf9kFpS2tSp z`IDXL_}v9T-N!iQC#0`p zPXH28N}?@Ked^JCcGV72bW3veR()(4%dU~9r<(M)vfrBI{#@)@*6>#(QhGq1?jdzs zSwJDz1>qfPR}<(>uafLSSyRz}m6j%U07@2F!0&;rPl95Guce6J-Z5>aPZbEh^}iPF zM4#JvFW9%I-fU~rX^pE{iv3ViQ*iR?S47b%Z!>&LpCxIC(AZn`>@b8_?hVpk5k(HN zWw>;uAjFvbeN5Yu?z_BjXTVvrVtR~YJ`>(oj=ssu&0?LcF;FK%3e8Z905Xc|h{q4b z)V=XQ*J|H6LC3*cijJu6P2>(HS*^8XE9SjIQ+Qtp_aqnh&~2!oUQhC&2R`&^g*xBa zP_ry`t!RgO_M)^FWwt%D6(`GqK+Lw8{skD4Qp%io*Ata3NpM~I^@*2jhPKI4_;^y+ zC;U(14);5>YpVH;V~9y(LaYu#(#?rb_f2`3G>I*8>+({JzraVq`h-}aAn%cY@fxHA z;ddo5r4QG_MZblj%rta4{X}zMS(!<*z~oPm`Vv@VDCys+-`FwFE zI-NC2Ab^?F>$YI-U1)^cKOYR4ui_CVZ(pf|vE6u8EgZ13BcWSD++q)UW%x9xG+TxD z6t82pMrBiH*h22n)+r(H7i&_Y=N0zY1q9CuB6yRIJVET7ZlR_k)h5FA)IRaJTi)|i z6PAC&nxeNm%LGyc(PxN6GRk_$?&skKeZPuP^EK*Us@$-YN^P4-nDU<;f+{R=1`PB5 zj}@X@0Dw)yhO|DvS*TrAaBe4+cr|J8^n_nAU$rYIxaoPk5n`fhF%E# zSm9iqBnZ^ZTJSGNfY$W8mVVn_+paNPQsU57ChD=)OKCwOMWsBWao)0+1E1+@Zs?;}s@H+HlKfp(R0=x!T0OzBiW-d{rruxz;Q$`;}4&$G!WRh%zMTk zZxaw^-u=i>Rz(K(*d~B%$=tUzjd|(xRngnO&j5RQ{oDx_334v_lVIYdypu6pXGf?_ z?UABeAUFTDDflH4MKqTD4fLMozos<GteA&?$EzYx8Clh9kk-JaHhFI=5?=at|#doj7o3`rYM%lvgK ziJthIPMaTQIlDR{t^eKXDbo~Qs}cKJ!-y*DWhVCC>-!0?IM&(!_tSi6aj{l=mSW#4 zb+Q;=oMsU9%)27Fk4j(AV#EClXoKGbtfIqBCTzOSEcpkn18z{3WWlf|$En_eUzs&B zwvd5h58T9TpV{3p&;$u zkImIu(lquwO&AsTS;QfUn>f*14kNdetR0c7q%9XqthX`q2P%WqM&T?L6L1Nn6 zh>L}*IYn~C9^7#sgWhQ(ZZvUx_+Mi)rmTNpv)$Szna=7~_SpDC7#$vT8@-+)^S5WA zq7h4`RJ_sH|8$8sH}+F{z6^xCN#8S&P(d^z`zC|GDn}}z8#NREh7c#hunT;((#sbK zjH;WxH<<(}`og}~ogcyDuzlUeZ<^H26h9Hqdx?9l!pJkIq;L^)mXq(VC{Skjz#B(q z$h;Mcif1^WhQtNYEP6`_7nnEX_R5xXX6wX7^oa|$;rN^jR3Av|@&tPocprGD;OrUg zn5xh*ay_)L$H1Oe4b21T~92ExIN)NjtriHCI5DSB9z$x({+1L=^S;e zfyjOv?WPobz1G#LbmL_5Wif=86N zfBH3gxOZ{7I=|Hm>jb(8NNg~bTo+gF1&hJMHRix(5)t_F+;j_=`!1PD#zPCCNg+!BH#WMjviEFBKWvSx!IbqiJkkuq&@zZf72fQ zfq&2*ATpH!o#RaU`UN~})92S}ddaNcz5dhPnVbmRHIy}dPR|6>V$WzZ<+OR%Td_a` znL~FAr_sj1Wn2iklj79|l4c8VQ1I7|mqWM7+HdO-KR`DUfH_0kt|jl|19xIQ`}Qmj zx+1UR07nE$r>;WiQ0l@%Q8}pY4qn?vQBIzX6aT=6e?p-+WuX19 z4!CMsK5`r}qGFjA+m~JBCYE*k?7}auJ@6!od-eL&)Sn?-X$4oBuyox)@S3%lc6^YA8}rJmV~p$0ihsh zv)*4RK+!?Oq_f?$^|%Vz@;!Rf-2Z=&%qE)Z)}sF2)rXIJwZmc$bB?*?Tp@)wYFj(g z;ovKsD0;qskg@0g&(ICc^se~Pob@k>`)~6{B)X9-=^RDZm=IDm)~hX#KdpuDhcz*; zTY_7@o`~DgO;>e;T-R}=8$k5vbGYPb_e{fk=k%C&NiY|Y!WT1S+pnTy-5T8W($;^5 za)eITGkvKWl6x?zUU7m@cqMXrtT~eg^FW?wwk#p6lNO7WUW_-z$1i6RCEMnMBnEm0 z_>euMwKxTAUBSQ!8@47FZ1v>HyAO;68dpK^Q8U%XQ3RfBBNf zQcYtzF6^Qwcn0cb7kAbS7vOBgL&1VB)XFWC9+kVw{#3ATTf&~0)nQMmJ9672+tBVs zw=fsEDEQ6!3>mIv<+gTP%m)NKdc5(#x24 zu67`PJRe*e@^niKYk)@9#H1`!Gyn%UFQZs;Izy#` zyS;(ZXk|0i7v2e!#6BWz1FzQ~rrx|EsqP{1iUp7NcS8jTD#AoS3fjdyHKx6cp^Lnr zxYYCo+rez3!r@P|m?NQ}@pNhiJp&99%B0jc_u)KwP?fYvjo7Li!}rdqwy6x2rh~3F zNl2BZ3Bng_>9k6Q8f0AZ5XW@mMMM^^*i~rE0F~&-z))hm+%)02w&b8-#ZaY5Fo>5D z%>Aw!jH#?AkgOg(fMV5*eI7@?wOCw-^;ABc?Q=Wo#^hLu+uo90a)~e{h<15iLEHX% zy2x-AZf}IOq_EwBu5A@xAnGYsZtNawxry{zZ!!;uwk!|Rd5xx$7*x=hZQdwawa-eO zVbk6Jk}*_HmlaQvLE)=M?L;E4;X#D!+Wq>9-HV?FEi^^kzPJ9z;@62PEY*SQ0}O6J zgUv-^djjva$={0-rub8~ljQo7(|PonGP0u-6*8R!xhG;BFZUfv){Gya=p`;s2UAut zo8`vN^-8B;>YEZnhIp0Mg5GDVr|)HVDIhW#x9cEh85SDfg!i>o6!L4nC>7$XwsP(|OAuUKF?}j&~CxChprNf69v_Rjs z_%;!^%ev$)Fd2Lcw;1JyR}|j+$x!kx!rA8A{%ALI`KL!>vrJXg&oG@O15A4I z^#i`%^z=WGdR4nwQxl9l%eFEtzmIV#83yl;qT^7yB()f!eDbJQ>Q^%4_9~F~FD>mT z@+@rB*h-q0*|}eT^ys7iaT{km%yn;Cq(m>jzMLeA--Njy>)>O!^OE}Jl+O%;;^hjr z9-q{*fYidg$gz71>=G6A(7!QLL75H3;o(JOwG9j{xZ-=c$D9=E++35SP1|dp!ijo4 zFUod58`MfP+A9)upXEhKdok3b#=sHQI31|W2`9Xa#@#BhLI$H2wp0;YA)o!U5kA?f zO1Op^jI`~%);bfa(`>fxk%cW@PC0e7=2SY_Uo-1BmBO5MNin4-UOTM+2-A58Uy(Zy=BHT`x0(CfT z;%iQ32g{{N2R35-0gmf@Ym9fN{%?6^Annh)r(gEGBm{faARD7Wv~fHZxbBZ5*S&ur z4rWly?qsdJO`N;RdJo(A(1hG8Bu%?*h+)+c6)pD0$^mRYY4pC)&#p8WvSaed_qNK z3m!uQ1Xg-}p1GWN=>eE4BG>dmF3&i%rQGzn4H^(135rT_ueUQ-op8^siLzD%gAy0t zR5*ej4Y)7szfX{_ddF}ICp9IVAR?;d0zlyH3B4s{edx8YeAYeI1*y1Z8|l+)m3tY#_w;PJBUsmJ4C3DM|^K#tIHtk-i$)c)Ic2BNFN-ahi!wD~js8R1! z<=Y95q`%0G3Aq%|itu0hj{Y>q{u(2<+$ZRI1lQ>ZM+c=F_tH(yGQp4=Yozj>krI0T z>CYod9y_QNLdi_)cG3m@8q~Hefn^pcWRNYzEE^(mRBof^7`_%g&gZh?`lkbIfhNMI z(YN~5pa)d$k9AN`7!hwIL{(F9>dC(iTJ_p3^MpPuGj}FtbYNWPjQWJQnsPoy|5U($ z3v-h_#3^ReB|nN6P{CkZ;=(eF_OOw~zbhf12quXqp>+|_;rG9OFJfoM|HuqNXtZDE z2}-p^2>O_lCJaTOdTy<{L^XMtaexcV9Sk+!Ys+@Zc_;%g#gI;S`j7g6ufR7e`kx6< ze*d8ew)pR_v2>x?zkdcr!}jd=-!Z>d(AX&Jd`iuq0Ht3m!Fm-~a@+Y#L=ZjjA+jW4t<<#0`~Ub`;binfBj<#QQ#Uw5lr5y)F2aQZ{qa|H0 zqq^+KAqVb@D(Tn451n7}3ND_0e@gH{|B7MKFl46;OS2wo|7&8YC(2VG=h#Q+WAdmC z?TMuAHvhItn!ET42gb)9wzQe3jh30f(iORi1}6uvLyY@GZiA-s^@nwWWyGb5C!~&} zuzqW(iUMDy3cR4N!dkx?*sxa8k`3Hh58?}ogcT)RJ9>XmD%D(P^Y#7Jdqn+yRJbOK zN{tjct-EI3v>72$5G<2853w#yNvU6WooGA`+56zb`qu|jqd%Bp(+fTa(zup)r&)@a z3cs%QK{CcJ=%BFt<7y?LkH1HA+?6@Nj)brmH_b$4X+aF$OnoS(5>epxcvUq_V9{sz zcB8PZMKTGO$m<(=AVR4qqpBy!Vp+_pA;pat3KFcehGe1G`1yC+m}r6V#Noe@B~hxv zD3WnuSP1YsSJ{&{&rUjVAWdA8Kg$7%h{V;0E^p$g4?xA1%IfM&u(OeRJvr6m4m zw;HN73(Y=b4Ijv+7c`TAFMvz>F*|uEBX|{fC*WW$`MY5Xvu;)i6-%CL@S z8|T7FW}#F%PpGFjLe{TeBQ}(GA8ODIGgRcD+5E+B#Y6UqvpL#RW&hyc=dNYpVOb#; zq;0Tsr^eL=VxnEyrLRg@>AQ$`>>+m+Djp&zpZw=)$*R~977|yN&4#_{e-E6~m@}Fn zqNZ~SZ?70{nq@-6HghRs&L4%ArYE|nNEpBa*R2i_?h)c%J7MC~MTW41bGJ4ET7hO? z{az9)o~XSeQ9Y}44_hjPsgJ~Jbx6)F^q#-3L_iX}g|W5HAjYS!q$SKX((9jg|dQYaG1DA`OSnvg|%RT&smHgK@FNbyXzJ_cm&5U3-6|cIk zH4I@5-oJjKBXiPao%H$QyyjQ)oIVXd$oXG;j9veDQZH^NQq|ywOv|S3kR?c7I^~Yd z(!;pTf?12kMJLUU#7?!oZDV@d3LBKbE>*rdqptt;8`wC2xhl3{mc9;$q^MifqrjK> zqTjJTvZatd;fqkz)5?_ryQk*8Kxh{f-sSiL_)+k|YLhk^N1z3xDhEc(Q0e+8d(_9z zvz33lpC0tw!d+&1^=Zm?(q&C&@#&IFUQb;#;h5rfBXGykekhNfXXevJDg7cBBC}7# z_5KhwG^fiFo}cNo0bZ{`J;>`y;HYq9M<-ipP#}))rIy%9W|V#q@u~M*z?lJr-EzoP zud}@VbxC2$>T9OFA!$8}cCRYhZNfQ}CIiqT>RYAb)^OL>z66s`yy0wvQg)A*XnVW( zth-IfVTnQ-ph?3b0Rm#t6{{Zi2X+st+f3%G>Vd5Q=}elBAEBqwm0WGsnmsY{InOn- z%!Tlk_7&sxF}zdtuN_MG!))p!TFb{@$!PzY-<>!V&}IY!pE~Olw~0^6qVzLoWp3_W zPjJdNytptzwd#dEQTK4q@Kr^QHFz?wa)V$B3TfT0_-{J) z-6E;u?mRWI2F1S%)-0Vx!nNt~H3(=~L0>JKFGh2%kU@w!5TS<`=Bc$6c$EM!M%d6^crtk$mYm?o3clcR!Jj9GAKF@Rsg5jYqPEP4|r6 zYO4#0sXb}O@gd#K9>#L_K`K7(RcQR|59|Y+@GJugpU)|y`l!oKt?(Nu&v{KN6RbOY zvhgn0c$YAZBS8!oh#FoI`y&^w=&av6SpM31IF$l` z_TBSIAT+xn*v3lS6L+V@xByiz%QiYU+MBpZMAV0HE!%lEDjcl)@KR7Q4@d2L%;r%| zk|X*KL$W-W9bYYY`D5oXDP;)f#=`@x6IF|LyfjPIO!)ac{f`ALWLj#7W53t9i}OtB`wlg?jv zE{ncBlPc<+1n*gmKxYwn72Q}pE1{pS1v`3(tR&IcmyAp8OQ<~R_HE-Xm5|%%I&KQ= zQMo>~Lo4{!AytXxuGora)6>B#|Ah|_OxY?5oC^lnScLrF#kJoyq3#^?fL|NS+b&E9 z&Z(LIxzT6ob2w*TUeO)VfoSD7LKKhQ`yJb~x402c35A?h?Fq{qh_ek9!H>D%3C1`c zbUHPv4{hftlJ?-~x)?!8;>ZU&>h#qcDdeA#{3(sTR;xE@vy66)45Nf8S13bdd}|Us#hr z2$mS`jU`7XuS6Y{c93ZMKNz2XfKic6_Gcw^ObJ9>gt~6pn3inTVCu-Yya9|()+opY z%2r>>@V(Y*D^@uSt7D0hr%xTsbk@Aa-v)O7t$6?NknX0hYS(I z3cD(ZugO=slB@hTqg^qr_S-FG+vt>&eS+iK{QOiqFLvQWM!N;xDV!{-z*!% zHkI)CQh7TxpB<0bjVaY>)f z?d2P^>`^_@6_r`{T~GX6FrZJSwp1A6=22~@^;KWsUN$ha|Jvam6z%mJ8;7%oqojp{ z!v47Kuqp9W6}dZAr3Hy~*aF)?lzhjuHLdnxu1|b&8SOAyZX?V=k6b{hTtO@*=VbKW zHRXY54MM=3D^8SGl*~vs$*dnKdEjTwB4ZPW{DSgEMQID9NRfXx-qYNnV z^jfa;9!@wv#}I~gn}DtU0`=9?JY^cr46@!_G`V(X!!)A&^I8)&lYWY$V!3X?hHr|= z;YD3&en$jUo(<)DSom=exrpjd9=UZy6=EI0w`i7XTID zIQfze&lS;`b`BP55Ihu3C{0%II`gNom`U0jJ|07dDo_Rv#L<$7Ia^pg&~>&rCs}{n znc!A(Ou(`U&sC_)0ru{7#Y*^qPqwXpHQ9N|$g_k8Im%jSw<)7utjn+yN3RtS0ZOEJ zd49kfB&o!}FQ?NkA1BX~t_bSdIa2P-=Qa$sU9YC6{_@UX#=3pzu*dvBrZ=mSnA<2# zt3I(xT2^Z^sfQWnVI0?K6wSutTpja%Mm#{PH88zcA86|diy;UBq-(m5$?gRgepM@8 zRc@4BuZ7hu5@boGUhZAogB&scDk!J?vh%xIQVTIsU0HiV)aonm(4)7Uh4N1gA?m?J zxv-`923J-zi^}Mk+^c9zUGwF3DayyV&zXUNorCmy)#!ru_o`7a=zm@{u0BCuq}X7q T`O&UG0k1cb@)BiY`hNc(=bS Date: Sun, 3 Nov 2024 01:43:47 +0100 Subject: [PATCH 09/12] [Added] Nightly filling up of fresh data (and swapping), Startup filling of DB, sessionToken for bulk gathering --- .../quiz/components/StartupRunner.java | 47 ++-- ...SourceConfig.java => DataSourceConfig.txt} | 0 .../quiz/controllers/QuizController.java | 1 + .../quiz/entities/BaseQuestionEntity.java | 34 +++ .../entities/QuestionCollectingEntity.java | 10 + .../ninjaws/quiz/entities/QuestionEntity.java | 28 +-- .../com/ninjaws/quiz/models/QuizSettings.java | 57 ----- .../ninjaws/quiz/models/SessionTokenDTO.java | 15 ++ .../QuestionCollectingRepository.java | 14 ++ .../quiz/repositories/QuestionRepository.java | 37 ++-- .../com/ninjaws/quiz/services/ApiService.java | 29 ++- .../ninjaws/quiz/services/CleanerService.java | 38 +++- .../ninjaws/quiz/services/DataService.java | 48 +++-- .../quiz/services/DatabaseScheduler.java | 20 -- .../ninjaws/quiz/services/QuizService.java | 11 +- .../services/ResponseHandlingService.java | 26 ++- .../quiz/services/SchedulerService.java | 16 +- .../quiz/services/StateManagementService.java | 29 +++ .../src/main/resources/application.properties | 44 ++-- .../quiz/services/CleanerServiceTest.txt | 107 ---------- .../ninjaws/quiz/services/DataServiceTest.txt | 202 ------------------ .../quiz/services/backupApiServiceTest.txt | 114 ---------- .../quiz/services/backupQuizServiceTest.txt | 72 ------- scripts/LoadTestMultiple.sh | 6 +- scripts/LoadTestOne-Silent.sh | 38 ++++ scripts/LoadTestOne-WithParams.sh | 7 +- scripts/LoadTestOne.sh | 15 +- ...- All Params - 1000 and 10000 sessions.png | Bin 0 -> 25996 bytes 28 files changed, 365 insertions(+), 700 deletions(-) rename backend/src/main/java/com/ninjaws/quiz/config/{DataSourceConfig.java => DataSourceConfig.txt} (100%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java delete mode 100644 backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt create mode 100755 scripts/LoadTestOne-Silent.sh create mode 100644 scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png diff --git a/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java index 1885a81..129f975 100644 --- a/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java +++ b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java @@ -1,27 +1,40 @@ package com.ninjaws.quiz.components; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; -import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; -/** - * Sets up the database, and adds the categories - * - * Fills up the primary database - * - * Then, at night, creates a new database, adds categories, and then starts filling it up - * When full, swaps the databases, and wipes the old one - */ -public class StartupRunner implements CommandLineRunner { - private final JdbcTemplate primaryJdbcTemplate; +import com.ninjaws.quiz.services.ApiService; +import com.ninjaws.quiz.services.StateManagementService; - public StartupRunner(JdbcTemplate primaryJdbcTemplate) { - this.primaryJdbcTemplate = primaryJdbcTemplate; - } + +@Component +public class StartupRunner implements CommandLineRunner { + + @Autowired + private StateManagementService stateManagementService; + @Autowired + private ApiService apiService; @Override public void run(String... args) { - // Initialization logic here - primaryJdbcTemplate.execute("CREATE TABLE IF NOT EXISTS test_table (id INT PRIMARY KEY, name VARCHAR(255))"); - primaryJdbcTemplate.update("INSERT INTO test_table (id, name) VALUES (?, ?)", 1, "Sample Data"); + + /** + * On startup: + * - Set ProcessingState to Startup + * - Store the categories + * - Get a sessiontoken + * + * * Inside the SchedulerService + * - Start filling the question table + * + * * Inside the Response Handling Service + * - No more questions? Stop + * - Set ProcessingState to Normal + */ + + stateManagementService.setCurrentProcessingState(StateManagementService.ProcessingState.STARTUP); + apiService.getCategories(); + apiService.getSessionToken(); } } diff --git a/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt similarity index 100% rename from backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java rename to backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index b9f592e..fca6518 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -23,6 +23,7 @@ public class QuizController { @Autowired private QuizService quizService; + /** * Start a session * @param amount The amount of questsions diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java new file mode 100644 index 0000000..bd4f7a7 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java @@ -0,0 +1,34 @@ +package com.ninjaws.quiz.entities; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@MappedSuperclass +@Getter +@Setter +public class BaseQuestionEntity { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne + @JoinColumn(name = "category", nullable = false) + private Category category; + + private String difficulty; + private String type; + private String question; + + private String correctAnswer; + // @ElementCollection // This stores it in a separate table, it's just a set of strings so not required right now + private List incorrectAnswers; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java new file mode 100644 index 0000000..bc5370e --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java @@ -0,0 +1,10 @@ +package com.ninjaws.quiz.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "question_collecting") +public class QuestionCollectingEntity extends BaseQuestionEntity{ + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java index 753254d..24aece9 100644 --- a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java @@ -1,36 +1,10 @@ package com.ninjaws.quiz.entities; -import java.util.List; - import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import lombok.Getter; -import lombok.Setter; @Entity @Table(name = "question") -@Getter -@Setter -public class QuestionEntity { +public class QuestionEntity extends BaseQuestionEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "category", nullable = false) - private Category category; - - private String difficulty; - private String type; - private String question; - - //* Hidden from the user */ - private String correctAnswer; - private List incorrectAnswers; } diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java index cb82d5e..4693f8a 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -1,8 +1,5 @@ package com.ninjaws.quiz.models; -import com.ninjaws.quiz.models.QuizSettings.Category; -import com.ninjaws.quiz.models.QuizSettings.Difficulty; - import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -13,45 +10,9 @@ import lombok.Setter; @Setter public class QuizSettings { - public enum Type { - ANY, BOOLEAN, MULTIPLE; - - public String getApiValue() { - return this.name().toLowerCase(); - } - } - - public enum Difficulty { - ANY, EASY, MEDIUM, HARD; - - public String getApiValue() { - return this.name().toLowerCase(); - } - } - - public enum Category { - ANY, GEN_KNOWLEDGE, ENT_BOOKS, ENT_FILM, ENT_MUSIC, ENT_MUSICAL_THEATRE, ENT_TELEVISION, - ENT_VIDEOGAMES, ENT_BOARDGAMES, SCIENCE_NATURE, SCIENCE_COMPUTERS, SCIENCE_MATH, MYTHOLOGY, SPORTS, GEOGRAPHY, HISTORY, POLITICS, ART, CELEBRITIES, - ANIMALS, VEHICLES, ENT_COMICS, SCIENCE_GADGETS, ENT_ANIME_MANGA, ENT_CARTOON_ANIMATIONS; - - /** - * For some reason the external API starts with a value of 9 for the General Knowledge category, so we have to adjust - * ANY also gets adjusted, but it doesn't matter since that one doesn't get used as parameter anyway - * @return The corrected value - */ - public int getApiValue() { - return 8 + this.ordinal(); - } - } - @Min(1) @Max(50) private int amount = 10; - - // private Type type = Type.ANY; - - // private Difficulty difficulty = Difficulty.ANY; - private Integer category; private String difficulty; private String type; @@ -81,24 +42,6 @@ public class QuizSettings { * Converts the class to a format that can be provided as arguments for the api request * @return The resulting request arguments as String */ - // public String toApiQuery() { - // StringBuilder query = new StringBuilder("?amount=" + amount); - - // if (category != null && category != Category.ANY) { - // query.append("&category=").append(category.getApiValue()); - // } - - // if (difficulty != null && difficulty != Difficulty.ANY) { - // query.append("&difficulty=").append(difficulty.getApiValue()); - // } - - // if (type != null && type != Type.ANY) { - // query.append("&type=").append(type.getApiValue()); - // } - - // return query.toString(); - // } - public String toApiQuery() { StringBuilder query = new StringBuilder("?amount=" + amount); diff --git a/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java b/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java new file mode 100644 index 0000000..193a48e --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java @@ -0,0 +1,15 @@ +package com.ninjaws.quiz.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SessionTokenDTO { + @JsonProperty("response_code") + private int responseCode; + @JsonProperty("token") + private String token; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java new file mode 100644 index 0000000..ae6214c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ninjaws.quiz.entities.QuestionCollectingEntity; + +/** + * This table is used for collecting questions from the external API + * When it finishes, the data from this repository will replace the data in the QuestionRepository, to ensure it stays fresh + * This should be scheduled once every day + */ +public interface QuestionCollectingRepository extends JpaRepository{ + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java index 4ec9de2..afcc321 100644 --- a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java @@ -3,24 +3,37 @@ package com.ninjaws.quiz.repositories; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ninjaws.quiz.entities.QuestionEntity; +import jakarta.transaction.Transactional; + public interface QuestionRepository extends JpaRepository{//, JpaSpecificationExecutor { + // @Query(value = "SELECT * FROM question AS q " + + // "WHERE (?1 IS NULL OR q.category = ?1) " + + // "AND (?2 IS NULL OR q.difficulty = ?2) " + + // "AND (?3 IS NULL OR q.type = ?3) " + + // "ORDER BY RAND() LIMIT ?4", nativeQuery = true) @Query(value = "SELECT * FROM question AS q " + - "WHERE (?1 IS NULL OR q.category = ?1) " + - "AND (?2 IS NULL OR q.difficulty = ?2) " + - "AND (?3 IS NULL OR q.type = ?3) " + - "ORDER BY RAND() LIMIT ?4", nativeQuery = true) - List lookupQuestions(Integer categoryId, String difficulty, String type, int limit); + "WHERE (:categoryId IS NULL OR q.category = :categoryId) " + + "AND (:difficulty IS NULL OR q.difficulty = :difficulty) " + + "AND (:type IS NULL OR q.type = :type) " + + "ORDER BY RAND() LIMIT :limit;", nativeQuery = true) + List lookupQuestions( + @Param("categoryId") Integer categoryId, + @Param("difficulty") String difficulty, + @Param("type") String type, + @Param("limit") int limit); - // public List getQuestionsBy(String type, String category, String difficulty, int limit) { - // Specification spec = Specification.where(QuestionSpecification.hasType(type)) - // .and(QuestionSpecification.hasCategory(category)) - // .and(QuestionSpecification.hasDifficulty(difficulty)); - - // return productRepository.findAll(spec); - // } + /** + * Moves all data from one table to another + */ + @Modifying + @Transactional + @Query(value="INSERT INTO question SELECT * FROM question_collecting;", nativeQuery=true) + void moveCollection(); } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index 219e6fb..e4ce492 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -12,6 +12,7 @@ import com.ninjaws.quiz.models.ApiResponse; import com.ninjaws.quiz.models.CategoryResponse; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Request; +import com.ninjaws.quiz.models.SessionTokenDTO; import jakarta.validation.Valid; @@ -28,6 +29,7 @@ public class ApiService { private static final System.Logger logger = System.getLogger(ApiService.class.getName()); private final String apiUrl = "https://opentdb.com"; + private String sessionToken; public ApiService() { } @@ -55,21 +57,42 @@ public class ApiService { * Store them in the DataService */ public void collectBulk() { - // TODO: Make this happen only once, when a new database is generated - getCategories(); - String url = apiUrl + "/api.php?amount=50"; + String tokenStr = sessionToken == null ? "" : "&token="+sessionToken; + String url = apiUrl + "/api.php?amount=50" + tokenStr; try { ResponseEntity response = restTemplate.getForEntity(url, String.class); String json = response.getBody(); ApiResponse apiResponse = cleanerService.extractApiResponse(json); responseHandler.handleResponse(apiResponse); + /** + * A bit dirty, but otherwise it causes circular dependencies + * Makes sure the categories are refreshed after finishing to replace the questions, to make sure the ids match + * And resets the sessionToken, it will be remade during the next REFRESH + */ + if(apiResponse.getResponseCode() == 4) { + getCategories(); + sessionToken = null; + } + } catch (RestClientException | IOException e) { logger.log(System.Logger.Level.ERROR, "Critical failure", e); } } + public void getSessionToken() { + String url = apiUrl + "/api_token.php?command=request"; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + SessionTokenDTO tokenDto = cleanerService.extractSessionToken(json); + sessionToken = tokenDto.getToken(); + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } public void getCategories() { String url = apiUrl + "/api_category.php"; diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java index 9c08359..7b26710 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -10,12 +10,20 @@ import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.ObjectMapper; import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionCollectingEntity; import com.ninjaws.quiz.entities.QuestionEntity; import com.ninjaws.quiz.models.ApiResponse; import com.ninjaws.quiz.models.CategoryResponse; import com.ninjaws.quiz.models.QuestionDTO; +import com.ninjaws.quiz.models.SessionTokenDTO; import com.ninjaws.quiz.repositories.CategoryRepository; +/** + * Deals with conversions from one form of data to another + * Json to Object + * Entity to DTO + * etc + */ @Service public class CleanerService { @@ -44,6 +52,10 @@ public class CleanerService { return dtos; } + public SessionTokenDTO extractSessionToken(String json) throws IOException { + return objectMapper.readValue(json, SessionTokenDTO.class); + } + public QuestionDTO questionEntityToDTO(QuestionEntity entity) { QuestionDTO dto = new QuestionDTO(); dto.setQuestion(entity.getQuestion()); @@ -63,7 +75,7 @@ public class CleanerService { } return entities; } - + public QuestionEntity questionDTOtoEntity(QuestionDTO dto) { QuestionEntity entity = new QuestionEntity(); Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); @@ -76,6 +88,30 @@ public class CleanerService { return entity; } + + // TODO: Find a simpler way to convert from one table to another if the data is identical so this nonsense isn't necessary + public List questionListDTOtoCollectingEntity(List dtos) { + List entities = new ArrayList<>(); + for (int i = 0; i < dtos.size(); i++) { + QuestionDTO dto = dtos.get(i); + entities.add(questionDTOtoCollectingEntity(dto)); + } + return entities; + } + + public QuestionCollectingEntity questionDTOtoCollectingEntity(QuestionDTO dto) { + QuestionCollectingEntity entity = new QuestionCollectingEntity(); + Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); + entity.setCategory(category); + entity.setDifficulty(dto.getDifficulty()); + entity.setType(dto.getType()); + entity.setQuestion(dto.getQuestion()); + entity.setCorrectAnswer(dto.getCorrectAnswer()); + entity.setIncorrectAnswers(dto.getIncorrectAnswers()); + return entity; + } + + /** * For some reason, the categories come with special characters, * But the categories from the questions dont diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java index adb2469..de2f114 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java @@ -6,17 +6,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionCollectingEntity; import com.ninjaws.quiz.entities.QuestionEntity; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.repositories.CategoryRepository; +import com.ninjaws.quiz.repositories.QuestionCollectingRepository; import com.ninjaws.quiz.repositories.QuestionRepository; +import jakarta.transaction.Transactional; + @Service public class DataService { @Autowired private QuestionRepository questionRepository; - + @Autowired + private QuestionCollectingRepository collectingRepository; @Autowired private CategoryRepository categoryRepository; @@ -32,34 +37,31 @@ public class DataService { return questionRepository.saveAll(questions); } - // public List getRandomQuestions(String type, String category, String difficulty, int limit) { - // return questionRepository.findRandomQuestions(type, limit); - // } - - public List lookupQuestions(QuizSettings quizSettings) { - // Long category = Long.valueOf(quizSettings.getCategory().getApiValue() == 1 ? null : quizSettings.getCategory().getApiValue()); - // String difficulty = quizSettings.getDifficulty() == QuizSettings.Difficulty.ANY ? null : quizSettings.getDifficulty().getApiValue(); - // String type = quizSettings.getType() == QuizSettings.Type.ANY ? null : quizSettings.getType().getApiValue(); - // System.out.println(category); - // System.out.println(difficulty); - // System.out.println(type); - - return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount()); - } - /** * Tries to get questions that match the specified QuizSettings + * * @return The questions if the query was possible, and an empty array if not */ - // public List getByQuizSettings(QuizSettings quizSettings) { - // Specification spec = Specification.where(QuestionSpecification.hasType(type)) - // .and(QuestionSpecification.hasCategory(category)) - // .and(QuestionSpecification.hasDifficulty(difficulty)); - - // return productRepository.findAll(spec); - // } + public List lookupQuestions(QuizSettings quizSettings) { + return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount()); + } public List saveCategories(List categories) { return categoryRepository.saveAll(categories); } + + /** + * Moving the collected questions to the questions list + * + */ + @Transactional + public void migrateQuestions() { + questionRepository.deleteAll(); + questionRepository.moveCollection(); + collectingRepository.deleteAll(); + } + + public List collectQuestions(List questions) { + return collectingRepository.saveAll(questions); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java b/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java deleted file mode 100644 index 83686c1..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ninjaws.quiz.services; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Service -public class DatabaseScheduler { - @Autowired - private JdbcTemplate secondaryJdbcTemplate; - - @Scheduled(fixedRate = 60000) // Runs every 60 seconds - public void createAndFillSecondaryDatabase() { - // Create tables and insert data into the secondary database - secondaryJdbcTemplate. - .execute("CREATE TABLE IF NOT EXISTS sample_table (id INT PRIMARY KEY, name VARCHAR(255))"); - secondaryJdbcTemplate.update("INSERT INTO sample_table (id, name) VALUES (?, ?)", 1, "Sample Data"); - } -} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index d511850..5bb92b3 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -38,7 +38,7 @@ public class QuizService { /** First, we check if there are already enough items in our db */ List questionsRetrieved = dataService.lookupQuestions(quizSettings); - if(!questionsRetrieved.isEmpty()) { + if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved)); } @@ -47,7 +47,7 @@ public class QuizService { /** * If the questions were retrieved from the DB, then it doesn't need to be added to the queue */ - if(!questionsRetrieved.isEmpty()) { + if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { return Optional.of(session.getId()); } @@ -81,9 +81,10 @@ public class QuizService { } /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ - if(session.getStatusCode() != 0) { - cacheService.removeFromCache(session.getId()); - } + // if(session.getStatusCode() != 0) { + /** Regardless of status code, this link is not reusable */ + cacheService.removeFromCache(session.getId()); + // } return Optional.of(session); } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java index 96b2dd0..4d07c9f 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -10,6 +10,8 @@ import com.ninjaws.quiz.models.Session; @Service public class ResponseHandlingService { + @Autowired + private StateManagementService stateManager; @Autowired private CacheService cacheService; @Autowired @@ -33,7 +35,8 @@ public class ResponseHandlingService { case 0: // Success updateSessionInCache(session, response); - dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + /** Managing the token gets difficult since there is a timelimit, so simply dont add these, let the bulk collector do it */ + // dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); break; case 1: // Not enough questions to satisfy the request. @@ -67,11 +70,26 @@ public class ResponseHandlingService { public void handleResponse(ApiResponse response) { switch (response.getResponseCode()) { case 0: - dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + if(stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP) { + /** Initial saving during the startup */ + dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + } else if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + /** Background collecting during the night */ + dataService.collectQuestions(cleanerService.questionListDTOtoCollectingEntity(response.getResults())); + } break; case 4: - // Signal DataService that we have extracted all the data. Stop gathering, switch to caching-mode - // TODO: Signal DB that it's full, start db-swapping process + // Signal DataService that we have extracted all the data. Stop gathering, + // TODO: Switch to caching-mode + + /** + * If it was in the REFRESH state, move the data from collecting to the used questions + * Otherwise simply switch to the NORMAL state + */ + if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + dataService.migrateQuestions(); + } + stateManager.setCurrentProcessingState(StateManagementService.ProcessingState.NORMAL); break; case 5: // Should be prevented by the scheduler diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java index 41b7dc4..a8f474a 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -13,6 +13,8 @@ public class SchedulerService { private QueueService queueService; @Autowired private ApiService apiService; + @Autowired + private StateManagementService stateService; public SchedulerService() {} @@ -25,11 +27,23 @@ public class SchedulerService { @Scheduled(fixedDelay = 5000) protected void processQueue() { Request request = queueService.popFromQueue(); + if (request != null) { apiService.handleRequest(request); } else { - apiService.collectBulk(); + if (stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP || stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + apiService.collectBulk(); + } } } + + /** + * Starts the renewing of the questions, to ensure they stay fresh + */ + @Scheduled(cron = "0 0 1 * * ?") + protected void StartDbRefresh() { + apiService.getSessionToken(); + stateService.setCurrentProcessingState(StateManagementService.ProcessingState.REFRESH); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java b/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java new file mode 100644 index 0000000..b1b8c49 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java @@ -0,0 +1,29 @@ +package com.ninjaws.quiz.services; + +import org.springframework.stereotype.Service; + +@Service +public class StateManagementService { + + /** + * STARTUP: When starting the app + * REFRESH: Scheduled at night, so the database stays fresh + * NORMAL: No special activities + */ + public enum ProcessingState { + STARTUP, + REFRESH, + NORMAL + } + + private ProcessingState currentProcessingState; + + public ProcessingState getCurrentProcessingState() { + return currentProcessingState; + } + + public void setCurrentProcessingState(ProcessingState currentProcessingState) { + this.currentProcessingState = currentProcessingState; + } + +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f7d952d..451ac85 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -6,29 +6,29 @@ spring.h2.console.enabled=true # spring.h2.console.path=/console -# spring.datasource.url=jdbc:h2:mem:testdb -# spring.datasource.driverClassName=org.h2.Driver -# spring.datasource.username=sa -# spring.datasource.password= +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= -# spring.jpa.hibernate.ddl-auto=create-drop -# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -# Primary database configuration -spring.datasource.primary.url=jdbc:h2:mem:testdb -# ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.primary.driver-class-name=org.h2.Driver -spring.datasource.primary.username=sa -spring.datasource.primary.password= -spring.jpa.primary.hibernate.ddl-auto=create-drop -spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect +# # Primary database configuration +# spring.datasource.primary.url=jdbc:h2:mem:testdb +# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# spring.datasource.primary.driver-class-name=org.h2.Driver +# spring.datasource.primary.username=sa +# spring.datasource.primary.password= +# spring.jpa.primary.hibernate.ddl-auto=create-drop +# spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect -# Secondary database configuration -spring.datasource.secondary.url=jdbc:h2:mem:secondarydb -# ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.secondary.driver-class-name=org.h2.Driver -spring.datasource.secondary.username=sa -spring.datasource.secondary.password= -spring.jpa.secondary.hibernate.ddl-auto=create-drop -spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file +# # Secondary database configuration +# spring.datasource.secondary.url=jdbc:h2:mem:secondarydb +# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# spring.datasource.secondary.driver-class-name=org.h2.Driver +# spring.datasource.secondary.username=sa +# spring.datasource.secondary.password= +# spring.jpa.secondary.hibernate.ddl-auto=create-drop +# spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt deleted file mode 100644 index bdb7919..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt +++ /dev/null @@ -1,107 +0,0 @@ -package com.ninjaws.quiz.services; - -import java.io.IOException; -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.entities.Category; -import com.ninjaws.quiz.models.ApiResponse; -import com.ninjaws.quiz.models.QuestionDTO; - -// public class CleanerServiceTest { - -// @InjectMocks -// private CleanerService cleanerService; - -// @Mock -// private ObjectMapper objectMapper; - -// @BeforeEach -// public void setUp() { -// MockitoAnnotations.openMocks(this); -// } - -// @Test -// public void testExtractApiResponse() throws IOException { -// String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input - -// Question question = new Question(); -// question.setType("multiple choice"); -// question.setDifficulty("easy"); -// question.setCategory("general knowledge"); -// question.setQuestion("What is the capital of France?"); -// question.setCorrectAnswer("Paris"); -// question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - -// ApiResponse expectedResponse = new ApiResponse(); -// expectedResponse.setResponseCode(200); -// expectedResponse.setResults(Arrays.asList(question)); -// when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); - -// ApiResponse actualResponse = cleanerService.extractApiResponse(json); -// assertThat(actualResponse).isNotNull(); -// assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); -// assertThat(actualResponse.getResults()).isNotEmpty(); -// assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); -// verify(objectMapper, times(1)).readValue(json, ApiResponse.class); -// } -// } - -@ExtendWith(MockitoExtension.class) -public class CleanerServiceTest { - - @InjectMocks - private CleanerService cleanerService; - - @Mock - private ObjectMapper objectMapper; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - public void testExtractApiResponse() throws IOException { - String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input - - // Create a Category object for the question - Category category = new Category(); - category.setId(1L); - category.setName("General Knowledge"); - - // Create the Question object with the category - QuestionDTO question = new QuestionDTO(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory(category); // Set the Category object here - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - ApiResponse expectedResponse = new ApiResponse(); - expectedResponse.setResponseCode(200); - expectedResponse.setResults(Arrays.asList(question)); - when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); - - ApiResponse actualResponse = cleanerService.extractApiResponse(json); - assertThat(actualResponse).isNotNull(); - assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); - assertThat(actualResponse.getResults()).isNotEmpty(); - assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); - assertThat(actualResponse.getResults().get(0).getCategory().getName()).isEqualTo(category.getName()); // Check category name - verify(objectMapper, times(1)).readValue(json, ApiResponse.class); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt deleted file mode 100644 index faa94e2..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt +++ /dev/null @@ -1,202 +0,0 @@ -package com.ninjaws.quiz.services; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.ninjaws.quiz.entities.Category; -import com.ninjaws.quiz.models.QuestionDTO; -import com.ninjaws.quiz.repositories.QuestionRepository; - -// public class DataServiceTest { - -// @InjectMocks -// private DataService dataService; - -// @Mock -// private QuestionRepository questionRepository; - -// private Question question; - - -// @BeforeEach -// public void setUp() { -// MockitoAnnotations.openMocks(this); - -// question = new Question(); -// question.setType("multiple choice"); -// question.setDifficulty("easy"); -// question.setCategory("general knowledge"); -// question.setQuestion("What is the capital of France?"); -// question.setCorrectAnswer("Paris"); -// question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); -// } - -// @Test -// public void testSaveQuestion() { -// when(questionRepository.save(any(Question.class))).thenReturn(question); - -// Question savedQuestion = dataService.saveQuestion(question); -// assertThat(savedQuestion).isNotNull(); -// assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); -// verify(questionRepository, times(1)).save(question); -// } - -// @Test -// public void testGetAllQuestions() { -// when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); - -// List questions = dataService.getAllQuestions(); -// assertThat(questions).isNotEmpty(); -// assertThat(questions.size()).isEqualTo(1); -// assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); -// verify(questionRepository, times(1)).findAll(); -// } - -// @Test -// public void testFindQuestionById() { -// when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); - -// Optional foundQuestion = questionRepository.findById(1L); -// assertThat(foundQuestion).isPresent(); -// assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); -// verify(questionRepository, times(1)).findById(1L); -// } - -// @Test -// public void testSaveQuestions() { -// Question question1 = new Question(); -// question1.setType("multiple choice"); -// question1.setDifficulty("easy"); -// question1.setCategory("general knowledge"); -// question1.setQuestion("What is the capital of France?"); -// question1.setCorrectAnswer("Paris"); -// question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - -// Question question2 = new Question(); -// question2.setType("multiple choice"); -// question2.setDifficulty("medium"); -// question2.setCategory("science"); -// question2.setQuestion("What is the chemical symbol for water?"); -// question2.setCorrectAnswer("H2O"); -// question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); - -// when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); - -// List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); - -// assertThat(questions).hasSize(2); -// assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); -// assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); -// verify(questionRepository, times(1)).saveAll(anyList()); -// } -// } - -@ExtendWith(MockitoExtension.class) -public class DataServiceTest { - - @InjectMocks - private DataService dataService; - - @Mock - private QuestionRepository questionRepository; - - private QuestionDTO question; - private Category category; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - - category = new Category(); - category.setId(1L); - category.setName("General Knowledge"); - - question = new QuestionDTO(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory(category); // Set the Category object here - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - } - - @Test - public void testSaveQuestion() { - when(questionRepository.save(any(QuestionDTO.class))).thenReturn(question); - - QuestionDTO savedQuestion = dataService.saveQuestion(question); - assertThat(savedQuestion).isNotNull(); - assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(savedQuestion.getCategory().getName()).isEqualTo("General Knowledge"); // Check category name - verify(questionRepository, times(1)).save(question); - } - - @Test - public void testGetAllQuestions() { - when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); - - List questions = dataService.getAllQuestions(); - assertThat(questions).isNotEmpty(); - assertThat(questions.size()).isEqualTo(1); - assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(questions.get(0).getCategory().getName()).isEqualTo("General Knowledge"); // Check category name - verify(questionRepository, times(1)).findAll(); - } - - @Test - public void testFindQuestionById() { - when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); - - Optional foundQuestion = questionRepository.findById(1L); - assertThat(foundQuestion).isPresent(); - assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(foundQuestion.get().getCategory().getName()).isEqualTo("General Knowledge"); // Check category name - verify(questionRepository, times(1)).findById(1L); - } - - @Test - public void testSaveQuestions() { - QuestionDTO question1 = new QuestionDTO(); - question1.setType("multiple choice"); - question1.setDifficulty("easy"); - question1.setCategory(category); // Set the Category object here - question1.setQuestion("What is the capital of France?"); - question1.setCorrectAnswer("Paris"); - question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - QuestionDTO question2 = new QuestionDTO(); - Category category2 = new Category(); - category2.setId(2L); - category2.setName("Science"); - question2.setType("multiple choice"); - question2.setDifficulty("medium"); - question2.setCategory(category2); // Set the Category object here - question2.setQuestion("What is the chemical symbol for water?"); - question2.setCorrectAnswer("H2O"); - question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); - - when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); - - List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); - - assertThat(questions).hasSize(2); - assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); - verify(questionRepository, times(1)).saveAll(anyList()); - } -} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt deleted file mode 100644 index 1b3f44e..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt +++ /dev/null @@ -1,114 +0,0 @@ -package com.ninjaws.quiz.services; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.models.QuizSettings; - -@SpringBootTest -public class ApiServiceTest { - - @Autowired - private ApiService apiService; - - @Autowired - private ObjectMapper objectMapper; - - /** - * The external API only allows one request every 5 seconds - */ - @AfterEach - public void cooldown() throws InterruptedException { - Thread.sleep(5000); - } - - @Test - public void testRequestQuestions() throws Exception { - QuizSettings settings = new QuizSettings(); - String jsonResponse = apiService.handleRequest(settings); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - // Check if the response code is as expected - assertThat(jsonNode.has("response_code")).isTrue(); - assertThat(jsonNode.get("response_code").asInt()).isEqualTo(0); - - // Check the results array - assertThat(jsonNode.has("results")).isTrue(); - assertThat(jsonNode.get("results").isArray()).isTrue(); - assertThat(jsonNode.get("results").size()).isEqualTo(10); - - // Verify the first result has the expected fields - JsonNode firstResult = jsonNode.get("results").get(0); - assertThat(firstResult.has("type")).isTrue(); - assertThat(firstResult.has("difficulty")).isTrue(); - assertThat(firstResult.has("category")).isTrue(); - assertThat(firstResult.has("question")).isTrue(); - assertThat(firstResult.has("correct_answer")).isTrue(); - assertThat(firstResult.has("incorrect_answers")).isTrue(); - assertThat(firstResult.get("incorrect_answers").size()).isGreaterThan(0); - } - - @Test - public void testAllParams() throws Exception { - int amount = 10; - - QuizSettings settings = new QuizSettings(); - settings.setAmount(amount); - settings.setCategory(QuizSettings.Category.HISTORY); - settings.setType(QuizSettings.Type.MULTIPLE); - settings.setDifficulty(QuizSettings.Difficulty.MEDIUM); - - String jsonResponse = apiService.requestQuestions(settings); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - // Check if the response code is as expected - assertThat(jsonNode.has("response_code")).isTrue(); - assertThat(jsonNode.get("response_code").asInt()).isEqualTo(0); - - // Check the results array - assertThat(jsonNode.has("results")).isTrue(); - assertThat(jsonNode.get("results").isArray()).isTrue(); - assertThat(jsonNode.get("results").size()).isEqualTo(amount); - - // Verify the first result has the expected fields - JsonNode firstResult = jsonNode.get("results").get(0); - String retrievedType = firstResult.get("type").toString(); - String retrievedDifficulty = firstResult.get("difficulty").toString(); - String retrievedCategory = firstResult.get("category").toString(); - assertThat(retrievedType.equals(settings.getType().getApiValue())); - assertThat(retrievedDifficulty.equals(settings.getDifficulty().getApiValue())); - assertThat(retrievedCategory.equals("Animals")); - assertThat(firstResult.get("incorrect_answers").size()).isEqualTo(3); - } - - /** - * If the API doesn't have the requested amount of questions, it will return an empty list and respond with 1 - */ - @Test - public void testCombinationThatResultsInZeroQuestions() throws Exception { - int amount = 30; - - QuizSettings settings = new QuizSettings(); - settings.setAmount(amount); - settings.setCategory(QuizSettings.Category.ANIMALS); - settings.setType(QuizSettings.Type.MULTIPLE); - settings.setDifficulty(QuizSettings.Difficulty.MEDIUM); - - String jsonResponse = apiService.requestQuestions(settings); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - // Check if the response code is as expected - assertThat(jsonNode.has("response_code")).isTrue(); - assertThat(jsonNode.get("response_code").asInt()).isEqualTo(1); - - // Check the results array - assertThat(jsonNode.has("results")).isTrue(); - assertThat(jsonNode.get("results").isArray()).isTrue(); - assertThat(jsonNode.get("results").size()).isEqualTo(0); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt deleted file mode 100644 index 130fbd0..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt +++ /dev/null @@ -1,72 +0,0 @@ -package com.ninjaws.quiz.services; - -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -// @EnableScheduling -// @TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class QuizServiceTest { - - // @InjectMocks - // private QuizService quizService; - - // @Mock - // private ApiService apiService; - - // @BeforeAll - // public void setUp() { - // MockitoAnnotations.openMocks(this); - // // Cache cache = Caffeine.newBuilder() - // // .expireAfterWrite(1, TimeUnit.HOURS) - // // .build(); // Create a mock cache or a real cache for tests - // // quizService = new QuizService(cache); - // } - - // @Test - // public void testCreateSession() { - // QuizSettings settings = new QuizSettings(); - // String response = quizService.createSession(settings); - // assertTrue(response instanceof String); - // } - - // /** - // * TODO: Fix the test - // * Seems to clear the cache and queue when the createSession is finished - // */ - // @Test - // public void testCheckSession() throws InterruptedException { - - // QuizSettings settings = new QuizSettings(); - // when(apiService.requestQuestions(settings)).thenReturn("{\"response_code\":0,\"results\":[{\"type\":\"multiple\",\"difficulty\":\"hard\",\"category\":\"Entertainment: Video Games\",\"question\":\"The map featured in Arma 3 named "Altis" is based off of what Greek island?\",\"correct_answer\":\"Lemnos\",\"incorrect_answers\":[\"Ithaca\",\"Naxos\",\"Anafi\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Entertainment: Music\",\"question\":\"What is the last song on the first Panic! At the Disco album?\",\"correct_answer\":\"Build God, Then We'll Talk\",\"incorrect_answers\":[\"Nails for Breakfast, Tacks for Snacks\",\"I Write Sins Not Tragedies\",\"Lying Is The Most Fun A Girl Can Have Without Taking Her Clothes Off\"]},{\"type\":\"multiple\",\"difficulty\":\"easy\",\"category\":\"Entertainment: Video Games\",\"question\":\"Which of these is NOT a playable character in the 2016 video game Overwatch?\",\"correct_answer\":\"Invoker\",\"incorrect_answers\":[\"Mercy\",\"Winston\",\"Zenyatta\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Science: Computers\",\"question\":\"What does AD stand for in relation to Windows Operating Systems? \",\"correct_answer\":\"Active Directory\",\"incorrect_answers\":[\"Alternative Drive\",\"Automated Database\",\"Active Department\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Entertainment: Japanese Anime & Manga\",\"question\":\"In "Highschool DxD", Koneko Toujou is from what race?\",\"correct_answer\":\"Nekomata\",\"incorrect_answers\":[\"Kitsune\",\"Human\",\"Kappa\"]}]}"); - // String sessionId = quizService.createSession(settings); - - - // int interval = 1000; - // int maxTime = 5000; - // long startTime = System.currentTimeMillis(); - // Session session = null; - - // while((System.currentTimeMillis() - maxTime) < startTime) { - // Optional retrieved = quizService.checkSession(sessionId); - // System.out.println(retrieved); - // if (retrieved.isPresent()) { - // System.out.print("Present!"); - // session = retrieved.get(); - // break; - // } else { - // try { - // Thread.sleep(interval); - // } catch (InterruptedException e) { - // Thread.currentThread().interrupt(); // Restore interrupted status - // fail("Thread was interrupted during sleep"); - // } - // } - // } - - // assertNotEquals(session, null); - // } - - // public void testCalculateScore(){ - - // } -} diff --git a/scripts/LoadTestMultiple.sh b/scripts/LoadTestMultiple.sh index 352f191..1bc9f85 100755 --- a/scripts/LoadTestMultiple.sh +++ b/scripts/LoadTestMultiple.sh @@ -2,9 +2,9 @@ start_time=$(date +%s) -for i in {1..6}; do - ./LoadTestOne-WithParams.sh & - sleep 0.1 +for i in {1..1000}; do + ./LoadTestOne-Silent.sh & +# sleep 0.1 done wait # Wait for all background jobs to finish diff --git a/scripts/LoadTestOne-Silent.sh b/scripts/LoadTestOne-Silent.sh new file mode 100755 index 0000000..a76f8b3 --- /dev/null +++ b/scripts/LoadTestOne-Silent.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=10&category=23&difficulty=medium&type=multiple") + +# Extract the status code +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + exit 1 +fi + +# Extract the session ID +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + exit 1 +fi + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s "http://localhost:9090/quiz/status/$sessionId") + + if [ "$status_response" != "null" ]; then + # Extract the status code from the response + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + exit 1 + fi + + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then + break + fi + fi + + sleep 2 +done diff --git a/scripts/LoadTestOne-WithParams.sh b/scripts/LoadTestOne-WithParams.sh index f23ce00..42d65f4 100755 --- a/scripts/LoadTestOne-WithParams.sh +++ b/scripts/LoadTestOne-WithParams.sh @@ -1,7 +1,6 @@ #!/bin/bash - # Step 1: Get the sessionId from the questions endpoint -sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=25&category=27&difficulty=medium&type=boolean") +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=20&category=27&difficulty=medium&type=multiple") statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') @@ -22,8 +21,7 @@ echo "Received sessionId: $sessionId" # Step 2: Poll the status endpoint every 2 seconds while true; do status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") - - if [ $? -eq 0 ]; then + if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') @@ -34,7 +32,6 @@ while true; do # Check if the response is JSON (starts with '{') if [[ $status_response == \{* ]]; then -# echo "$statusCode" echo "$sessionId: done! status: $statusCode" break fi diff --git a/scripts/LoadTestOne.sh b/scripts/LoadTestOne.sh index feb32eb..a85eab8 100755 --- a/scripts/LoadTestOne.sh +++ b/scripts/LoadTestOne.sh @@ -1,5 +1,4 @@ #!/bin/bash - # Step 1: Get the sessionId from the questions endpoint sessionIdJson=$(curl -s http://localhost:9090/quiz/questions?amount=5) @@ -22,16 +21,22 @@ echo "Received sessionId: $sessionId" # Step 2: Poll the status endpoint every 2 seconds while true; do status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") + if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then + + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is $statusCode" + exit 1 + fi - if [ $? -eq 0 ]; then # Check if the response is JSON (starts with '{') if [[ $status_response == \{* ]]; then - echo "$sessionId: done!" + echo "$sessionId: done! status: $statusCode" break fi else - echo "Status check failed, exiting..." - exit 1 + echo "Status check failed, retrying..." fi sleep 2 diff --git a/scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png b/scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png new file mode 100644 index 0000000000000000000000000000000000000000..e9032bbaf79e0aaeb49c2c6a5e17cc3df949650f GIT binary patch literal 25996 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�}fU5j>joKV`-ToZ3}3(4pU%3qA37u3gTc`M7#8@86m8^Re_8Yeu=@My-qN zPBcab#Jl1|uHdlFMTG?{as5vPZdT{*aj%WS#>TU;oT~p!6m&81#`XButGSgQg-cW4 zu;_31(}LpvUz2C#A3LNogR=pB-jPaJe*SS-@N0YDntbLsgh)v6hho{}%C(?0!H7BYjWK!4%AG|CQMWi?B z>D>?P{-Qy;MM~U)_{**O*a27kU_)$;v$L4cAB4dX4+(~CA3BQyY@0Q>RbgcJ18+-e zazA|cLMfv_9?yWYCn29W*^`a9*Rm&!0&5 zt7X+7-+MX_1n>V;bNJHh8UJfDR*Mu%;=^;vTbmaX?hrQ|;f~?dYHjm;?0^mw8oOkS zgDiAE0#b29aC~f6>V9=Mqi8}+7fC6bUqruA8v@^F{OkOVVH%fd@$n9lgP8YmvaPjm zIahzjBir=Ka*+#Qw`X^9oaL&h-0yrnBy*2EGFfkAq=*p&lRIymq<{s5|P0sAvpF2ElM8 zPtm$w^eUjcgB$$~UdIObAq2fuL_olMzYh&ktcD2zGZL#=Fmaeve9i-R4o!Cp+J5m5 z4!?kgk#9C6f{B%?A^Fq6r}G*q;&YBk;}F`!&BrA? zVjkATrpms+Vmx;nPaDe+eQr_$)B-|n$>WRJ=!Y}}yWe((et`X4emmo{6Dlo%s6rfu zilH3M#InT@vZ&r|^R#|H&eb1e#}n55?gl`tfv;ugUbcu#3>4|_Ru!MYq%|Av@G8HE zJ0lyZWoWe$?Blb?oG;K{%pD5z<+>!Eymx!y2TF6E+)_8?iDl6aSM)|X$|jw@+x@On@DmX| z+_lOe>eq7gYtLFHxRMm7lBU54_ALG5E|_kD(K}9{T9A9x<>g++;ni&H(L}+EHQ1xe zu$m(qo>+7xgbXo32AJOduCxWtv(bhS#wB^>j;i7q+W<5{g@c4@y+0X8#7 z$m}c29C1pb{_F=6ixDb1lePF>j>6`5ni-Qha_k$9n=j)?wb!pCO^SaJgU#F~4}DZa zD@Ur{dX4;}`tB6vqFM;2n75L|1!QZz8P(60^;KCJ>&ggEFB#Y zlA^Z><}qIJ;?zObBaWi7GGMnjkScJs#Erj(L;zYBl%o`rHQB;tB>Xt)%1mJH?raT9 zF&}oKj+e6a|7wYbM*x#7rwq;g(S0+S(G-UVy9zRn{49^KQ+|-NrVgfcscyk(8@8vc zM*QMbqV?eZ#Z9nANatZTUp>NLK7_1npu>~=mu#de zz2!r{11QsRLJcTyGv}_^31fpX49FEjcnVem)J%(oKOI?2HefTV!Qw^uVbjMZKcf;p zc9RINJ@>_?f;a$Li^oJ39e%7yV(sl1*9Vc)d*0=Fks=L?Ffa&HfA0v00!Y9iZEL

ih|B+SYw5R@pI07Y@tT+0*=6K?O9xZj3{uiMfQQ}pq({U>+%YNKqL!rof zqU{_n%#02MzZEA6q^Hr`6dWD`^m*|&iGnfUHLVnEDDwn8d@r9jn~1~hhqTs=` zJbsG!4-^+^mQrNTC<<1jc@_$!g8nF);BWFmB<_-->WEr@oP+j@NtVEJ2RbVA$f(v^ z+$2EDqJwyPd{hlyaOjcL&JFz)x3bVu3L`~E=W~v57MQ5q*fO%0@i(O)C$$JP)z*BX z6jK{l#^`V@#CepZ(qMjzEP!gQ>392(e?pam-937S3nvWXk>|L=`~6_+X3g2bCunDs8aexSJLg zl6a8HbOmPxN?3!B@h@ov^=&8s)IiT$5qQk62Ijr4Nd`4N=aER4lE z>S4)_D0MY7wt??a)QMIp-l~sUl=>#Uy6*6|E7cLDSZ^TDJ>Hl62+TVfKVQvE0L3|+ z_I94SRkDRl(W>iw@6P#$Yq0M>YmMo)-%L+>cz7PDh<-1Ewb2$ZUof_u_#QB|5KV@< zQ>7p3zURNX%`mt#X?`}QOl&0Og`ah>RcR9H_W)-&mEg*cx;VTO27JU_tWhLj znL6>g=nzrsCsNh5eZ;(@Tvb@b78Yrk;AXDFRO(=miDS;tPE~~p_UKq{;V;3=o*)h7MX!)w}rb}U>tWUoavZURy^tw zpL*|UmFSU=?Hc_J>EVgoa-+plji4;OxViY5*q~K~KK?S$vQ6Tw^kT~Ad6?1c@(#|LVw&_#tjHI($q}&1B-#^ z0vTr$!0VQs@43=CL>eD=s!U?)+EFp<&86O}{UV49II0zLg#6tc7!~zrpLfbSI<94c zjQ$Ncm1kZypV_b$zWx`?6Q!v{{4bWTkDa&AMn}uE>C#hv3@A Date: Sun, 3 Nov 2024 01:44:47 +0100 Subject: [PATCH 10/12] [Removed] Unused file --- .../ninjaws/quiz/config/DataSourceConfig.txt | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt diff --git a/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt b/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt deleted file mode 100644 index 8645af9..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt +++ /dev/null @@ -1,46 +0,0 @@ -package com.ninjaws.quiz.config; - -import javax.sql.DataSource; - -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.jdbc.core.JdbcTemplate; - -public class DataSourceConfig { - @Primary - @Bean(name = "primaryDataSourceProperties") - @ConfigurationProperties("spring.datasource.primary") - public DataSourceProperties primaryDataSourceProperties() { - return new DataSourceProperties(); - } - - @Primary - @Bean(name = "primaryDataSource") - public DataSource primaryDataSource() { - return primaryDataSourceProperties().initializeDataSourceBuilder().build(); - } - - @Bean(name = "secondaryDataSourceProperties") - @ConfigurationProperties("spring.datasource.secondary") - public DataSourceProperties secondaryDataSourceProperties() { - return new DataSourceProperties(); - } - - @Bean(name = "secondaryDataSource") - public DataSource secondaryDataSource() { - return secondaryDataSourceProperties().initializeDataSourceBuilder().build(); - } - - @Bean - public JdbcTemplate primaryJdbcTemplate(@Qualifier("primaryDataSource") DataSource ds) { - return new JdbcTemplate(ds); - } - - @Bean - public JdbcTemplate secondaryJdbcTemplate(@Qualifier("secondaryDataSource") DataSource ds) { - return new JdbcTemplate(ds); - } -} From b82dfdbe17668a93920c36f76790728cc954ecfb Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Sun, 3 Nov 2024 13:29:52 +0100 Subject: [PATCH 11/12] [Updated] Frontend now uses categories retrieved from the backend --- README.md | 12 +++++- Reflection.md | 8 ++++ .../quiz/controllers/QuizController.java | 10 +++++ .../ninjaws/quiz/services/DataService.java | 3 ++ .../ninjaws/quiz/services/QuizService.java | 32 ++++++++------- .../quiz/scala/QuestionStatusSimulation.scala | 1 + frontend/src/app/Quiz/models/category.ts | 4 ++ frontend/src/app/Quiz/models/quiz-params.ts | 4 +- .../src/app/Quiz/services/quiz.service.ts | 39 ++++--------------- .../src/app/Quiz/setup/setup.component.html | 2 +- .../src/app/Quiz/setup/setup.component.ts | 16 +++++--- scripts/LoadTestMultiple.sh | 7 ++-- 12 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 frontend/src/app/Quiz/models/category.ts diff --git a/README.md b/README.md index 4f51dba..e3c838c 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,14 @@ sudo reboot ```bash docker compose up ``` -- Visit localhost:4200 to play the quiz! \ No newline at end of file +- Visit localhost:4200 to play the quiz! + + +## Benchmarking +- Go to the /scripts folder and run the LoadTestMultiple.sh script +- Inside you can change how many sessions should be created (set to 6 by default) + +## Notes +- If the data is not present, it will try to query it from the external API, which takes significantly longer (only 1 request per 5 seconds) +- When running it locally, give the backend a few minutes to gather data in bulk (should be completely done in ~7 minutes) +- It refreshes this data every night in the background, so it stays up to date \ No newline at end of file diff --git a/Reflection.md b/Reflection.md index 88450d4..2e902ef 100644 --- a/Reflection.md +++ b/Reflection.md @@ -28,3 +28,11 @@ Then start replacing the cache items one at a time. Users shouldn't feel this at all, because the active sessions are stored in their own cache items +# Progress so far +There only seem to be ~4100 items, making the collecting phase only last around 7 minutes. + +Swapping dbs is replaced with swapping tables, seems to work just as fine, yet much simpler to set up + +TODO: Add a caching state, and start filling up each unique combination with the items from the db +This should lead to performance gains + diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index fca6518..7d7d114 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -1,5 +1,6 @@ package com.ninjaws.quiz.controllers; +import java.util.List; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; @@ -11,9 +12,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.ninjaws.quiz.entities.Category; import com.ninjaws.quiz.models.Answers; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Session; +import com.ninjaws.quiz.services.DataService; import com.ninjaws.quiz.services.QuizService; @RestController @@ -22,6 +25,8 @@ public class QuizController { @Autowired private QuizService quizService; + @Autowired + private DataService dataService; /** @@ -69,4 +74,9 @@ public class QuizController { String jsonString = String.format("{\"score\":\"%s\"}", quizService.getScore(answers)); return jsonString; } + + @GetMapping("/categories") + public List getCategories(){ + return dataService.getCategories(); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java index de2f114..717fdc8 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java @@ -50,6 +50,9 @@ public class DataService { return categoryRepository.saveAll(categories); } + public List getCategories(){ + return categoryRepository.findAll(); + } /** * Moving the collected questions to the questions list * diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index 5bb92b3..4bb1484 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -24,6 +24,8 @@ public class QuizService { private DataService dataService; @Autowired private CleanerService cleanerService; + @Autowired + private StateManagementService stateManager; /** * Actions: @@ -38,19 +40,20 @@ public class QuizService { /** First, we check if there are already enough items in our db */ List questionsRetrieved = dataService.lookupQuestions(quizSettings); + /** If the items are not matching the required amount, and the db is not currently being filled up, then they simply don't exist */ + if (questionsRetrieved.size() < quizSettings.getAmount() && stateManager.getCurrentProcessingState() != StateManagementService.ProcessingState.STARTUP) { + session.setStatusCode(1); + cacheService.addToCache(session.getId(), session); + return Optional.of(session.getId()); + } + /** All items are collected in from the database, it doesn't need to be added to the queue */ if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved)); - } - - cacheService.addToCache(session.getId(), session); - - /** - * If the questions were retrieved from the DB, then it doesn't need to be added to the queue - */ - if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { + cacheService.addToCache(session.getId(), session); return Optional.of(session.getId()); } + cacheService.addToCache(session.getId(), session); boolean addedToQueue = queueService.addToQueue(new Request(session.getId(), quizSettings)); /** In this order, because doing the queue before the cache could lead to race conditions (request completion immediately calls cache, but then the item doesn't exist) */ if(!addedToQueue) { @@ -75,16 +78,17 @@ public class QuizService { throw new IllegalArgumentException("Invalid session ID: " + sessionId); } + /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ + if(session.getStatusCode() != 0) { + cacheService.removeFromCache(session.getId()); + return Optional.of(session); + } + /** Still waiting for a response */ if (session.getQuestions() == null) { return Optional.empty(); } - /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ - // if(session.getStatusCode() != 0) { - /** Regardless of status code, this link is not reusable */ - cacheService.removeFromCache(session.getId()); - // } return Optional.of(session); } @@ -93,7 +97,7 @@ public class QuizService { * Receives: sessionId and answers * * Actions: - * - retrieves session from cache + * - Retrieves session from cache * - Deletes session from cache * - Matches answers answers to correctAnswers * - Calculates score diff --git a/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala b/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala index 9f75a56..13900dc 100644 --- a/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala +++ b/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala @@ -3,6 +3,7 @@ import io.gatling.core.Predef._ import io.gatling.http.Predef._ import scala.concurrent.duration._ +/** Does not work, use the LoadTestMultiple.sh in the /scripts folder in the root instead */ class QuestionStatusSimulation extends Simulation { val httpProtocol = http.baseUrl("http://localhost:9090").acceptHeader("application/json") diff --git a/frontend/src/app/Quiz/models/category.ts b/frontend/src/app/Quiz/models/category.ts new file mode 100644 index 0000000..dae9760 --- /dev/null +++ b/frontend/src/app/Quiz/models/category.ts @@ -0,0 +1,4 @@ +export interface Category { + id: number; + name: string; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/quiz-params.ts b/frontend/src/app/Quiz/models/quiz-params.ts index b37a2b2..7b8fff8 100644 --- a/frontend/src/app/Quiz/models/quiz-params.ts +++ b/frontend/src/app/Quiz/models/quiz-params.ts @@ -1,5 +1,7 @@ +import { Category } from "./category"; + export interface QuizParams { - category: string; + category: number; type: string; amount: number; difficulty: string; diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts index 184570f..e9c141a 100644 --- a/frontend/src/app/Quiz/services/quiz.service.ts +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -19,6 +19,7 @@ import { StorageService } from './storage.service'; import { CleanerService } from './cleaner.service'; import { ResponseHandlerService } from './response-handler.service'; import { environment } from '../../../environments/environment'; +import { Category } from '../models/category'; @Injectable({ providedIn: 'root', @@ -40,8 +41,7 @@ export class QuizService { } this.storageService.setSession(sessionIdObj.sessionId); const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); - - return this.reponseHandler.handleResponse(sessionIdObj.statusCode, sessionObj); + return this.reponseHandler.handleResponse(sessionObj.statusCode, sessionObj); } public pollQuestions(sessionId: string) { @@ -67,7 +67,7 @@ export class QuizService { private convertParamsToUrl(quizParams: QuizParams) { let url = `?amount=${quizParams.amount}`; - const catIndex = this.getCategories().indexOf(quizParams.category); + const catIndex = quizParams.category; url += catIndex !== 0 ? `&category=${catIndex}` : ''; const diff = quizParams.difficulty.toLowerCase(); @@ -83,34 +83,11 @@ export class QuizService { return url; } - public getCategories(): string[] { - return [ - 'Any Category', - 'General Knowledge', - 'Entertainment: Books', - 'Entertainment: Film', - 'Entertainment: Music', - 'Entertainment: Musicals & Theatres', - 'Entertainment: Television', - 'Entertainment: Video Games', - 'Entertainment: Board Games', - 'Science & Nature', - 'Science: Computers', - 'Science: Mathematics', - 'Mythology', - 'Sports', - 'Geography', - 'History', - 'Politics', - 'Art', - 'Celebrities', - 'Animals', - 'Vehicles', - 'Entertainment: Comics', - 'Science: Gadgets', - 'Entertainment: Japanese Anime & Manga', - 'Entertainment: Cartoon & Animations', - ]; + public async getCategories(): Promise { + let categories: Category[] = await firstValueFrom(this.http.get(this.apiUrl + '/categories')); + categories.push({id:0,name:"Any Category"}); + categories.sort((a,b) => a.id - b.id); + return categories; } public getDifficulties(): string[] { diff --git a/frontend/src/app/Quiz/setup/setup.component.html b/frontend/src/app/Quiz/setup/setup.component.html index 9cc3b1d..26d99e3 100644 --- a/frontend/src/app/Quiz/setup/setup.component.html +++ b/frontend/src/app/Quiz/setup/setup.component.html @@ -5,7 +5,7 @@

diff --git a/frontend/src/app/Quiz/setup/setup.component.ts b/frontend/src/app/Quiz/setup/setup.component.ts index 34c9056..bf28d90 100644 --- a/frontend/src/app/Quiz/setup/setup.component.ts +++ b/frontend/src/app/Quiz/setup/setup.component.ts @@ -7,6 +7,7 @@ import { QuizParams } from '../models/quiz-params'; import { SpinnerService } from '../../Spinner/services/spinner.service'; import { CommonModule } from '@angular/common'; import { Question } from '../models/question'; +import { Category } from '../models/category'; @Component({ selector: 'app-setup', @@ -17,8 +18,8 @@ import { Question } from '../models/question'; }) export class SetupComponent { - - category: string = ""; + categories!: Category[]; + category!: number; difficulty: string = ""; type: string = ""; amount: number = 10; @@ -28,10 +29,13 @@ export class SetupComponent { public quizService: QuizService, private spinnerService: SpinnerService ) { - /** Setting default values */ - this.category = this.quizService.getCategories()[0]; - this.difficulty = this.quizService.getDifficulties()[0]; - this.type = this.quizService.getTypes()[0]; + quizService.getCategories().then((categories: Category[]) => { + /** Setting default values */ + this.categories = categories; + this.category = this.categories.find(item => item.id == 0)!.id; + this.difficulty = this.quizService.getDifficulties()[0]; + this.type = this.quizService.getTypes()[0]; + }); } startQuiz() { diff --git a/scripts/LoadTestMultiple.sh b/scripts/LoadTestMultiple.sh index 1bc9f85..0692369 100755 --- a/scripts/LoadTestMultiple.sh +++ b/scripts/LoadTestMultiple.sh @@ -2,16 +2,15 @@ start_time=$(date +%s) -for i in {1..1000}; do +for i in {1..6}; do ./LoadTestOne-Silent.sh & # sleep 0.1 done -wait # Wait for all background jobs to finish +wait -end_time=$(date +%s) # Capture the end time +end_time=$(date +%s) -# Calculate and print the elapsed time elapsed_time=$((end_time - start_time)) echo "Done!" echo "Total time taken: $elapsed_time seconds" From e1b04745b27385d906194cfba673928b58aa344c Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Sun, 3 Nov 2024 13:39:50 +0100 Subject: [PATCH 12/12] [Updated] Readme --- README.md | 1 + ...ue.png => 1. Queue - No params - 6 sessions.png} | Bin ...=> 2. DB and Queue - No Params - 6 sessions.png} | Bin ...ueue - All Params - 1000 and 10000 sessions.png} | Bin 4 files changed, 1 insertion(+) rename scripts/benchmarks/{1. Queue.png => 1. Queue - No params - 6 sessions.png} (100%) rename scripts/benchmarks/{2. DB and Queue - No Params.png => 2. DB and Queue - No Params - 6 sessions.png} (100%) rename scripts/benchmarks/{DB and Queue - All Params - 1000 and 10000 sessions.png => 3. DB and Queue - All Params - 1000 and 10000 sessions.png} (100%) diff --git a/README.md b/README.md index e3c838c..c7d6be5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ docker compose up ## Benchmarking - Go to the /scripts folder and run the LoadTestMultiple.sh script - Inside you can change how many sessions should be created (set to 6 by default) +- In the /scripts/benchmarks folder you can find some benchmarks I've done ## Notes - If the data is not present, it will try to query it from the external API, which takes significantly longer (only 1 request per 5 seconds) diff --git a/scripts/benchmarks/1. Queue.png b/scripts/benchmarks/1. Queue - No params - 6 sessions.png similarity index 100% rename from scripts/benchmarks/1. Queue.png rename to scripts/benchmarks/1. Queue - No params - 6 sessions.png diff --git a/scripts/benchmarks/2. DB and Queue - No Params.png b/scripts/benchmarks/2. DB and Queue - No Params - 6 sessions.png similarity index 100% rename from scripts/benchmarks/2. DB and Queue - No Params.png rename to scripts/benchmarks/2. DB and Queue - No Params - 6 sessions.png diff --git a/scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png b/scripts/benchmarks/3. DB and Queue - All Params - 1000 and 10000 sessions.png similarity index 100% rename from scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png rename to scripts/benchmarks/3. DB and Queue - All Params - 1000 and 10000 sessions.png