From cdd3ef0ed2700a3c1d041b09837bcba84277bcaf Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Wed, 18 Oct 2023 08:18:44 +0200 Subject: [PATCH 1/2] Development: Bump version to 6.6.2 --- build.gradle | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 799346b9ab13..913dafcd158d 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ plugins { } group = "de.tum.in.www1.artemis" -version = "6.6.1" +version = "6.6.2" description = "Interactive Learning with Individual Feedback" sourceCompatibility=17 diff --git a/package-lock.json b/package-lock.json index 0df5bb6c7351..9682c900837e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis", - "version": "6.6.1", + "version": "6.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "6.6.1", + "version": "6.6.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a33e13b27ae0..b3aea9d3f91a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "6.6.1", + "version": "6.6.2", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", From f25936466a300814668181a815719fb3adfb7c52 Mon Sep 17 00:00:00 2001 From: Rizky Riyaldhi Date: Wed, 18 Oct 2023 08:19:17 +0200 Subject: [PATCH 2/2] Exam mode: Add quiz pool configuration (#6612) --- .../www1/artemis/domain/quiz/QuizGroup.java | 32 +++ .../in/www1/artemis/domain/quiz/QuizPool.java | 119 ++++++++ .../artemis/domain/quiz/QuizQuestion.java | 25 ++ .../repository/QuizGroupRepository.java | 14 + .../repository/QuizPoolRepository.java | 27 ++ .../www1/artemis/service/QuizPoolService.java | 139 ++++++++++ .../artemis/web/rest/QuizPoolResource.java | 95 +++++++ .../changelog/20230524102945_changelog.xml | 40 +++ .../resources/config/liquibase/master.xml | 1 + .../app/entities/quiz/quiz-group.model.ts | 6 + .../app/entities/quiz/quiz-pool.model.ts | 13 + .../app/entities/quiz/quiz-question.model.ts | 2 + .../app/exam/manage/exam-management.route.ts | 11 + ...ecklist-exercisegroup-table.component.html | 15 + ...checklist-exercisegroup-table.component.ts | 9 +- .../exam-checklist.component.html | 5 +- .../exam-checklist.component.ts | 24 ++ .../manage/exams/exam-detail.component.html | 5 + .../quiz/manage/quiz-management.module.ts | 6 + ...-pool-mapping-question-list.component.html | 33 +++ ...-pool-mapping-question-list.component.scss | 59 ++++ ...iz-pool-mapping-question-list.component.ts | 37 +++ .../manage/quiz-pool-mapping.component.html | 71 +++++ .../manage/quiz-pool-mapping.component.scss | 15 + .../manage/quiz-pool-mapping.component.ts | 202 ++++++++++++++ .../quiz/manage/quiz-pool.component.html | 100 +++++++ .../quiz/manage/quiz-pool.component.scss | 32 +++ .../quiz/manage/quiz-pool.component.ts | 260 ++++++++++++++++++ .../quiz/manage/quiz-pool.service.ts | 39 +++ .../shared/layouts/navbar/navbar.component.ts | 1 + src/main/webapp/i18n/de/exam.json | 1 + src/main/webapp/i18n/de/quizPool.json | 23 ++ src/main/webapp/i18n/en/exam.json | 1 + src/main/webapp/i18n/en/quizPool.json | 23 ++ .../artemis/exam/QuizPoolIntegrationTest.java | 206 ++++++++++++++ .../quizexercise/QuizExerciseUtilService.java | 33 +++ .../exams/exam-detail.component.spec.ts | 5 + ...ol-mapping-question-list.component.spec.ts | 65 +++++ .../quiz-pool-mapping.component.spec.ts | 225 +++++++++++++++ .../quiz/manage/quiz-pool.component.spec.ts | 212 ++++++++++++++ .../spec/service/quiz-pool.service.spec.ts | 49 ++++ 41 files changed, 2278 insertions(+), 2 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java create mode 100644 src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java create mode 100644 src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java create mode 100644 src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java create mode 100644 src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java create mode 100644 src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml create mode 100644 src/main/webapp/app/entities/quiz/quiz-group.model.ts create mode 100644 src/main/webapp/app/entities/quiz/quiz-pool.model.ts create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.html create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.scss create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.ts create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.html create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.scss create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.ts create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.html create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts create mode 100644 src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts create mode 100644 src/main/webapp/i18n/de/quizPool.json create mode 100644 src/main/webapp/i18n/en/quizPool.json create mode 100644 src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java create mode 100644 src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts create mode 100644 src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts create mode 100644 src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts create mode 100644 src/test/javascript/spec/service/quiz-pool.service.spec.ts diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java new file mode 100644 index 000000000000..70ee3de742a5 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java @@ -0,0 +1,32 @@ +package de.tum.in.www1.artemis.domain.quiz; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.DomainObject; + +@Entity +@Table(name = "quiz_group") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class QuizGroup extends DomainObject { + + @Column(name = "name") + private String name; + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @JsonIgnore + public boolean isValid() { + return getName() != null && !getName().isEmpty(); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java new file mode 100644 index 000000000000..e3e088eda513 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java @@ -0,0 +1,119 @@ +package de.tum.in.www1.artemis.domain.quiz; + +import java.util.ArrayList; +import java.util.List; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.JoinColumn; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.persistence.Transient; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonView; + +import de.tum.in.www1.artemis.domain.DomainObject; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.view.QuizView; + +@Entity +@Table(name = "quiz_pool") +@JsonInclude +public class QuizPool extends DomainObject implements QuizConfiguration { + + @OneToOne + @JoinColumn(name = "exam_id", referencedColumnName = "id") + private Exam exam; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "quiz_pool_id", referencedColumnName = "id") + private List quizQuestions; + + @Column(name = "max_points") + private int maxPoints; + + @Column(name = "randomize_question_order") + @JsonView(QuizView.Before.class) + private Boolean randomizeQuestionOrder = false; + + @Transient + private List quizGroups; + + public QuizPool() { + this.quizGroups = new ArrayList<>(); + this.quizQuestions = new ArrayList<>(); + } + + public void setExam(Exam exam) { + this.exam = exam; + } + + public Exam getExam() { + return exam; + } + + public int getMaxPoints() { + return maxPoints; + } + + public void setMaxPoints(int maxPoints) { + this.maxPoints = maxPoints; + } + + public Boolean getRandomizeQuestionOrder() { + return randomizeQuestionOrder; + } + + public void setRandomizeQuestionOrder(Boolean randomizeQuestionOrder) { + this.randomizeQuestionOrder = randomizeQuestionOrder; + } + + @Override + public void setQuestionParent(QuizQuestion quizQuestion) { + // Do nothing since the relationship between QuizPool and QuizQuestion is defined in QuizPool. + } + + @Override + public List getQuizQuestions() { + return this.quizQuestions; + } + + public void setQuizQuestions(List quizQuestions) { + this.quizQuestions = quizQuestions; + } + + @JsonProperty(value = "quizGroups", access = JsonProperty.Access.READ_ONLY) + public List getQuizGroups() { + return quizGroups; + } + + @JsonProperty(value = "quizGroups", access = JsonProperty.Access.WRITE_ONLY) + public void setQuizGroups(List quizGroups) { + this.quizGroups = quizGroups; + } + + /** + * Check if all quiz groups and questions are valid + * + * @return true if all quiz groups and questions are valid + */ + @JsonIgnore + public boolean isValid() { + for (QuizGroup quizGroup : getQuizGroups()) { + if (!quizGroup.isValid()) { + return false; + } + } + for (QuizQuestion quizQuestion : getQuizQuestions()) { + if (!quizQuestion.isValid()) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java index 4760dc813d2a..2ba00ad2e9c1 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java @@ -65,6 +65,10 @@ public abstract class QuizQuestion extends DomainObject { @JsonView(QuizView.Before.class) private Boolean invalid = false; + @Column(name = "quiz_group_id") + @JsonView(QuizView.Before.class) + private Long quizGroupId; + @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) @JoinColumn(unique = true) private QuizQuestionStatistic quizQuestionStatistic; @@ -73,6 +77,9 @@ public abstract class QuizQuestion extends DomainObject { @JsonIgnore private QuizExercise exercise; + @Transient + private QuizGroup quizGroup; + public String getTitle() { return title; } @@ -168,6 +175,24 @@ public void setExercise(QuizExercise quizExercise) { this.exercise = quizExercise; } + public Long getQuizGroupId() { + return quizGroupId; + } + + public void setQuizGroupId(Long quizGroupId) { + this.quizGroupId = quizGroupId; + } + + @JsonProperty(value = "quizGroup", access = JsonProperty.Access.READ_ONLY) + public QuizGroup getQuizGroup() { + return quizGroup; + } + + @JsonProperty(value = "quizGroup", access = JsonProperty.Access.WRITE_ONLY) + public void setQuizGroup(QuizGroup quizGroup) { + this.quizGroup = quizGroup; + } + /** * Calculate the score for the given answer * diff --git a/src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java new file mode 100644 index 000000000000..2730d938084e --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java @@ -0,0 +1,14 @@ +package de.tum.in.www1.artemis.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import de.tum.in.www1.artemis.domain.quiz.QuizGroup; + +/** + * Spring Data JPA repository for the QuizGroup entity. + */ +@SuppressWarnings("unused") +@Repository +public interface QuizGroupRepository extends JpaRepository { +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java new file mode 100644 index 000000000000..4da6d6f3653d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java @@ -0,0 +1,27 @@ +package de.tum.in.www1.artemis.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import de.tum.in.www1.artemis.domain.quiz.QuizPool; + +/** + * Spring Data JPA repository for the QuizPool entity. + */ +@SuppressWarnings("unused") +@Repository +public interface QuizPoolRepository extends JpaRepository { + + @Query(""" + SELECT qe + FROM QuizPool qe + JOIN qe.exam e + LEFT JOIN FETCH qe.quizQuestions qeq + LEFT JOIN FETCH qeq.quizQuestionStatistic + WHERE e.id = :examId + """) + Optional findWithEagerQuizQuestionsByExamId(Long examId); +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java b/src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java new file mode 100644 index 000000000000..3b952a08ab6f --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java @@ -0,0 +1,139 @@ +package de.tum.in.www1.artemis.service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.quiz.QuizGroup; +import de.tum.in.www1.artemis.domain.quiz.QuizPool; +import de.tum.in.www1.artemis.domain.quiz.QuizQuestion; +import de.tum.in.www1.artemis.repository.DragAndDropMappingRepository; +import de.tum.in.www1.artemis.repository.ExamRepository; +import de.tum.in.www1.artemis.repository.QuizGroupRepository; +import de.tum.in.www1.artemis.repository.QuizPoolRepository; +import de.tum.in.www1.artemis.repository.ShortAnswerMappingRepository; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; +import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; + +/** + * This service contains the functions to manage QuizPool entity. + */ +@Service +public class QuizPoolService extends QuizService { + + private static final String ENTITY_NAME = "quizPool"; + + private final Logger log = LoggerFactory.getLogger(QuizPoolService.class); + + private final QuizPoolRepository quizPoolRepository; + + private final QuizGroupRepository quizGroupRepository; + + private final ExamRepository examRepository; + + public QuizPoolService(DragAndDropMappingRepository dragAndDropMappingRepository, ShortAnswerMappingRepository shortAnswerMappingRepository, + QuizPoolRepository quizPoolRepository, QuizGroupRepository quizGroupRepository, ExamRepository examRepository) { + super(dragAndDropMappingRepository, shortAnswerMappingRepository); + this.quizPoolRepository = quizPoolRepository; + this.quizGroupRepository = quizGroupRepository; + this.examRepository = examRepository; + } + + /** + * Check if the given exam id is valid, then update quiz pool that belongs to the given exam id + * + * @param examId the id of the exam to be checked + * @param quizPool the quiz pool to be updated + * @return updated quiz pool + */ + public QuizPool update(Long examId, QuizPool quizPool) { + Exam exam = examRepository.findByIdElseThrow(examId); + + quizPool.setExam(exam); + + if (quizPool.getQuizQuestions() == null || !quizPool.isValid()) { + throw new BadRequestAlertException("The quiz pool is invalid", ENTITY_NAME, "invalidQuiz"); + } + + List savedQuizGroups = quizGroupRepository.saveAllAndFlush(quizPool.getQuizGroups()); + quizPoolRepository.findWithEagerQuizQuestionsByExamId(examId).ifPresent(existingQuizPool -> { + List existingQuizGroupIds = existingQuizPool.getQuizQuestions().stream().map(QuizQuestion::getQuizGroupId).filter(Objects::nonNull).toList(); + removeUnusedQuizGroup(existingQuizGroupIds, savedQuizGroups); + }); + + Map quizGroupNameIdMap = savedQuizGroups.stream().collect(Collectors.toMap(QuizGroup::getName, QuizGroup::getId)); + for (QuizQuestion quizQuestion : quizPool.getQuizQuestions()) { + if (quizQuestion.getQuizGroup() != null) { + quizQuestion.setQuizGroupId(quizGroupNameIdMap.get(quizQuestion.getQuizGroup().getName())); + } + else { + quizQuestion.setQuizGroupId(null); + } + } + quizPool.reconnectJSONIgnoreAttributes(); + + log.debug("Save quiz pool to database: {}", quizPool); + super.save(quizPool); + + QuizPool savedQuizPool = quizPoolRepository.findWithEagerQuizQuestionsByExamId(examId).orElseThrow(() -> new EntityNotFoundException(ENTITY_NAME, "examId=" + examId)); + savedQuizPool.setQuizGroups(savedQuizGroups); + reassignQuizQuestion(savedQuizPool, savedQuizGroups); + + return savedQuizPool; + } + + /** + * Find a quiz pool (if exists) that belongs to the given exam id + * + * @param examId the id of the exam to be searched + * @return quiz pool that belongs to the given exam id + */ + public QuizPool findByExamId(Long examId) { + QuizPool quizPool = quizPoolRepository.findWithEagerQuizQuestionsByExamId(examId).orElseThrow(() -> new EntityNotFoundException(ENTITY_NAME, "examId=" + examId)); + List quizGroupIds = quizPool.getQuizQuestions().stream().map(QuizQuestion::getQuizGroupId).filter(Objects::nonNull).toList(); + List quizGroups = quizGroupRepository.findAllById(quizGroupIds); + quizPool.setQuizGroups(quizGroups); + reassignQuizQuestion(quizPool, quizGroups); + return quizPool; + } + + /** + * Reassign the connection between quiz question, quiz pool and quiz group + * + * @param quizPool the quiz pool to be reset + * @param quizGroups the list of quiz group to be reset + */ + private void reassignQuizQuestion(QuizPool quizPool, List quizGroups) { + Map idQuizGroupMap = quizGroups.stream().collect(Collectors.toMap(QuizGroup::getId, Function.identity())); + for (QuizQuestion quizQuestion : quizPool.getQuizQuestions()) { + if (quizQuestion.getQuizGroupId() != null) { + quizQuestion.setQuizGroup(idQuizGroupMap.get(quizQuestion.getQuizGroupId())); + } + } + } + + /** + * Remove existing groups that do not exist anymore in the updated quiz pool + * + * @param existingQuizGroupIds the list of existing quiz group id of the quiz pool + * @param usedQuizGroups the list of quiz group that are still exists in the updated quiz pool + */ + private void removeUnusedQuizGroup(List existingQuizGroupIds, List usedQuizGroups) { + Set usedQuizGroupIds = usedQuizGroups.stream().map(QuizGroup::getId).collect(Collectors.toSet()); + Set ids = existingQuizGroupIds.stream().filter(id -> !usedQuizGroupIds.contains(id)).collect(Collectors.toSet()); + quizGroupRepository.deleteAllById(ids); + } + + @Override + protected QuizPool saveAndFlush(QuizPool quizConfiguration) { + return quizPoolRepository.saveAndFlush(quizConfiguration); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java new file mode 100644 index 000000000000..2d503e9197d5 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java @@ -0,0 +1,95 @@ +package de.tum.in.www1.artemis.web.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.quiz.QuizPool; +import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.security.Role; +import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; +import de.tum.in.www1.artemis.service.AuthorizationCheckService; +import de.tum.in.www1.artemis.service.QuizPoolService; +import de.tum.in.www1.artemis.service.exam.ExamAccessService; +import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; + +/** + * REST controller for managing QuizPool. + */ +@RestController +@RequestMapping("api/") +public class QuizPoolResource { + + private static final String ENTITY_NAME = "quizPool"; + + private final Logger log = LoggerFactory.getLogger(QuizPoolResource.class); + + @Value("${jhipster.clientApp.name}") + private String applicationName; + + private final QuizPoolService quizPoolService; + + private final CourseRepository courseRepository; + + private final AuthorizationCheckService authCheckService; + + private final ExamAccessService examAccessService; + + public QuizPoolResource(QuizPoolService quizPoolService, CourseRepository courseRepository, AuthorizationCheckService authCheckService, ExamAccessService examAccessService) { + this.quizPoolService = quizPoolService; + this.courseRepository = courseRepository; + this.authCheckService = authCheckService; + this.examAccessService = examAccessService; + } + + /** + * PUT /courses/{courseId}/exams/{examId}/quiz-pools : Update an existing QuizPool. + * + * @param courseId the id of the Course of which the QuizPool belongs to + * @param examId the id of the Exam of which the QuizPool belongs to + * @param quizPool the QuizPool to update + * @return the ResponseEntity with status 200 (OK) and with the body of the QuizPool, or with status 400 (Bad Request) if the QuizPool is invalid + */ + @PutMapping("courses/{courseId}/exams/{examId}/quiz-pools") + @EnforceAtLeastInstructor + public ResponseEntity updateQuizPool(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody QuizPool quizPool) { + log.info("REST request to update QuizPool : {}", quizPool); + + validateCourseRole(courseId); + QuizPool updatedQuizPool = quizPoolService.update(examId, quizPool); + + return ResponseEntity.ok().headers(HeaderUtil.createEntityUpdateAlert(applicationName, true, ENTITY_NAME, updatedQuizPool.getId().toString())).body(updatedQuizPool); + } + + /** + * GET /courses/{courseId}/exams/{examId}/quiz-pools : Get an existing QuizPool. + * + * @param courseId the id of the Course of which the QuizPool belongs to + * @param examId the id of the Exam of which the QuizPool belongs to + * @return the ResponseEntity with status 200 (OK) and with the body of the QuizPool, or with status 404 (Not Found) if the QuizPool is not found + */ + @GetMapping("courses/{courseId}/exams/{examId}/quiz-pools") + @EnforceAtLeastInstructor + public ResponseEntity getQuizPool(@PathVariable Long courseId, @PathVariable Long examId) { + log.info("REST request to get QuizPool given examId : {}", examId); + + validateCourseRole(courseId); + QuizPool quizPool = quizPoolService.findByExamId(examId); + + return ResponseEntity.ok().body(quizPool); + } + + private void validateCourseRole(Long courseId) { + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); + examAccessService.checkCourseAccessForInstructorElseThrow(courseId); + } +} diff --git a/src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml b/src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml new file mode 100644 index 000000000000..f8db84eaa30c --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index a387a1af2d6f..107239fe2eb4 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -64,6 +64,7 @@ + diff --git a/src/main/webapp/app/entities/quiz/quiz-group.model.ts b/src/main/webapp/app/entities/quiz/quiz-group.model.ts new file mode 100644 index 000000000000..18443e981c47 --- /dev/null +++ b/src/main/webapp/app/entities/quiz/quiz-group.model.ts @@ -0,0 +1,6 @@ +import { BaseEntity } from 'app/shared/model/base-entity'; + +export class QuizGroup implements BaseEntity { + id?: number; + name: string; +} diff --git a/src/main/webapp/app/entities/quiz/quiz-pool.model.ts b/src/main/webapp/app/entities/quiz/quiz-pool.model.ts new file mode 100644 index 000000000000..b6d7f82b7a83 --- /dev/null +++ b/src/main/webapp/app/entities/quiz/quiz-pool.model.ts @@ -0,0 +1,13 @@ +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; +import { Exam } from 'app/entities/exam.model'; +import { BaseEntity } from 'app/shared/model/base-entity'; + +export class QuizPool implements BaseEntity { + id?: number; + exam: Exam; + quizGroups: QuizGroup[] = []; + quizQuestions: QuizQuestion[] = []; + maxPoints = 0; + randomizeQuestionOrder = false; +} diff --git a/src/main/webapp/app/entities/quiz/quiz-question.model.ts b/src/main/webapp/app/entities/quiz/quiz-question.model.ts index 2e50064e158a..508aaf016129 100644 --- a/src/main/webapp/app/entities/quiz/quiz-question.model.ts +++ b/src/main/webapp/app/entities/quiz/quiz-question.model.ts @@ -3,6 +3,7 @@ import { SafeHtml } from '@angular/platform-browser'; import { QuizQuestionStatistic } from 'app/entities/quiz/quiz-question-statistic.model'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { CanBecomeInvalid } from 'app/entities/quiz/drop-location.model'; +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; export enum ScoringType { ALL_OR_NOTHING = 'ALL_OR_NOTHING', @@ -44,6 +45,7 @@ export abstract class QuizQuestion implements BaseEntity, CanBecomeInvalid, Exer public exercise?: QuizExercise; public exportQuiz = false; // default value public type?: QuizQuestionType; + public quizGroup?: QuizGroup; protected constructor(type: QuizQuestionType) { this.type = type; diff --git a/src/main/webapp/app/exam/manage/exam-management.route.ts b/src/main/webapp/app/exam/manage/exam-management.route.ts index 5d9b09e74882..31c0d5f33596 100644 --- a/src/main/webapp/app/exam/manage/exam-management.route.ts +++ b/src/main/webapp/app/exam/manage/exam-management.route.ts @@ -57,6 +57,7 @@ import { CourseResolve, ExamResolve, ExerciseGroupResolve, StudentExamResolve } import { BonusComponent } from 'app/grading-system/bonus/bonus.component'; import { SuspiciousBehaviorComponent } from 'app/exam/manage/suspicious-behavior/suspicious-behavior.component'; import { SuspiciousSessionsOverviewComponent } from 'app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; +import { QuizPoolComponent } from 'app/exercises/quiz/manage/quiz-pool.component'; export const examManagementRoute: Routes = [ { @@ -440,6 +441,16 @@ export const examManagementRoute: Routes = [ }, canActivate: [UserRouteAccessService], }, + // Quiz Pool Configuration + { + path: ':examId/quiz-pool', + component: QuizPoolComponent, + data: { + authorities: [Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], + pageTitle: 'artemisApp.quizExercise.home.title', + }, + canActivate: [UserRouteAccessService], + }, // Import File Upload Exercise { path: ':examId/exercise-groups/:exerciseGroupId/file-upload-exercises/import/:exerciseId', diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.html index c176750cfbb6..17aef6f3bc56 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.html @@ -30,6 +30,21 @@ + + + + + +
+
+ +
+ Quiz Exam +
+ + {{ quizPoolMaxPoints }} + {{ totalParticipants }} +
{{ column.indexExerciseGroup }}
diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.ts b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.ts index b0e681153741..abf4cd5d9647 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnChanges } from '@angular/core'; import { ExerciseGroup } from 'app/entities/exercise-group.model'; -import { getIcon, getIconTooltip } from 'app/entities/exercise.model'; +import { ExerciseType, getIcon, getIconTooltip } from 'app/entities/exercise.model'; import { ExerciseGroupVariantColumn } from 'app/entities/exercise-group-variant-column.model'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; @@ -10,6 +10,7 @@ import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; styleUrls: ['./exam-checklist-exercisegroup-table.component.scss'], }) export class ExamChecklistExerciseGroupTableComponent implements OnChanges { + @Input() quizPoolMaxPoints: number; @Input() exerciseGroups: ExerciseGroup[]; exerciseGroupVariantColumns: ExerciseGroupVariantColumn[] = []; getIcon = getIcon; @@ -17,6 +18,7 @@ export class ExamChecklistExerciseGroupTableComponent implements OnChanges { // Icons faExclamationTriangle = faExclamationTriangle; + totalParticipants: number; ngOnChanges() { this.exerciseGroupVariantColumns = []; // Clear any previously existing entries @@ -40,6 +42,7 @@ export class ExamChecklistExerciseGroupTableComponent implements OnChanges { exerciseGroupVariantColumn.noExercises = false; let exerciseVariantIndex = 1; + this.totalParticipants = 0; exerciseGroup.exercises!.forEach((exercise, index) => { // generate columns for each exercise let exerciseVariantColumn; @@ -58,6 +61,8 @@ export class ExamChecklistExerciseGroupTableComponent implements OnChanges { exerciseVariantColumn.exerciseNumberOfParticipations = exercise.numberOfParticipations ? exercise.numberOfParticipations : 0; exerciseVariantColumn.exerciseMaxPoints = exercise.maxPoints; + this.totalParticipants += exerciseVariantColumn.exerciseNumberOfParticipations; + this.exerciseGroupVariantColumns.push(exerciseVariantColumn); exerciseVariantIndex++; }); @@ -66,4 +71,6 @@ export class ExamChecklistExerciseGroupTableComponent implements OnChanges { }); } } + + protected readonly ExerciseType = ExerciseType; } diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html index 0b4817a2aa9e..416e3d842ea6 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html @@ -92,7 +92,10 @@


- + diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts index 956bcf2b039b..917bf0d00607 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts @@ -4,6 +4,11 @@ import { ExamChecklist } from 'app/entities/exam-checklist.model'; import { faChartBar, faEye, faListAlt, faThList, faUser, faWrench } from '@fortawesome/free-solid-svg-icons'; import { ExamChecklistService } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist.service'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { onError } from 'app/shared/util/global.utils'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-exam-checklist', @@ -13,6 +18,7 @@ export class ExamChecklistComponent implements OnChanges, OnInit, OnDestroy { @Input() exam: Exam; @Input() getExamRoutesByIdentifier: any; + quizPool: QuizPool; examChecklist: ExamChecklist; isLoading = false; pointsExercisesEqual = false; @@ -39,6 +45,8 @@ export class ExamChecklistComponent implements OnChanges, OnInit, OnDestroy { constructor( private examChecklistService: ExamChecklistService, private websocketService: JhiWebsocketService, + private alertService: AlertService, + private quizPoolService: QuizPoolService, ) {} ngOnInit() { @@ -48,6 +56,22 @@ export class ExamChecklistComponent implements OnChanges, OnInit, OnDestroy { const startedTopic = this.examChecklistService.getStartedTopic(this.exam); this.websocketService.subscribe(startedTopic); this.websocketService.receive(startedTopic).subscribe(() => (this.numberOfStarted += 1)); + if (this.exam && this.exam.course) { + this.quizPoolService.find(this.exam.course.id!, this.exam.id!).subscribe({ + next: (response: HttpResponse) => { + this.quizPool = response.body!; + }, + error: (error: HttpErrorResponse) => { + if (error.status === 404) { + this.quizPool = new QuizPool(); + this.quizPool.quizGroups = []; + this.quizPool.quizQuestions = []; + } else { + onError(this.alertService, error); + } + }, + }); + } } ngOnChanges() { diff --git a/src/main/webapp/app/exam/manage/exams/exam-detail.component.html b/src/main/webapp/app/exam/manage/exams/exam-detail.component.html index 2a6f442a4fe5..8866bd3cab56 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-detail.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-detail.component.html @@ -12,6 +12,11 @@

{{ 'artemisApp.examManagement.exerciseGroups' | artemisTranslate }} + + + + {{ 'artemisApp.examManagement.quizPool' | artemisTranslate }} + {{ 'artemisApp.examManagement.students' | artemisTranslate }} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts index 43ec41dc54f7..348eeb15d97b 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts @@ -31,6 +31,9 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { FitTextModule } from 'app/exercises/quiz/shared/fit-text/fit-text.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercise-categories.module'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizPoolMappingQuestionListComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping-question-list.component'; +import { QuizPoolComponent } from 'app/exercises/quiz/manage/quiz-pool.component'; import { QuizExerciseCreateButtonsComponent } from 'app/exercises/quiz/manage/quiz-exercise-create-buttons.component'; import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; import { QuizQuestionListEditExistingComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit-existing.component'; @@ -75,6 +78,9 @@ const ENTITY_STATES = [...quizManagementRoute]; QuizReEvaluateWarningComponent, QuizExerciseExportComponent, MatchPercentageInfoModalComponent, + QuizPoolMappingComponent, + QuizPoolMappingQuestionListComponent, + QuizPoolComponent, QuizQuestionListEditComponent, QuizQuestionListEditExistingComponent, ], diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.html new file mode 100644 index 000000000000..6c439d96e2fe --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.html @@ -0,0 +1,33 @@ +
+
+ +
+
+ {{ question.title }} +
+
MC
+
{{ question.points }}
+
+
+
+ {{ question.title }} +
+
DnD
+
{{ question.points }}
+
+
+
+ {{ question.title }} +
+
SA
+
{{ question.points }}
+
+
+
+
diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.scss b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.scss new file mode 100644 index 000000000000..2a36218af24e --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.scss @@ -0,0 +1,59 @@ +.question-list { + min-height: 2.5rem; +} + +.cdk-drag { + cursor: pointer; + height: 2rem; + line-height: 1.8rem; +} + +.cdk-drag .question { + width: 8rem !important; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + background: var(--text-editor-background); + color: var(--text-editor-color); +} + +.cdk-drag .question-type { + width: 3rem; + font-weight: bold; +} + +.cdk-drag .question-points { + width: 2rem; + background: var(--text-editor-background); + color: var(--text-editor-color); +} + +.border-info { + border: 1px solid var(--cyan); +} + +.border-warning { + border: 1px solid var(--yellow); +} + +.border-success { + border: 1px solid var(--green); +} + +.cdk-drop-list-dragging { + /* change highlight color to light green when dragging the item above a drop location */ + background: var(--dnd-question-drop-list-dragging-background) !important; + cursor: pointer; + + .cdk-drag { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drop-list-receiving { + background: var(--dnd-question-drop-list-receiving-background) !important; +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.ts new file mode 100644 index 000000000000..11f72daf4686 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping-question-list.component.ts @@ -0,0 +1,37 @@ +import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; +import { QuizQuestionType } from 'app/entities/quiz/quiz-question.model'; +import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; + +@Component({ + selector: 'jhi-quiz-pool-mapping-question-list', + templateUrl: './quiz-pool-mapping-question-list.component.html', + styleUrls: ['./quiz-pool-mapping-question-list.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class QuizPoolMappingQuestionListComponent { + @Input() quizQuestions: Array; + @Input() disabled = false; + + @Output() onQuizQuestionDropped: EventEmitter = new EventEmitter(); + + MULTIPLE_CHOICE = QuizQuestionType.MULTIPLE_CHOICE; + DRAG_AND_DROP = QuizQuestionType.DRAG_AND_DROP; + SHORT_ANSWER = QuizQuestionType.SHORT_ANSWER; + + /** + * If the quiz question is dropped to the same group, move the index. Otherwise, move the quiz question to the new group. + * Then emit onQuizQuestionDropped of the newly dropped QuizQuestion. + * + * @param event the onDropListDropped event + */ + handleOnDropQuestion(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + transferArrayItem(event.previousContainer.data, event.container.data, event.previousIndex, event.currentIndex); + } + const quizQuestion = event.container.data[event.currentIndex]; + this.onQuizQuestionDropped.emit(quizQuestion); + } +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.html new file mode 100644 index 000000000000..97601944429c --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.html @@ -0,0 +1,71 @@ +
+ +
+ + + + + + + + + + + + + + + +
+ Group + + Questions +
{{ quizGroup.name }} + + + + + +
+
+
+
+ + +
+
+ + +
+
+
diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.scss b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.scss new file mode 100644 index 000000000000..9e75d1169839 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.scss @@ -0,0 +1,15 @@ +.table-wrapper { + table { + table-layout: fixed; + min-width: 30rem; + + td { + width: 5rem; + vertical-align: middle; + } + } +} + +.question-list-container { + border: 1px solid var(--border-color); +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.ts new file mode 100644 index 000000000000..e4fd431ab46f --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool-mapping.component.ts @@ -0,0 +1,202 @@ +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; +import { faExclamationCircle, faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; +import { Subject } from 'rxjs'; +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-quiz-pool-mapping', + templateUrl: './quiz-pool-mapping.component.html', + styleUrls: ['./quiz-pool-mapping.component.scss'], +}) +export class QuizPoolMappingComponent implements OnInit, OnChanges, OnDestroy { + @Input() quizGroups: QuizGroup[] = []; + @Input() quizQuestions: QuizQuestion[] = []; + @Input() disabled = false; + + @Output() onQuizGroupUpdated = new EventEmitter(); + @Output() onQuizQuestionDropped = new EventEmitter(); + + quizGroupNameQuestionsMap: Map> = new Map(); + unmappedQuizQuestions: QuizQuestion[] = []; + + faPlus = faPlus; + faTimes = faTimes; + faExclamationCircle = faExclamationCircle; + + protected dialogErrorSource = new Subject(); + dialogError$ = this.dialogErrorSource.asObservable(); + + constructor(private alertService: AlertService) {} + + ngOnInit(): void { + this.handleUpdate(); + } + + ngOnChanges(): void { + this.handleUpdate(); + } + + ngOnDestroy(): void { + this.dialogErrorSource.unsubscribe(); + } + + /** + * Set the given quizGroup to the given quizQuestion and emit onQuizQuestionDropped. + * + * @param quizQuestion the quiz question that is dropped + * @param quizGroup the quiz question to which the quiz question is dropped + */ + handleOnQuizQuestionDropped(quizQuestion: QuizQuestion, quizGroup?: QuizGroup) { + quizQuestion.quizGroup = quizGroup; + this.onQuizQuestionDropped.emit(); + } + + /** + * If QuizGroup with the same name does not exist, create a new QuizGroup object and push to the quizGroups list. + * Additionally, initialize the quizGroupNameQuestionsMap of the name of the new QuizGroup + * + * @param name the name of the new quiz group + */ + addGroup(name: string) { + if (name.length == 0) { + this.alertService.error('artemisApp.quizPool.invalidReasons.groupNameEmpty'); + } else if (name.length > 100) { + this.alertService.error('artemisApp.quizPool.invalidReasons.groupNameLength'); + } else if (this.quizGroupNameQuestionsMap.has(name)) { + this.alertService.error('artemisApp.quizPool.invalidReasons.groupSameName'); + } else { + const quizGroup = new QuizGroup(); + quizGroup.name = name; + this.quizGroups.push(quizGroup); + this.quizGroupNameQuestionsMap.set(quizGroup.name, new Array()); + this.onQuizGroupUpdated.emit(); + } + } + + /** + * Move all quiz questions of the to be deleted quiz group to unmappedQuizQuestions, then remove the quiz group from the quizGroups list and from quizGroupNameQuestionsMap. + * + * @param index the index of the quiz group that is going to be deleted + */ + deleteGroup(index: number) { + const quizGroup = this.quizGroups[index]; + for (const quizQuestion of this.quizGroupNameQuestionsMap.get(quizGroup.name)!) { + this.addQuestion(quizQuestion); + quizQuestion.quizGroup = undefined; + } + this.quizGroups.splice(index, 1); + this.quizGroupNameQuestionsMap.delete(quizGroup.name); + this.dialogErrorSource.next(''); + this.onQuizGroupUpdated.emit(); + } + + /** + * Add given quizQuestion to the unmappedQuizQuestions list. + * + * @param quizQuestion the quizQuestion to be added + */ + addQuestion(quizQuestion: QuizQuestion) { + this.unmappedQuizQuestions.push(quizQuestion); + } + + /** + * If the to be deleted QuizQuestion has a group, update the list of QuizQuestion that belongs to the group. + * Otherwise, update the unmappedQuizQuestions list. + * + * @param quizQuestionToBeDeleted + */ + deleteQuestion(quizQuestionToBeDeleted: QuizQuestion) { + const groupName = quizQuestionToBeDeleted.quizGroup?.name; + if (groupName) { + const quizQuestions = this.quizGroupNameQuestionsMap.get(groupName); + const updatedQuizQuestions = quizQuestions!.filter((quizQuestion) => quizQuestion !== quizQuestionToBeDeleted); + this.quizGroupNameQuestionsMap.set(groupName, updatedQuizQuestions); + } else { + this.unmappedQuizQuestions! = this.unmappedQuizQuestions!.filter((quizQuestion) => quizQuestion !== quizQuestionToBeDeleted); + } + } + + /** + * Find list of name of the groups that do not have any questions mapped to it. + * + * @returns the list of group name + */ + getGroupNamesWithNoQuestion(): Array { + const results = new Array(); + for (const [name, quizQuestions] of this.quizGroupNameQuestionsMap) { + if (quizQuestions.length == 0) { + results.push(name); + } + } + return results; + } + + /** + * Check if there is a group with no questions mapped to it. + * + * @return true if such group exists or false otherwise + */ + hasGroupsWithNoQuestion(): boolean { + return this.getGroupNamesWithNoQuestion().length > 0; + } + + /** + * Find list of name of the groups that have questions with different points. + * + * @returns the list of group name + */ + getGroupNamesWithDifferentQuestionPoints(): Array { + const results = new Array(); + for (const [name, quizQuestions] of this.quizGroupNameQuestionsMap) { + if (!quizQuestions.every((quizQuestion) => quizQuestion.points === quizQuestions[0].points)) { + results.push(name); + } + } + return results; + } + + /** + * Check if there is a group with questions that have different points mapped to it. + * + * @return true if such group exists or false otherwise + */ + hasGroupsWithDifferentQuestionPoints() { + return this.getGroupNamesWithDifferentQuestionPoints().length > 0; + } + + /** + * Calculate the maximum points of quiz questions by summing the points of first question from each group and the points of questions from unmappedQuizQuestions. + * + * @return the maximum point + */ + getMaxPoints(): number { + let maxPoints = 0; + for (const quizQuestions of this.quizGroupNameQuestionsMap.values()) { + if (quizQuestions.length > 0) { + maxPoints += quizQuestions[0].points ?? 0; + } + } + maxPoints += this.unmappedQuizQuestions.reduce((sum: number, quizQuestion: QuizQuestion) => sum + (quizQuestion.points ?? 0), 0); + return maxPoints; + } + + /** + * Set the quizGroupNameQuestionsMap of each quiz group according to the current quizQuestions. + */ + handleUpdate() { + this.quizGroupNameQuestionsMap = new Map>(); + for (const quizGroup of this.quizGroups) { + this.quizGroupNameQuestionsMap.set(quizGroup.name, []); + } + this.unmappedQuizQuestions = []; + for (const quizQuestion of this.quizQuestions) { + if (quizQuestion.quizGroup) { + this.quizGroupNameQuestionsMap.get(quizQuestion.quizGroup.name)!.push(quizQuestion); + } else { + this.unmappedQuizQuestions.push(quizQuestion); + } + } + } +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.html new file mode 100644 index 000000000000..9ed49ee89431 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.html @@ -0,0 +1,100 @@ +
+
+

Edit Quiz Pool

+

+ +   + {{ quizPool.maxPoints }} + + +

+
+
+ +
+
+
+ + +
+
+
+
+ +
+
+ +
diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss new file mode 100644 index 000000000000..ef7fcf6573e4 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss @@ -0,0 +1,32 @@ +.title-container { + float: left; +} + +.max-score-container { + float: right; +} + +.max-score { + background: var(--quiz-exercise-detail-max-score-background); + color: var(--quiz-exercise-detail-max-score-color); +} + +.edit-quiz-footer { + height: 100px; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + box-shadow: 0 0 3px var(--edit-quiz-footer-box-shadow); + background: var(--edit-quiz-footer-background); + padding-top: 10px; + + .container { + height: 100%; + + .edit-quiz-footer-content { + flex-wrap: wrap; + } + } +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts new file mode 100644 index 000000000000..49b9b6f77a53 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts @@ -0,0 +1,260 @@ +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizQuestion, QuizQuestionType } from 'app/entities/quiz/quiz-question.model'; +import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; +import { DragAndDropQuestionUtil } from 'app/exercises/quiz/shared/drag-and-drop-question-util.service'; +import { ShortAnswerQuestionUtil } from 'app/exercises/quiz/shared/short-answer-question-util.service'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { ValidationReason } from 'app/entities/exercise.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; +import { onError } from 'app/shared/util/global.utils'; +import { computeQuizQuestionInvalidReason, isQuizQuestionValid } from 'app/exercises/quiz/shared/quiz-manage-util.service'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { Exam } from 'app/entities/exam.model'; +import dayjs from 'dayjs/esm'; + +@Component({ + selector: 'jhi-quiz-pool', + templateUrl: './quiz-pool.component.html', + providers: [DragAndDropQuestionUtil, ShortAnswerQuestionUtil], + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./quiz-pool.component.scss', '../shared/quiz.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class QuizPoolComponent implements OnInit { + @ViewChild('quizPoolQuestionMapping') + quizPoolMappingComponent: QuizPoolMappingComponent; + @ViewChild('quizQuestionsEdit') + quizQuestionsEditComponent: QuizQuestionListEditComponent; + + faExclamationCircle = faExclamationCircle; + + quizPool: QuizPool; + savedQuizPool: string; + isSaving: boolean; + isValid: boolean; + hasPendingChanges: boolean; + invalidReasons: ValidationReason[] = []; + warningReasons: ValidationReason[] = []; + + courseId: number; + examId: number; + isExamStarted: boolean; + + constructor( + private route: ActivatedRoute, + private quizPoolService: QuizPoolService, + private examService: ExamManagementService, + private changeDetectorRef: ChangeDetectorRef, + private dragAndDropQuestionUtil: DragAndDropQuestionUtil, + private shortAnswerQuestionUtil: ShortAnswerQuestionUtil, + private alertService: AlertService, + ) {} + + ngOnInit(): void { + this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); + this.examId = Number(this.route.snapshot.paramMap.get('examId')); + this.checkIfExamStarted(); + this.initializeQuizPool(); + } + + /** + * Add question to the quiz pool mapping component + * + * @param quizQuestion the quiz question to be added + */ + handleQuestionAdded(quizQuestion: QuizQuestion) { + this.quizPoolMappingComponent.addQuestion(quizQuestion); + this.handleUpdate(); + } + + /** + * Delete question from the quiz pool mapping component + * + * @param quizQuestion the quiz question to be deleted + */ + handleQuestionDeleted(quizQuestion: QuizQuestion) { + this.quizPoolMappingComponent.deleteQuestion(quizQuestion); + this.handleUpdate(); + } + + /** + * Save the quiz pool if there is pending changes and the configuration is valid + */ + save() { + if (!this.hasPendingChanges || !this.isValid) { + return; + } + + this.isSaving = true; + this.quizQuestionsEditComponent.parseAllQuestions(); + const requestOptions = {} as any; + this.quizPool.maxPoints = this.quizPoolMappingComponent.getMaxPoints(); + this.quizPoolService.update(this.courseId, this.examId, this.quizPool, requestOptions).subscribe({ + next: (quizPoolResponse: HttpResponse) => { + if (quizPoolResponse.body) { + this.onSaveSuccess(quizPoolResponse.body); + } else { + this.onSaveError(); + } + }, + error: () => this.onSaveError(), + }); + } + + /** + * Set isExamStarted to true if exam has been started or false otherwise + */ + private checkIfExamStarted() { + this.examService.find(this.courseId, this.examId).subscribe({ + next: (response: HttpResponse) => { + const exam = response.body!; + this.isExamStarted = exam.startDate ? exam.startDate.isBefore(dayjs()) : false; + this.changeDetectorRef.detectChanges(); + }, + error: (error: HttpErrorResponse) => { + onError(this.alertService, error); + }, + }); + } + + /** + * Set quizPool if already exists or create a new object otherwise + */ + private initializeQuizPool() { + this.quizPoolService.find(this.courseId, this.examId).subscribe({ + next: (response: HttpResponse) => { + this.quizPool = response.body!; + this.savedQuizPool = JSON.stringify(this.quizPool); + this.isValid = true; + this.computeReasons(); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 404) { + this.quizPool = new QuizPool(); + this.quizPool.quizGroups = []; + this.quizPool.quizQuestions = []; + this.hasPendingChanges = false; + this.isValid = true; + this.changeDetectorRef.detectChanges(); + } else { + onError(this.alertService, error); + } + }, + }); + } + + /** + * Set pending changes to true if there is a change from the last saved quiz pool and set is valid to true if the configuration is valid + */ + handleUpdate() { + this.hasPendingChanges = JSON.stringify(this.quizPool) !== this.savedQuizPool; + this.isValid = this.isConfigurationValid(); + this.computeReasons(); + } + + /** + * Set invalidReasons and warningReasons + */ + private computeReasons() { + this.changeDetectorRef.detectChanges(); + this.invalidReasons = this.getInvalidReasons(); + this.warningReasons = this.getWarningReasons(); + this.changeDetectorRef.detectChanges(); + } + + /** + * Check if the quiz questions and groups are all valid. + * @return true if the configuration is valid or false otherwise + */ + private isConfigurationValid(): boolean { + const quizQuestionsValid = this.quizPool.quizQuestions.every((question) => isQuizQuestionValid(question, this.dragAndDropQuestionUtil, this.shortAnswerQuestionUtil)); + const totalPoints = this.quizPool.quizQuestions?.map((quizQuestion) => quizQuestion.points ?? 0).reduce((accumulator, points) => accumulator + points, 0); + return ( + (this.quizPool.quizQuestions.length === 0 || (quizQuestionsValid && totalPoints > 0)) && + !this.quizPoolMappingComponent.hasGroupsWithNoQuestion() && + !this.quizPoolMappingComponent.hasGroupsWithDifferentQuestionPoints() + ); + } + + /** + * Compute invalid reasons of the configurations + * @return an array of ValidationReason. + */ + private getInvalidReasons(): Array { + const invalidReasons = new Array(); + this.quizPool.quizQuestions!.forEach((question: QuizQuestion, index: number) => { + computeQuizQuestionInvalidReason(invalidReasons, question, index, this.dragAndDropQuestionUtil, this.shortAnswerQuestionUtil); + }); + + if (this.quizPoolMappingComponent.hasGroupsWithNoQuestion()) { + const names = this.quizPoolMappingComponent.getGroupNamesWithNoQuestion(); + for (const name of names) { + invalidReasons.push({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupNoQuestion', + translateValues: { + name, + }, + }); + } + } + + if (this.quizPoolMappingComponent.hasGroupsWithDifferentQuestionPoints()) { + const names = this.quizPoolMappingComponent.getGroupNamesWithDifferentQuestionPoints(); + for (const name of names) { + invalidReasons.push({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupHasDifferentQuestionPoints', + translateValues: { + name, + }, + }); + } + } + + return invalidReasons; + } + + /** + * Compute warning reasons of the configurations + * @return an array of ValidationReason. + */ + private getWarningReasons(): Array { + const warningReasons = new Array(); + this.quizPool.quizQuestions.forEach((quizQuestion: QuizQuestion, index: number) => { + if (quizQuestion.type === QuizQuestionType.MULTIPLE_CHOICE && (quizQuestion).answerOptions!.some((option) => !option.explanation)) { + warningReasons.push({ + translateKey: 'artemisApp.quizExercise.invalidReasons.explanationIsMissing', + translateValues: { index: index + 1 }, + }); + } + }); + return warningReasons; + } + + /** + * Callback if the save is successful. Set isSaving & hasPendingchanges to false and update quizPool and savedQuizPool. + * + * @param quizPool the saved quiz pool + */ + private onSaveSuccess(quizPool: QuizPool): void { + this.isSaving = false; + this.hasPendingChanges = false; + this.quizPool = quizPool; + this.savedQuizPool = JSON.stringify(quizPool); + this.changeDetectorRef.detectChanges(); + } + + /** + * Callback if the save is unsuccessful. Set isSaving to false and display alert. + */ + private onSaveError = (): void => { + this.alertService.error('artemisApp.quizExercise.saveError'); + this.isSaving = false; + this.changeDetectorRef.detectChanges(); + }; +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts new file mode 100644 index 000000000000..c1b8e7cf4fae --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; +import { createRequestOption } from 'app/shared/util/request.util'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; + +export type EntityResponseType = HttpResponse; +export type EntityArrayResponseType = HttpResponse; + +@Injectable({ providedIn: 'root' }) +export class QuizPoolService { + constructor(private http: HttpClient) {} + + /** + * Update the given quiz pool that belongs to the given course id and exam id + * + * @param courseId the course id of which the exam belongs to + * @param examId the exam id of which the quiz pool belongs to + * @param quizPool the quiz pool to be updated + * @param req request options + * @return the updated quiz pool + */ + update(courseId: number, examId: number, quizPool: QuizPool, req?: any): Observable> { + const options = createRequestOption(req); + return this.http.put(`api/courses/${courseId}/exams/${examId}/quiz-pools`, quizPool, { params: options, observe: 'response' }); + } + + /** + * Find the quiz pool that belongs to the given course id and exam id + * + * @param courseId the course id of which the exam belongs to + * @param examId the exam id of which the quiz pool belongs to + * @return the quiz pool that belongs to the given course id and exam id + */ + find(courseId: number, examId: number): Observable> { + return this.http.get(`api/courses/${courseId}/exams/${examId}/quiz-pools`, { observe: 'response' }); + } +} diff --git a/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts b/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts index 044bf64a98fc..7a3cae1ea399 100644 --- a/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts +++ b/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts @@ -299,6 +299,7 @@ export class NavbarComponent implements OnInit, OnDestroy { unit_management: 'artemisApp.lectureUnit.home.title', exams: 'artemisApp.examManagement.title', exercise_groups: 'artemisApp.examManagement.exerciseGroups', + quiz_pool: 'artemisApp.examManagement.quizPool', students: 'artemisApp.course.students', tutors: 'artemisApp.course.tutors', instructors: 'artemisApp.course.instructors', diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index 73ef51b079b7..170c7197af8d 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -562,6 +562,7 @@ }, "importSuccessful": "Import der Aufgabengruppen erfolgreich!" }, + "quizPool": "Quiz Pool", "delete": { "question": "Soll die Klausur {{ title }} wirklich dauerhaft gelöscht werden? Alle zugehörigen Elemente werden gelöscht, inklusive der Klausuren der Studierenden. Diese Aktion kann NICHT rückgängig gemacht werden!", "typeNameToConfirm": "Bitte gib den Namen der Klausur zur Bestätigung ein." diff --git a/src/main/webapp/i18n/de/quizPool.json b/src/main/webapp/i18n/de/quizPool.json new file mode 100644 index 000000000000..9e70f7aed8fe --- /dev/null +++ b/src/main/webapp/i18n/de/quizPool.json @@ -0,0 +1,23 @@ +{ + "artemisApp": { + "quizPool": { + "editTitle": "Quiz-Pool bearbeiten", + "group": "Gruppe", + "groupName": "Gruppenname", + "addGroup": "Gruppe hinzufügen", + "delete": { + "question": "Möchten Sie die Quizgruppe {{ title }} wirklich löschen? Alle zugewiesenen Fragen werden nicht zugeordnet." + }, + "dragExplanation": "Ziehen Sie die Frage unten, um sie einer Gruppe zuzuordnen.", + "groupExplanation": "Fragen in der gleichen Gruppe werden für jeden Schüler nach dem Zufallsprinzip ausgewählt. Fragen, die nicht in einer Gruppe sind, werden allen Schülern zugewiesen.", + "updated": "Quiz Pool aktualisiert mit ID {{ param }}", + "invalidReasons": { + "groupNameEmpty": "Der Gruppenname kann nicht leer sein.", + "groupNameLength": "Der Gruppenname kann nicht länger als 100 Zeichen sein.", + "groupSameName": "Der Gruppenname muss einmalig sein.", + "groupNoQuestion": "Gruppe {{name}}: Die Gruppe hat keine Fragen.", + "groupHasDifferentQuestionPoints": "Gruppe {{name}}: Gruppe hat Fragen mit unterschiedlichen Schwerpunkten." + } + } + } +} diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index 0d0ad9daac6d..c42fb45f9d59 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -563,6 +563,7 @@ }, "importSuccessful": "Exercise Groups successfully imported!" }, + "quizPool": "Quiz Pool", "delete": { "question": "Are you sure you want to permanently delete the Exam {{ title }}? All associated elements will be deleted including the Student Exams. This action can NOT be undone!", "typeNameToConfirm": "Please type in the name of the exam to confirm." diff --git a/src/main/webapp/i18n/en/quizPool.json b/src/main/webapp/i18n/en/quizPool.json new file mode 100644 index 000000000000..1104b0f0e19d --- /dev/null +++ b/src/main/webapp/i18n/en/quizPool.json @@ -0,0 +1,23 @@ +{ + "artemisApp": { + "quizPool": { + "editTitle": "Edit Quiz Pool", + "group": "Group", + "groupName": "Group Name", + "addGroup": "Add Group", + "delete": { + "question": "Are you sure you want to delete Quiz Group {{ title }}? All assigned Questions will be unmapped." + }, + "dragExplanation": "Drag the question below to assign it to a group.", + "groupExplanation": "Questions in the same group will be randomly chosen for each student. Questions that are not in a group will be assigned to all students.", + "updated": "Updated Quiz Pool with identifier {{ param }}", + "invalidReasons": { + "groupNameEmpty": "Group name cannot be empty.", + "groupNameLength": "Group name cannot be longer than 100 characters.", + "groupSameName": "Group name must be unique.", + "groupNoQuestion": "Group {{name}}: Group does not have any question.", + "groupHasDifferentQuestionPoints": "Group {{name}}: Group has questions with different points." + } + } + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java new file mode 100644 index 000000000000..9616695f0892 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java @@ -0,0 +1,206 @@ +package de.tum.in.www1.artemis.exam; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.quiz.DragAndDropQuestion; +import de.tum.in.www1.artemis.domain.quiz.MultipleChoiceQuestion; +import de.tum.in.www1.artemis.domain.quiz.QuizGroup; +import de.tum.in.www1.artemis.domain.quiz.QuizPool; +import de.tum.in.www1.artemis.domain.quiz.QuizQuestion; +import de.tum.in.www1.artemis.domain.quiz.ShortAnswerQuestion; +import de.tum.in.www1.artemis.exercise.quizexercise.QuizExerciseFactory; +import de.tum.in.www1.artemis.exercise.quizexercise.QuizExerciseUtilService; +import de.tum.in.www1.artemis.service.QuizPoolService; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.RequestUtilService; + +class QuizPoolIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "quizpoolintegration"; + + @Autowired + private QuizPoolService quizPoolService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + @Autowired + private QuizExerciseUtilService quizExerciseUtilService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private RequestUtilService request; + + private Course course; + + private Exam exam; + + private QuizPool quizPool; + + private QuizGroup quizGroup0; + + private QuizGroup quizGroup1; + + private QuizGroup quizGroup2; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, 0, 0, 0, 1); + course = courseUtilService.addEmptyCourse(); + User instructor = userUtilService.getUserByLogin(TEST_PREFIX + "instructor1"); + instructor.setGroups(Set.of(course.getInstructorGroupName())); + exam = examUtilService.addExam(course); + quizPool = quizPoolService.update(exam.getId(), new QuizPool()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateQuizPoolSuccessful() throws Exception { + QuizPool responseQuizPool = createQuizPool(); + assertThat(responseQuizPool.getExam().getId()).isEqualTo(exam.getId()); + assertThat(responseQuizPool.getQuizGroups()).hasSize(quizPool.getQuizGroups().size()).extracting("name").containsExactly(quizPool.getQuizGroups().get(0).getName(), + quizPool.getQuizGroups().get(1).getName(), quizPool.getQuizGroups().get(2).getName()); + assertThat(responseQuizPool.getQuizQuestions()).hasSize(quizPool.getQuizQuestions().size()).extracting("title", "quizGroup.name").containsExactly( + tuple(quizPool.getQuizQuestions().get(0).getTitle(), quizGroup0.getName()), tuple(quizPool.getQuizQuestions().get(1).getTitle(), quizGroup0.getName()), + tuple(quizPool.getQuizQuestions().get(2).getTitle(), quizGroup1.getName()), tuple(quizPool.getQuizQuestions().get(3).getTitle(), quizGroup2.getName()), + tuple(quizPool.getQuizQuestions().get(4).getTitle(), null)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolSuccessful() throws Exception { + QuizPool quizPool = createQuizPool(); + + QuizGroup quizGroup3 = quizExerciseUtilService.createQuizGroup("Exception Handling"); + QuizQuestion saQuizQuestion1 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 1", quizGroup2); + QuizQuestion saQuizQuestion2 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 2", quizGroup3); + QuizQuestion saQuizQuestion3 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 3", null); + quizPool.setQuizGroups(List.of(quizPool.getQuizGroups().get(0), quizPool.getQuizGroups().get(2), quizGroup3)); + quizPool.setQuizQuestions(List.of(quizPool.getQuizQuestions().get(0), quizPool.getQuizQuestions().get(1), quizPool.getQuizQuestions().get(2), saQuizQuestion1, + saQuizQuestion2, saQuizQuestion3)); + quizPool.getQuizQuestions().get(2).setQuizGroup(quizGroup3); + + QuizPool responseQuizPool = request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, + HttpStatus.OK, null); + + assertThat(responseQuizPool.getExam().getId()).isEqualTo(exam.getId()); + assertThat(responseQuizPool.getQuizGroups()).hasSize(quizPool.getQuizGroups().size()).extracting("name").containsExactly(quizPool.getQuizGroups().get(0).getName(), + quizPool.getQuizGroups().get(1).getName(), quizPool.getQuizGroups().get(2).getName()); + assertThat(responseQuizPool.getQuizQuestions()).hasSize(quizPool.getQuizQuestions().size()).extracting("title", "quizGroup.name").containsExactly( + tuple(quizPool.getQuizQuestions().get(0).getTitle(), quizGroup0.getName()), tuple(quizPool.getQuizQuestions().get(1).getTitle(), quizGroup0.getName()), + tuple(quizPool.getQuizQuestions().get(2).getTitle(), quizGroup3.getName()), tuple(quizPool.getQuizQuestions().get(3).getTitle(), quizGroup2.getName()), + tuple(quizPool.getQuizQuestions().get(4).getTitle(), quizGroup3.getName()), tuple(quizPool.getQuizQuestions().get(5).getTitle(), null)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolBadRequestInvalidMCQuestion() throws Exception { + MultipleChoiceQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + quizQuestion.setTitle(null); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolBadRequestInvalidDnDQuestion() throws Exception { + DragAndDropQuestion quizQuestion = QuizExerciseFactory.createDragAndDropQuestion(); + quizQuestion.setCorrectMappings(null); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolBadRequestInvalidSAQuestion() throws Exception { + ShortAnswerQuestion quizQuestion = QuizExerciseFactory.createShortAnswerQuestion(); + quizQuestion.setCorrectMappings(null); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolNotFoundCourse() throws Exception { + QuizQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + int notFoundCourseId = 0; + request.putWithResponseBody("/api/courses/" + notFoundCourseId + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.NOT_FOUND, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolNotFoundExam() throws Exception { + QuizQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + int notFoundExamId = 0; + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + notFoundExamId + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.NOT_FOUND, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetQuizPoolSuccessful() throws Exception { + QuizGroup quizGroup0 = quizExerciseUtilService.createQuizGroup("Encapsulation"); + QuizGroup quizGroup1 = quizExerciseUtilService.createQuizGroup("Inheritance"); + QuizQuestion mcQuizQuestion = quizExerciseUtilService.createMultipleChoiceQuestionWithTitleAndGroup("MC", quizGroup0); + QuizQuestion dndQuizQuestion = quizExerciseUtilService.createDragAndDropQuestionWithTitleAndGroup("DND", quizGroup1); + QuizQuestion saQuizQuestion = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA", null); + quizPool.setQuizGroups(List.of(quizGroup0, quizGroup1)); + quizPool.setQuizQuestions(List.of(mcQuizQuestion, dndQuizQuestion, saQuizQuestion)); + QuizPool savedQuizPool = quizPoolService.update(exam.getId(), quizPool); + + QuizPool responseQuizPool = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", HttpStatus.OK, QuizPool.class); + assertThat(responseQuizPool.getExam().getId()).isEqualTo(exam.getId()); + assertThat(responseQuizPool.getQuizGroups()).hasSize(savedQuizPool.getQuizGroups().size()).containsExactly(savedQuizPool.getQuizGroups().get(0), + savedQuizPool.getQuizGroups().get(1)); + assertThat(responseQuizPool.getQuizQuestions()).hasSize(savedQuizPool.getQuizQuestions().size()).containsExactly(savedQuizPool.getQuizQuestions().get(0), + savedQuizPool.getQuizQuestions().get(1), savedQuizPool.getQuizQuestions().get(2)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetQuizPoolNotFoundExam() throws Exception { + int notFoundExamId = 0; + request.get("/api/courses/" + course.getId() + "/exams/" + notFoundExamId + "/quiz-pools", HttpStatus.NOT_FOUND, QuizPool.class); + } + + private QuizPool createQuizPool() throws Exception { + quizGroup0 = quizExerciseUtilService.createQuizGroup("Encapsulation"); + quizGroup1 = quizExerciseUtilService.createQuizGroup("Inheritance"); + quizGroup2 = quizExerciseUtilService.createQuizGroup("Polymorphism"); + QuizQuestion mcQuizQuestion0 = quizExerciseUtilService.createMultipleChoiceQuestionWithTitleAndGroup("MC 0", quizGroup0); + QuizQuestion mcQuizQuestion1 = quizExerciseUtilService.createMultipleChoiceQuestionWithTitleAndGroup("MC 1", quizGroup0); + QuizQuestion dndQuizQuestion0 = quizExerciseUtilService.createDragAndDropQuestionWithTitleAndGroup("DND 0", quizGroup1); + QuizQuestion dndQuizQuestion1 = quizExerciseUtilService.createDragAndDropQuestionWithTitleAndGroup("DND 1", quizGroup2); + QuizQuestion saQuizQuestion0 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 0", null); + quizPool.setQuizGroups(List.of(quizGroup0, quizGroup1, quizGroup2)); + quizPool.setQuizQuestions(List.of(mcQuizQuestion0, mcQuizQuestion1, dndQuizQuestion0, dndQuizQuestion1, saQuizQuestion0)); + + return request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.OK, null); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java index 909215386516..27594619aae1 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java @@ -362,4 +362,37 @@ public void joinQuizBatch(QuizExercise quizExercise, QuizBatch batch, String use user.setLogin(username); quizScheduleService.joinQuizBatch(quizExercise, batch, user); } + + @NotNull + public QuizGroup createQuizGroup(String name) { + QuizGroup quizGroup = new QuizGroup(); + quizGroup.setName(name); + return quizGroup; + } + + @NotNull + public MultipleChoiceQuestion createMultipleChoiceQuestionWithTitleAndGroup(String title, QuizGroup quizGroup) { + MultipleChoiceQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + setQuizQuestionsTitleAndGroup(quizQuestion, title, quizGroup); + return quizQuestion; + } + + @NotNull + public DragAndDropQuestion createDragAndDropQuestionWithTitleAndGroup(String title, QuizGroup quizGroup) { + DragAndDropQuestion quizQuestion = QuizExerciseFactory.createDragAndDropQuestion(); + setQuizQuestionsTitleAndGroup(quizQuestion, title, quizGroup); + return quizQuestion; + } + + @NotNull + public ShortAnswerQuestion createShortAnswerQuestionWithTitleAndGroup(String title, QuizGroup quizGroup) { + ShortAnswerQuestion quizQuestion = QuizExerciseFactory.createShortAnswerQuestion(); + setQuizQuestionsTitleAndGroup(quizQuestion, title, quizGroup); + return quizQuestion; + } + + private void setQuizQuestionsTitleAndGroup(Q quizQuestion, String title, QuizGroup quizGroup) { + quizQuestion.setTitle(title); + quizQuestion.setQuizGroup(quizGroup); + } } diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts index b3645afd842f..5116fe8891f9 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts @@ -33,6 +33,8 @@ import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; import { ExamLiveAnnouncementCreateButtonComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-button.component'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; @Component({ template: '', @@ -43,6 +45,7 @@ describe('ExamDetailComponent', () => { let examDetailComponentFixture: ComponentFixture; let examDetailComponent: ExamDetailComponent; let service: ExamManagementService; + let quizPoolService: QuizPoolService; let router: Router; const exampleHTML = '

Sample Markdown

'; @@ -112,6 +115,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture = TestBed.createComponent(ExamDetailComponent); examDetailComponent = examDetailComponentFixture.componentInstance; service = TestBed.inject(ExamManagementService); + quizPoolService = TestBed.inject(QuizPoolService); }); router = TestBed.inject(Router); @@ -129,6 +133,7 @@ describe('ExamDetailComponent', () => { exam.examMaxPoints = 100; exam.exerciseGroups = []; examDetailComponent.exam = exam; + jest.spyOn(quizPoolService, 'find').mockReturnValue(of(new HttpResponse({ body: new QuizPool() }))); }); afterEach(() => { diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts new file mode 100644 index 000000000000..c5828a1ec76a --- /dev/null +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts @@ -0,0 +1,65 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockDirective, MockPipe } from 'ng-mocks'; +import { ArtemisTestModule } from '../../../../test.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { QuizPoolMappingQuestionListComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping-question-list.component'; +import * as DragDrop from '@angular/cdk/drag-drop'; +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; + +describe('QuizPoolMappingQuestionListComponent', () => { + let fixture: ComponentFixture; + let component: QuizPoolMappingQuestionListComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, DragDrop.DragDropModule, HttpClientTestingModule], + declarations: [QuizPoolMappingQuestionListComponent, MockPipe(ArtemisTranslatePipe), MockPipe(ArtemisDatePipe), MockDirective(TranslateDirective)], + providers: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(QuizPoolMappingQuestionListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + }); + it('should move question in the same group', () => { + const container = { + data: {}, + }; + const event = { + previousContainer: container, + container: container, + previousIndex: 0, + currentIndex: 1, + } as DragDrop.CdkDragDrop; + const moveItemInArray = jest.spyOn(DragDrop, 'moveItemInArray').mockImplementation(() => {}); + + component.handleOnDropQuestion(event); + + expect(moveItemInArray).toHaveBeenCalledOnce(); + }); + + it('should move question within different groups', () => { + const container0 = { + data: {}, + }; + const container1 = { + data: {}, + }; + const event = { + previousContainer: container0, + container: container1, + previousIndex: 0, + currentIndex: 0, + } as DragDrop.CdkDragDrop; + const transferArrayItem = jest.spyOn(DragDrop, 'transferArrayItem').mockImplementation(() => {}); + + component.handleOnDropQuestion(event); + + expect(transferArrayItem).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts new file mode 100644 index 000000000000..595a8cb073a1 --- /dev/null +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts @@ -0,0 +1,225 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ButtonComponent } from 'app/shared/components/button.component'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { ArtemisTestModule } from '../../../../test.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { NgModel } from '@angular/forms'; +import { QuizPoolMappingQuestionListComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping-question-list.component'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; +import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; +import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; + +describe('QuizPoolMappingComponent', () => { + let fixture: ComponentFixture; + let component: QuizPoolMappingComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule, MockDirective(NgbTooltip)], + declarations: [ + QuizPoolMappingComponent, + ButtonComponent, + MockPipe(ArtemisTranslatePipe), + MockPipe(ArtemisDatePipe), + MockDirective(TranslateDirective), + MockComponent(QuizPoolMappingQuestionListComponent), + MockDirective(NgModel), + MockDirective(DeleteButtonDirective), + ], + providers: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(QuizPoolMappingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + }); + + it('should add group', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup('Test Group'); + + expect(component.quizGroups).toBeArrayOfSize(1); + }); + + it('should not add group with empty name', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup(''); + + expect(component.quizGroups).toBeArrayOfSize(0); + }); + + it('should not add group with name consisting of more than 100 characters', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean ma'); + + expect(component.quizGroups).toBeArrayOfSize(0); + }); + + it('should not add group with the same name', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup('Test Group'); + component.addGroup('Test Group'); + + expect(component.quizGroups).toBeArrayOfSize(1); + }); + + it('should delete group', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Group 1'; + const question = new MultipleChoiceQuestion(); + question.quizGroup = quizGroup; + component.quizGroups = [quizGroup]; + component.quizQuestions = [question]; + const addQuestion = jest.spyOn(component, 'addQuestion').mockImplementation(); + + component.handleUpdate(); + component.deleteGroup(0); + + expect(component.quizGroups).toBeArrayOfSize(0); + expect(addQuestion).toHaveBeenCalledOnce(); + expect(addQuestion).toHaveBeenCalledWith(question); + }); + + it('should add question', () => { + const question = new MultipleChoiceQuestion(); + component.addQuestion(question); + expect(component.unmappedQuizQuestions).toBeArrayOfSize(1); + expect(component.unmappedQuizQuestions[0]).toEqual(question); + }); + + it('should delete unmapped question', () => { + const question = new MultipleChoiceQuestion(); + component.deleteQuestion(question); + expect(component.unmappedQuizQuestions).toBeEmpty(); + }); + + it('should delete mapped question', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const question = new MultipleChoiceQuestion(); + question.quizGroup = quizGroup; + component.quizGroups = [quizGroup]; + component.handleUpdate(); + + component.deleteQuestion(question); + + const questions = component.quizGroupNameQuestionsMap.get(quizGroup.name); + expect(questions).toBeEmpty(); + }); + + it('should return group names that do not have questions', () => { + component.quizGroupNameQuestionsMap.set('Group 1', []); + component.quizGroupNameQuestionsMap.set('Group 2', [new MultipleChoiceQuestion()]); + const groupNamesWithNoQuestion = component.getGroupNamesWithNoQuestion(); + expect(groupNamesWithNoQuestion).toBeArrayOfSize(1); + expect(groupNamesWithNoQuestion[0]).toBe('Group 1'); + }); + + it('should return true if some groups with no questions', () => { + component.quizGroupNameQuestionsMap.set('Group 1', []); + component.quizGroupNameQuestionsMap.set('Group 2', [new MultipleChoiceQuestion()]); + expect(component.hasGroupsWithNoQuestion()).toBeTrue(); + }); + + it('should return false if all groups have at least 1 question', () => { + component.quizGroupNameQuestionsMap.set('Group 1', [new MultipleChoiceQuestion()]); + component.quizGroupNameQuestionsMap.set('Group 2', [new MultipleChoiceQuestion()]); + expect(component.hasGroupsWithNoQuestion()).toBeFalse(); + }); + + it('should return group names that have questions with different points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = 1; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = 2; + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', [question2, question3]); + const groupNamesWithNoQuestion = component.getGroupNamesWithDifferentQuestionPoints(); + expect(groupNamesWithNoQuestion).toBeArrayOfSize(1); + expect(groupNamesWithNoQuestion[0]).toBe('Group 2'); + }); + + it('should return true if some groups have questions with different points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = 1; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = 2; + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', [question2, question3]); + expect(component.hasGroupsWithDifferentQuestionPoints()).toBeTrue(); + }); + + it('should return false if some groups have questions with different points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = 1; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = 1; + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', [question2, question3]); + expect(component.hasGroupsWithDifferentQuestionPoints()).toBeFalse(); + }); + + it('should set unmappedQuizQuestions and quizGroupNameQuestionsMap when inputs are changed', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const question0 = new MultipleChoiceQuestion(); + const question1 = new MultipleChoiceQuestion(); + question0.quizGroup = quizGroup; + component.quizGroups = [quizGroup]; + component.quizQuestions = [question0, question1]; + component.ngOnChanges(); + expect(component.unmappedQuizQuestions).toBeArrayOfSize(1); + expect(component.unmappedQuizQuestions[0]).toEqual(question1); + expect(component.quizGroupNameQuestionsMap.size).toBe(1); + const questions = component.quizGroupNameQuestionsMap.get('Test Group'); + expect(questions).toBeArrayOfSize(1); + expect(questions![0]).toEqual(question0); + }); + + it('should set quiz group to quiz question when question is dropped to the group', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const question = new MultipleChoiceQuestion(); + component.handleOnQuizQuestionDropped(question, quizGroup); + expect(question.quizGroup).toEqual(quizGroup); + }); + + it('should return max points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = undefined; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = undefined; + component.quizGroupNameQuestionsMap = new Map(); + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', []); + component.unmappedQuizQuestions = [question2, question3]; + expect(component.getMaxPoints()).toBe(2); + }); +}); diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts new file mode 100644 index 000000000000..8cb46261cc4e --- /dev/null +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts @@ -0,0 +1,212 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; +import { ArtemisTestModule } from '../../../../test.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { QuizPoolComponent } from 'app/exercises/quiz/manage/quiz-pool.component'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { of, throwError } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { Exam } from 'app/entities/exam.model'; +import dayjs from 'dayjs/esm'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; +import { AnswerOption } from 'app/entities/quiz/answer-option.model'; +import { ChangeDetectorRef } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AlertService } from 'app/core/util/alert.service'; + +describe('QuizPoolComponent', () => { + let fixture: ComponentFixture; + let component: QuizPoolComponent; + let quizPoolService: QuizPoolService; + let examService: ExamManagementService; + let alertService: AlertService; + let changeDetectorRef: ChangeDetectorRef; + + const courseId = 1; + const examId = 2; + const route = { snapshot: { paramMap: convertToParamMap({ courseId, examId }) }, queryParams: of({}) } as any as ActivatedRoute; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule, NgbModule, FormsModule], + declarations: [ + QuizPoolComponent, + MockComponent(QuizPoolMappingComponent), + MockComponent(QuizQuestionListEditComponent), + MockPipe(ArtemisTranslatePipe), + MockPipe(ArtemisDatePipe), + MockDirective(TranslateDirective), + ], + providers: [{ provide: ActivatedRoute, useValue: route }, MockProvider(ChangeDetectorRef), MockProvider(AlertService)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(QuizPoolComponent); + component = fixture.componentInstance; + quizPoolService = fixture.debugElement.injector.get(QuizPoolService); + examService = fixture.debugElement.injector.get(ExamManagementService); + alertService = fixture.debugElement.injector.get(AlertService); + changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef); + fixture.detectChanges(); + }); + }); + + it('should initialize quiz pool', () => { + const quizPool = new QuizPool(); + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const quizQuestion = new MultipleChoiceQuestion(); + quizQuestion.quizGroup = quizGroup; + quizPool.id = 1; + quizPool.quizQuestions = [quizQuestion]; + quizPool.quizGroups = [quizGroup]; + jest.spyOn(quizPoolService, 'find').mockReturnValue(of(new HttpResponse({ body: quizPool }))); + component.ngOnInit(); + expect(component.quizPool).toEqual(quizPool); + }); + + it('should initialize quiz pool with new object if existing quiz pool is not found', () => { + jest.spyOn(quizPoolService, 'find').mockReturnValue(throwError(() => ({ status: 404 }))); + component.ngOnInit(); + expect(component.quizPool.quizGroups).toBeArrayOfSize(0); + expect(component.quizPool.quizQuestions).toBeArrayOfSize(0); + }); + + it('should set isExamStarted to true', () => { + const exam = new Exam(); + exam.startDate = dayjs().subtract(1, 'hour'); + jest.spyOn(examService, 'find').mockReturnValue(of(new HttpResponse({ body: exam }))); + component.ngOnInit(); + expect(component.isExamStarted).toBeTrue(); + }); + + it('should set isExamStarted to false', () => { + const exam = new Exam(); + exam.startDate = dayjs().add(1, 'hour'); + jest.spyOn(examService, 'find').mockReturnValue(of(new HttpResponse({ body: exam }))); + component.ngOnInit(); + expect(component.isExamStarted).toBeFalse(); + }); + + it('should call QuizGroupQuestionMappingComponent.addQuestion when there is a new question', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + const addQuestionSpy = jest.spyOn(component.quizPoolMappingComponent, 'addQuestion'); + const quizQuestion = new MultipleChoiceQuestion(); + component.handleQuestionAdded(quizQuestion); + expect(addQuestionSpy).toHaveBeenCalledOnce(); + expect(addQuestionSpy).toHaveBeenCalledWith(quizQuestion); + }); + + it('should call QuizGroupQuestionMappingComponent.deleteQuestion when a question is deleted', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + const deleteQuestionSpy = jest.spyOn(component.quizPoolMappingComponent, 'deleteQuestion'); + const quizQuestion = new MultipleChoiceQuestion(); + component.handleQuestionDeleted(quizQuestion); + expect(deleteQuestionSpy).toHaveBeenCalledOnce(); + expect(deleteQuestionSpy).toHaveBeenCalledWith(quizQuestion); + }); + + it('should call QuizPoolService.update when saving', () => { + const quizPool = new QuizPool(); + component.courseId = courseId; + component.examId = examId; + component.quizPool = quizPool; + component.hasPendingChanges = true; + component.isValid = true; + component.quizQuestionsEditComponent = new QuizQuestionListEditComponent(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + const parseAllQuestionsSpy = jest.spyOn(component.quizQuestionsEditComponent, 'parseAllQuestions').mockImplementation(); + const getMaxPointsSpy = jest.spyOn(component.quizPoolMappingComponent, 'getMaxPoints').mockImplementation(); + const updateQuizPoolSpy = jest.spyOn(quizPoolService, 'update').mockReturnValue(of(new HttpResponse({ body: quizPool }))); + component.save(); + expect(parseAllQuestionsSpy).toHaveBeenCalledOnce(); + expect(getMaxPointsSpy).toHaveBeenCalledOnce(); + expect(updateQuizPoolSpy).toHaveBeenCalledOnce(); + }); + + it('should not call QuizPoolService.update if there is no pending changes or is not valid', () => { + const quizPool = new QuizPool(); + component.courseId = courseId; + component.examId = examId; + component.quizPool = quizPool; + component.hasPendingChanges = false; + component.isValid = false; + const updateQuizPoolSpy = jest.spyOn(quizPoolService, 'update').mockImplementation(); + component.save(); + expect(updateQuizPoolSpy).toHaveBeenCalledTimes(0); + }); + + it('should set isValid to true if all questions and groups are valid', () => { + const answerOption0 = new AnswerOption(); + answerOption0.isCorrect = true; + const answerOption1 = new AnswerOption(); + answerOption1.isCorrect = false; + const question = new MultipleChoiceQuestion(); + question.points = 1; + question.answerOptions = [answerOption0, answerOption1]; + component.quizPool = new QuizPool(); + component.quizPool.quizQuestions = [question]; + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + component.isValid = false; + component.handleUpdate(); + component.isValid = true; + }); + + it('should set isValid to false if at least 1 question is invalid', () => { + const question = new MultipleChoiceQuestion(); + question.points = -1; + question.answerOptions = []; + component.quizPool = new QuizPool(); + component.quizPool.quizQuestions = [question]; + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + component.isValid = true; + component.handleUpdate(); + component.isValid = false; + }); + + it('should set invalid reasons when there is a group that does not have any question', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + jest.spyOn(changeDetectorRef.constructor.prototype, 'detectChanges').mockImplementation(); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithNoQuestion').mockReturnValue(true); + jest.spyOn(component.quizPoolMappingComponent, 'getGroupNamesWithNoQuestion').mockReturnValue(['Test Group']); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithDifferentQuestionPoints').mockReturnValue(false); + component.handleUpdate(); + expect(component.invalidReasons).toBeArrayOfSize(1); + expect(component.invalidReasons[0]).toEqual({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupNoQuestion', + translateValues: { + name: 'Test Group', + }, + }); + }); + + it('should set invalid reasons when there is a group whose questions do not have the same points', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + jest.spyOn(changeDetectorRef.constructor.prototype, 'detectChanges').mockImplementation(); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithNoQuestion').mockReturnValue(false); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithDifferentQuestionPoints').mockReturnValue(true); + jest.spyOn(component.quizPoolMappingComponent, 'getGroupNamesWithDifferentQuestionPoints').mockReturnValue(['Test Group']); + component.handleUpdate(); + expect(component.invalidReasons).toBeArrayOfSize(1); + expect(component.invalidReasons[0]).toEqual({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupHasDifferentQuestionPoints', + translateValues: { + name: 'Test Group', + }, + }); + }); +}); diff --git a/src/test/javascript/spec/service/quiz-pool.service.spec.ts b/src/test/javascript/spec/service/quiz-pool.service.spec.ts new file mode 100644 index 000000000000..049a8be08df1 --- /dev/null +++ b/src/test/javascript/spec/service/quiz-pool.service.spec.ts @@ -0,0 +1,49 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { ArtemisTestModule } from '../test.module'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { firstValueFrom } from 'rxjs'; + +describe('QuizPoolService', () => { + let quizPoolService: QuizPoolService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule], + providers: [QuizPoolService], + }); + quizPoolService = TestBed.inject(QuizPoolService); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should return updated quiz pool', async () => { + const updatedQuizPool = new QuizPool(); + updatedQuizPool.id = 1; + const courseId = 2; + const examId = 3; + const quizPool = new QuizPool(); + const response = firstValueFrom(quizPoolService.update(courseId, examId, quizPool)); + const req = httpMock.expectOne({ + method: 'PUT', + url: `api/courses/${courseId}/exams/${examId}/quiz-pools`, + }); + req.flush(updatedQuizPool); + expect((await response)?.body).toEqual(updatedQuizPool); + }); + + it('should return quiz pool', async () => { + const quizPool = new QuizPool(); + quizPool.id = 1; + const courseId = 2; + const examId = 3; + const response = firstValueFrom(quizPoolService.find(courseId, examId)); + const req = httpMock.expectOne({ + method: 'GET', + url: `api/courses/${courseId}/exams/${examId}/quiz-pools`, + }); + req.flush(quizPool); + expect((await response)?.body).toEqual(quizPool); + }); +});