diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java new file mode 100644 index 000000000000..d913f0c96e3f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.assessment.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record FeedbackAnalysisResponseDTO(SearchResultPageDTO feedbackDetails, long totalItems, int totalAmountOfTasks, List testCaseNames) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java index 23ea64b409b4..7b3fd09ad57d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java @@ -3,5 +3,5 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, int taskNumber) { +public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, String taskNumber, String errorCategory) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java new file mode 100644 index 000000000000..c63f9b5540f7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java @@ -0,0 +1,48 @@ +package de.tum.cit.aet.artemis.assessment.dto; + +import java.util.List; + +import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; + +public class FeedbackPageableDTO extends PageableSearchDTO { + + private List filterTasks; + + private List filterTestCases; + + private String[] filterOccurrence; + + private String searchTerm; + + public List getFilterTasks() { + return filterTasks; + } + + public void setFilterTasks(List filterTasks) { + this.filterTasks = filterTasks; + } + + public List getFilterTestCases() { + return filterTestCases; + } + + public void setFilterTestCases(List filterTestCases) { + this.filterTestCases = filterTestCases; + } + + public String[] getFilterOccurrence() { + return filterOccurrence; + } + + public void setFilterOccurrence(String[] filterOccurrence) { + this.filterOccurrence = filterOccurrence; + } + + public String getSearchTerm() { + return searchTerm != null ? searchTerm : ""; + } + + public void setSearchTerm(String searchTerm) { + this.searchTerm = searchTerm; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java index 07b038b9cab2..a9e9050d5e2c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java @@ -17,10 +17,12 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; +import org.apache.commons.lang3.StringUtils; import org.hibernate.Hibernate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; @@ -28,7 +30,9 @@ import de.tum.cit.aet.artemis.assessment.domain.FeedbackType; import de.tum.cit.aet.artemis.assessment.domain.LongFeedbackText; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackAnalysisResponseDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackPageableDTO; import de.tum.cit.aet.artemis.assessment.repository.ComplaintRepository; import de.tum.cit.aet.artemis.assessment.repository.ComplaintResponseRepository; import de.tum.cit.aet.artemis.assessment.repository.FeedbackRepository; @@ -40,10 +44,12 @@ import de.tum.cit.aet.artemis.buildagent.dto.ResultBuildJob; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.util.PageUtil; import de.tum.cit.aet.artemis.exam.domain.Exam; import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -53,11 +59,14 @@ import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService; import de.tum.cit.aet.artemis.lti.service.LtiNewResultService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseTestCase; import de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType; import de.tum.cit.aet.artemis.programming.domain.hestia.ProgrammingExerciseTask; import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.TemplateProgrammingExerciseParticipationRepository; @@ -110,6 +119,8 @@ public class ResultService { private final ProgrammingExerciseTaskService programmingExerciseTaskService; + private final ProgrammingExerciseRepository programmingExerciseRepository; + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository, @@ -118,7 +129,7 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository, BuildJobRepository buildJobRepository, BuildLogEntryService buildLogEntryService, StudentParticipationRepository studentParticipationRepository, - ProgrammingExerciseTaskService programmingExerciseTaskService) { + ProgrammingExerciseTaskService programmingExerciseTaskService, ProgrammingExerciseRepository programmingExerciseRepository) { this.userRepository = userRepository; this.resultRepository = resultRepository; this.ltiNewResultService = ltiNewResultService; @@ -139,6 +150,7 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos this.buildLogEntryService = buildLogEntryService; this.studentParticipationRepository = studentParticipationRepository; this.programmingExerciseTaskService = programmingExerciseTaskService; + this.programmingExerciseRepository = programmingExerciseRepository; } /** @@ -530,31 +542,85 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { } /** - * Retrieves aggregated feedback details for a given exercise, calculating relative counts based on the total number of distinct results. - * The task numbers are assigned based on the associated test case names, using the set of tasks fetched from the database. + * Retrieves paginated and filtered aggregated feedback details for a given exercise. *
* For each feedback detail: * 1. The relative count is calculated as a percentage of the total number of distinct results for the exercise. - * 2. The task number is determined by matching the test case name with the tasks. + * 2. The task numbers are assigned based on the associated test case names. A mapping between test cases and tasks is created using the set of tasks retrieved from the + * database. + *
+ * Filtering: + * - **Search term**: Filters feedback details by the search term (case-insensitive). + * - **Test case names**: Filters feedback based on specific test case names (if provided). + * - **Task names**: Maps provided task numbers to task names and filters feedback based on the test cases associated with those tasks. + * - **Occurrences**: Filters feedback where the number of occurrences (COUNT) is between the provided minimum and maximum values (inclusive). + *
+ * Pagination and sorting: + * - Sorting is applied based on the specified column and order (ascending or descending). + * - The result is paginated based on the provided page number and page size. * * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @return A list of FeedbackDetailDTO objects, each containing: - * - feedback count, - * - relative count (as a percentage of distinct results), - * - detail text, - * - test case name, - * - determined task number (based on the test case name). + * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters (task names, test cases, + * occurrence range). + * @return A {@link FeedbackAnalysisResponseDTO} object containing: + * - A {@link SearchResultPageDTO} of paginated feedback details. + * - The total number of distinct results for the exercise. + * - The total number of tasks associated with the feedback. + * - A list of test case names included in the feedback. */ - public List findAggregatedFeedbackByExerciseId(long exerciseId) { + public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data) { + + // 1. Fetch programming exercise with associated test cases + ProgrammingExercise programmingExercise = programmingExerciseRepository.findWithTestCasesByIdElseThrow(exerciseId); + long distinctResultCount = studentParticipationRepository.countDistinctResultsByExerciseId(exerciseId); - Set tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); - List feedbackDetails = studentParticipationRepository.findAggregatedFeedbackByExerciseId(exerciseId); - - return feedbackDetails.stream().map(detail -> { - double relativeCount = (detail.count() * 100.0) / distinctResultCount; - int taskNumber = tasks.stream().filter(task -> task.getTestCases().stream().anyMatch(tc -> tc.getTestName().equals(detail.testCaseName()))).findFirst() - .map(task -> tasks.stream().toList().indexOf(task) + 1).orElse(0); - return new FeedbackDetailDTO(detail.count(), relativeCount, detail.detailText(), detail.testCaseName(), taskNumber); + + // 2. Extract test case names using streams + List testCaseNames = programmingExercise.getTestCases().stream().map(ProgrammingExerciseTestCase::getTestName).toList(); + + List tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); + + // 3. Generate filter task names directly + List filterTaskNames = data.getFilterTasks().stream().map(index -> { + int idx = Integer.parseInt(index); + return (idx > 0 && idx <= tasks.size()) ? tasks.get(idx - 1).getTaskName() : null; + }).filter(Objects::nonNull).toList(); + + // 4. Set minOccurrence and maxOccurrence based on filterOccurrence + long minOccurrence = data.getFilterOccurrence().length == 2 ? Long.parseLong(data.getFilterOccurrence()[0]) : 0; + long maxOccurrence = data.getFilterOccurrence().length == 2 ? Long.parseLong(data.getFilterOccurrence()[1]) : Integer.MAX_VALUE; + + // 5. Create pageable object for pagination + final var pageable = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS); + + // 6. Fetch filtered feedback from the repository + final Page feedbackDetailPage = studentParticipationRepository.findFilteredFeedbackByExerciseId(exerciseId, + StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), filterTaskNames, minOccurrence, maxOccurrence, + pageable); + + // 7. Process feedback details + // Map to index (+1 for 1-based indexing) + List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> { + String taskIndex = tasks.stream().filter(task -> task.getTaskName().equals(detail.taskNumber())).findFirst().map(task -> String.valueOf(tasks.indexOf(task) + 1)) + .orElse("0"); + return new FeedbackDetailDTO(detail.count(), (detail.count() * 100.00) / distinctResultCount, detail.detailText(), detail.testCaseName(), taskIndex, "StudentError"); }).toList(); + + // 8. Return the response DTO containing feedback details, total elements, and test case/task info + return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, feedbackDetailPage.getTotalPages()), feedbackDetailPage.getTotalElements(), tasks.size(), + testCaseNames); + } + + /** + * Retrieves the maximum feedback count for a given exercise. + *
+ * This method calls the repository to fetch the maximum number of feedback occurrences across all feedback items for a specific exercise. + * This is used for filtering feedback based on the number of occurrences. + * + * @param exerciseId The ID of the exercise for which the maximum feedback count is to be retrieved. + * @return The maximum count of feedback occurrences for the given exercise. + */ + public long getMaxCountForExercise(long exerciseId) { + return studentParticipationRepository.findMaxCountForExercise(exerciseId); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java index 1692beaa7d69..5e28aa48b288 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java @@ -18,6 +18,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,19 +28,21 @@ import de.tum.cit.aet.artemis.assessment.domain.Feedback; import de.tum.cit.aet.artemis.assessment.domain.Result; -import de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackAnalysisResponseDTO; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackPageableDTO; import de.tum.cit.aet.artemis.assessment.dto.ResultWithPointsPerGradingCriterionDTO; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.ResultService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; -import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastInstructorInExercise; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.HeaderUtil; import de.tum.cit.aet.artemis.exam.domain.Exam; @@ -280,16 +283,56 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo } /** - * GET /exercises/:exerciseId/feedback-details : Retrieves all aggregated feedback details for a given exercise. - * The feedback details include counts and relative counts of feedback occurrences, along with associated test case names and task numbers. + * GET /exercises/{exerciseId}/feedback-details : Retrieves paginated and filtered aggregated feedback details for a given exercise. + * The feedback details include counts and relative counts of feedback occurrences, test case names, and task numbers. + * The method allows filtering by a search term and sorting by various fields. + *
+ * Pagination is applied based on the provided query parameters, including page number, page size, sorting order, and search term. + * Sorting is applied by the specified sorted column and sorting order. If the provided sorted column is not valid for sorting (e.g., "taskNumber" or "errorCategory"), + * the sorting defaults to "count". + *
+ * Filtering is applied based on: + * - Task numbers (mapped to task names) + * - Test case names + * - Occurrence range (minimum and maximum occurrences) + *
+ * The response contains both the paginated feedback details and the total count of distinct results for the exercise. * * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @return A ResponseEntity containing a list of {@link FeedbackDetailDTO}s + * @param data A {@link FeedbackPageableDTO} object containing pagination and filtering parameters, such as: + * - Page number + * - Page size + * - Search term (optional) + * - Sorting order (ASCENDING or DESCENDING) + * - Sorted column + * - Filter task numbers (optional) + * - Filter test case names (optional) + * - Occurrence range (optional) + * @return A {@link ResponseEntity} containing a {@link FeedbackAnalysisResponseDTO}, which includes: + * - {@link SearchResultPageDTO < FeedbackDetailDTO >} feedbackDetails: Paginated feedback details for the exercise. + * - long totalItems: The total number of feedback items (used for pagination). + * - int totalAmountOfTasks: The total number of tasks associated with the feedback. + * - List testCaseNames: A list of test case names included in the feedback. */ @GetMapping("exercises/{exerciseId}/feedback-details") - @EnforceAtLeastEditorInExercise - public ResponseEntity> getAllFeedbackDetailsForExercise(@PathVariable Long exerciseId) { - log.debug("REST request to get all Feedback details for Exercise {}", exerciseId); - return ResponseEntity.ok(resultService.findAggregatedFeedbackByExerciseId(exerciseId)); + @EnforceAtLeastInstructorInExercise + public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @ModelAttribute FeedbackPageableDTO data) { + FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data); + return ResponseEntity.ok(response); + } + + /** + * GET /exercises/{exerciseId}/feedback-details-max-count : Retrieves the maximum number of feedback occurrences for a given exercise. + * This method is useful for determining the highest count of feedback occurrences across all feedback items for the exercise, + * which can then be used to filter or adjust feedback analysis results. + * + * @param exerciseId The ID of the exercise for which the maximum feedback count should be retrieved. + * @return A {@link ResponseEntity} containing the maximum count of feedback occurrences (long). + */ + @GetMapping("exercises/{exerciseId}/feedback-details-max-count") + @EnforceAtLeastInstructorInExercise + public ResponseEntity getMaxCount(@PathVariable long exerciseId) { + long maxCount = resultService.getMaxCountForExercise(exerciseId); + return ResponseEntity.ok(maxCount); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java index 40cbff0c217d..b1d3aaf5c20e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; import de.tum.cit.aet.artemis.core.dto.SortingOrder; import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; @@ -69,6 +70,11 @@ public enum ColumnMapping { "id", "id", "name", "name", "build_completion_date", "buildCompletionDate" + )), + FEEDBACK_ANALYSIS(Map.of( + "count", "COUNT(f.id)", + "detailText", "f.detailText", + "testCaseName", "f.testCase.testName" )); // @formatter:on @@ -87,9 +93,29 @@ public String getMappedColumnName(String columnName) { } } + /** + * Creates a default {@link PageRequest} based on the provided {@link PageableSearchDTO} and {@link ColumnMapping}. + * This method maps the sorted column name from the provided search DTO using the column mapping, + * applies the appropriate sorting order (ascending or descending), and constructs a {@link PageRequest} + * with pagination and sorting information. + * + *

+ * If the mapped column name contains a "COUNT(" expression, this method treats it as an unsafe sort expression + * and uses {@link JpaSort(String)} to apply sorting directly to the database column. + *

+ * + * @param search The {@link PageableSearchDTO} containing pagination and sorting parameters (e.g., page number, page size, sorted column, and sorting order). + * @param columnMapping The {@link ColumnMapping} object used to map the sorted column name from the DTO to the actual database column. + * @return A {@link PageRequest} object containing the pagination and sorting options based on the search and column mapping. + * @throws IllegalArgumentException if any of the parameters are invalid or missing. + * @throws NullPointerException if the search or columnMapping parameters are null. + */ @NotNull public static PageRequest createDefaultPageRequest(PageableSearchDTO search, ColumnMapping columnMapping) { - var sortOptions = Sort.by(columnMapping.getMappedColumnName(search.getSortedColumn())); + String mappedColumn = columnMapping.getMappedColumnName(search.getSortedColumn()); + + var sortOptions = mappedColumn.contains("(") ? JpaSort.unsafe(mappedColumn) : Sort.by(mappedColumn); + sortOptions = search.getSortingOrder() == SortingOrder.ASCENDING ? sortOptions.ascending() : sortOptions.descending(); return PageRequest.of(search.getPage() - 1, search.getPageSize(), sortOptions); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java index aceb0bd9c2ae..499818ace8a2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java @@ -1199,12 +1199,29 @@ SELECT COALESCE(AVG(p.presentationScore), 0) double getAvgPresentationScoreByCourseId(@Param("courseId") long courseId); /** - * Retrieves aggregated feedback details for a given exercise, including the count of each unique feedback detail text and test case name. + * Retrieves aggregated feedback details for a given exercise, including the count of each unique feedback detail text, test case name, and task. *
- * The relative count and task number are initially set to 0 and are calculated in a separate step in the service layer. + * The query calculates: + * - The number of occurrences of each feedback detail (COUNT). + * - The relative count as a percentage of the total distinct results. + * - The corresponding task name for each feedback item by checking if the feedback test case name is associated with a task. + *
+ * It supports filtering by: + * - Search term: Case-insensitive filtering on feedback detail text. + * - Test case names: Filters feedback based on specific test case names. + * - Task names: Filters feedback based on specific task names by mapping them to their associated test cases. + * - Occurrence range: Filters feedback based on the count of occurrences between the specified minimum and maximum values (inclusive). + *
+ * Grouping is done by feedback detail text and test case name. The occurrence count is filtered using the HAVING clause. * - * @param exerciseId Exercise ID. - * @return a list of {@link FeedbackDetailDTO} objects, with the relative count and task number set to 0. + * @param exerciseId The ID of the exercise for which feedback details should be retrieved. + * @param searchTerm The search term used for filtering the feedback detail text (optional). + * @param filterTestCases List of test case names to filter the feedback results (optional). + * @param filterTaskNames List of task names to filter feedback results based on the associated test cases (optional). + * @param minOccurrence The minimum number of occurrences to include in the results. + * @param maxOccurrence The maximum number of occurrences to include in the results. + * @param pageable Pagination information to apply. + * @return A page of {@link FeedbackDetailDTO} objects representing the aggregated feedback details. */ @Query(""" SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO( @@ -1212,38 +1229,87 @@ SELECT COALESCE(AVG(p.presentationScore), 0) 0, f.detailText, f.testCase.testName, - 0 - ) + COALESCE(( + SELECT t.taskName + FROM ProgrammingExerciseTask t + JOIN t.testCases tct + WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName + ), ''), + '' + ) FROM StudentParticipation p - JOIN p.results r + JOIN p.results r ON r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + WHERE pr.participation.id = p.id + ) JOIN r.feedbacks f - WHERE p.exercise.id = :exerciseId - AND p.testRun = FALSE - AND r.id = ( - SELECT MAX(pr.id) - FROM p.results pr - ) - AND f.positive = FALSE - GROUP BY f.detailText, f.testCase.testName + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + AND f.positive = FALSE + AND (:searchTerm = '' OR LOWER(f.detailText) LIKE LOWER(CONCAT('%', REPLACE(REPLACE(:searchTerm, '%', '\\%'), '_', '\\_'), '%')) ESCAPE '\\') + AND (:#{#filterTestCases != NULL && #filterTestCases.size() < 1} = TRUE OR f.testCase.testName IN (:filterTestCases)) + AND (:#{#filterTaskNames != NULL && #filterTaskNames.size() < 1} = TRUE OR f.testCase.testName IN ( + SELECT tct.testName + FROM ProgrammingExerciseTask t + JOIN t.testCases tct + WHERE t.taskName IN (:filterTaskNames) + )) + GROUP BY f.detailText, f.testCase.testName + HAVING COUNT(f.id) BETWEEN :minOccurrence AND :maxOccurrence """) - List findAggregatedFeedbackByExerciseId(@Param("exerciseId") long exerciseId); + Page findFilteredFeedbackByExerciseId(@Param("exerciseId") long exerciseId, @Param("searchTerm") String searchTerm, + @Param("filterTestCases") List filterTestCases, @Param("filterTaskNames") List filterTaskNames, @Param("minOccurrence") long minOccurrence, + @Param("maxOccurrence") long maxOccurrence, Pageable pageable); /** * Counts the distinct number of latest results for a given exercise, excluding those in practice mode. + *
+ * For each participation, it selects only the latest result (using MAX) and ensures that the participation is not a test run. * - * @param exerciseId Exercise ID. - * @return The count of distinct latest results for the exercise. + * @param exerciseId Exercise ID for which distinct results should be counted. + * @return The total number of distinct latest results for the given exercise. */ @Query(""" - SELECT COUNT(DISTINCT r.id) + SELECT COUNT(DISTINCT r.id) + FROM StudentParticipation p + JOIN p.results r ON r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + WHERE pr.participation.id = p.id + ) + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + """) + long countDistinctResultsByExerciseId(@Param("exerciseId") long exerciseId); + + /** + * Retrieves the maximum feedback count for a given exercise. + *
+ * This query calculates the maximum number of feedback occurrences across all feedback entries for a specific exercise. + * It considers only the latest result per participation and excludes test runs. + *
+ * Grouping is done by feedback detail text and test case name, and the maximum feedback count is returned. + * + * @param exerciseId The ID of the exercise for which the maximum feedback count is to be retrieved. + * @return The maximum count of feedback occurrences for the given exercise. + */ + @Query(""" + SELECT MAX(feedbackCounts.feedbackCount) + FROM ( + SELECT COUNT(f.id) AS feedbackCount FROM StudentParticipation p - JOIN p.results r + JOIN p.results r ON r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + WHERE pr.participation.id = p.id + ) + JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId - AND p.testRun = FALSE - AND r.id = ( - SELECT MAX(pr.id) - FROM p.results pr - ) + AND p.testRun = FALSE + AND f.positive = FALSE + GROUP BY f.detailText, f.testCase.testName + ) AS feedbackCounts """) - long countDistinctResultsByExerciseId(@Param("exerciseId") long exerciseId); + long findMaxCountForExercise(@Param("exerciseId") long exerciseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java index 579d714b18a8..5f2bb062c2ec 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java @@ -988,4 +988,15 @@ public String getFetchPath() { default ProgrammingExercise findByIdElseThrow(long programmingExerciseId) { return getValueElseThrow(findById(programmingExerciseId)); } + + /** + * Find a programming exercise by its id, including its test cases, and throw an Exception if it cannot be found. + * + * @param exerciseId of the programming exercise. + * @return The programming exercise with the associated test cases related to the given id. + * @throws EntityNotFoundException if the programming exercise with the given id cannot be found. + */ + default ProgrammingExercise findWithTestCasesByIdElseThrow(Long exerciseId) { + return getArbitraryValueElseThrow(findWithTestCasesById(exerciseId), Long.toString(exerciseId)); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java index 2c8db4544456..432727c61a3e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -54,7 +55,7 @@ default ProgrammingExerciseTask findByIdWithTestCaseAndSolutionEntriesElseThrow( * @throws EntityNotFoundException If the exercise with exerciseId does not exist */ @NotNull - default Set findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(long exerciseId) throws EntityNotFoundException { + default List findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(long exerciseId) throws EntityNotFoundException { return getArbitraryValueElseThrow(findByExerciseIdWithTestCaseAndSolutionEntries(exerciseId), Long.toString(exerciseId)); } @@ -72,7 +73,7 @@ default Set findByExerciseIdWithTestCaseAndSolutionEntr WHERE t.exercise.id = :exerciseId AND tc.exercise.id = :exerciseId """) - Optional> findByExerciseIdWithTestCaseAndSolutionEntries(@Param("exerciseId") long exerciseId); + Optional> findByExerciseIdWithTestCaseAndSolutionEntries(@Param("exerciseId") long exerciseId); /** * Gets all tasks with its test cases for a programming exercise diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index d4e7de493f52..19f8e6e4cf80 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -1004,7 +1004,7 @@ public boolean preCheckProjectExistsOnVCSOrCI(ProgrammingExercise programmingExe * @param exerciseId of the exercise */ public void deleteTasksWithSolutionEntries(Long exerciseId) { - Set tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); + List tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); Set solutionEntries = tasks.stream().map(ProgrammingExerciseTask::getTestCases).flatMap(Collection::stream) .map(ProgrammingExerciseTestCase::getSolutionEntries).flatMap(Collection::stream).collect(Collectors.toSet()); programmingExerciseTaskRepository.deleteAll(tasks); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java index 24ed52858fe1..1684cf52c018 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java @@ -189,8 +189,8 @@ public Set getTasksWithoutInactiveTestCases(long exerci * @param exerciseId of the programming exercise * @return Set of all tasks including one for not manually assigned tests */ - public Set getTasksWithUnassignedTestCases(long exerciseId) { - Set tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); + public List getTasksWithUnassignedTestCases(long exerciseId) { + List tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); Set testsWithTasks = tasks.stream().flatMap(task -> task.getTestCases().stream()).collect(Collectors.toSet()); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java index 8618c9be7c1c..379ebfacb035 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java @@ -2,6 +2,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import java.util.List; import java.util.Set; import org.slf4j.Logger; @@ -74,13 +75,13 @@ public ResponseEntity> getTasks(@PathVariable Long */ @GetMapping("programming-exercises/{exerciseId}/tasks-with-unassigned-test-cases") @EnforceAtLeastTutor - public ResponseEntity> getTasksWithUnassignedTask(@PathVariable Long exerciseId) { + public ResponseEntity> getTasksWithUnassignedTask(@PathVariable Long exerciseId) { log.debug("REST request to retrieve ProgrammingExerciseTasks for ProgrammingExercise with id : {}", exerciseId); // Reload the exercise from the database as we can't trust data from the client ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, exercise, null); - Set tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); + List tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); return ResponseEntity.ok(tasks); } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component.html new file mode 100644 index 000000000000..0571f1039ca6 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component.html @@ -0,0 +1,70 @@ + + + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component.ts new file mode 100644 index 000000000000..09e6784658b9 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component.ts @@ -0,0 +1,90 @@ +import { Component, computed, inject, output, signal } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { FeedbackAnalysisService } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LocalStorageService } from 'ngx-webstorage'; + +export interface FilterData { + tasks: string[]; + testCases: string[]; + occurrence: number[]; +} + +@Component({ + selector: 'jhi-feedback-filter-modal', + templateUrl: './feedback-filter-modal.component.html', + imports: [RangeSliderComponent, ArtemisSharedCommonModule], + providers: [FeedbackAnalysisService], + standalone: true, +}) +export class FeedbackFilterModalComponent { + private localStorage = inject(LocalStorageService); + private activeModal = inject(NgbActiveModal); + + filterApplied = output(); + + readonly FILTER_TASKS_KEY = 'feedbackAnalysis.tasks'; + readonly FILTER_TEST_CASES_KEY = 'feedbackAnalysis.testCases'; + readonly FILTER_OCCURRENCE_KEY = 'feedbackAnalysis.occurrence'; + + readonly totalAmountOfTasks = signal(0); + readonly testCaseNames = signal([]); + readonly minCount = signal(0); + readonly maxCount = signal(0); + readonly taskArray = computed(() => Array.from({ length: this.totalAmountOfTasks() }, (_, i) => i + 1)); + + filters: FilterData = { + tasks: [], + testCases: [], + occurrence: [this.minCount(), this.maxCount() || 1], + }; + + applyFilter(): void { + this.localStorage.store(this.FILTER_TASKS_KEY, this.filters.tasks); + this.localStorage.store(this.FILTER_TEST_CASES_KEY, this.filters.testCases); + this.localStorage.store(this.FILTER_OCCURRENCE_KEY, this.filters.occurrence); + this.filterApplied.emit(this.filters); + this.activeModal.close(); + } + + clearFilter(): void { + this.localStorage.clear(this.FILTER_TASKS_KEY); + this.localStorage.clear(this.FILTER_TEST_CASES_KEY); + this.localStorage.clear(this.FILTER_OCCURRENCE_KEY); + this.filters = { + tasks: [], + testCases: [], + occurrence: [this.minCount(), this.maxCount()], + }; + this.filterApplied.emit(this.filters); + this.activeModal.close(); + } + + onCheckboxChange(event: Event, controlName: keyof FilterData): void { + const checkbox = event.target as HTMLInputElement; + const values = this.filters[controlName]; + + if (controlName === 'occurrence') { + const numericValue = Number(checkbox.value); + this.pushValue(checkbox, values as number[], numericValue); + } else { + this.pushValue(checkbox, values as string[], checkbox.value); + } + } + + private pushValue(checkbox: HTMLInputElement, values: T[], valueToAddOrRemove: T): void { + if (checkbox.checked) { + values.push(valueToAddOrRemove); + } else { + const index = values.indexOf(valueToAddOrRemove); + if (index >= 0) { + values.splice(index, 1); + } + } + } + + closeModal(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component.html new file mode 100644 index 000000000000..abf8a2bf5b47 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component.html @@ -0,0 +1,44 @@ +
+

+ +
+
+
+
+ + + + + + + + +
+ +
+

{{ feedbackDetail().detailText }}

+
+ + +
+ +

{{ value }}

+
+
diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component.ts new file mode 100644 index 000000000000..7178244be5d2 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component.ts @@ -0,0 +1,15 @@ +import { Component, inject, input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; + +@Component({ + selector: 'jhi-feedback-modal', + templateUrl: './feedback-modal.component.html', + imports: [ArtemisSharedCommonModule], + standalone: true, +}) +export class FeedbackModalComponent { + feedbackDetail = input.required(); + activeModal = inject(NgbActiveModal); +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html index 4c76747e8e96..295cb206a373 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html @@ -1,27 +1,81 @@ + + + + @if (sortedColumn() === column) { + + } + +
-

+
+

+
+ + +
+
- - - - - + + + + + - @for (item of feedbackDetails; track item) { + @for (item of content().resultsOnPage; track item) { - + - - + + }
{{ item.count }} ({{ item.relativeCount | number: '1.0-0' }}%){{ item.detailText }} + {{ item.detailText.length > MAX_FEEDBACK_DETAIL_TEXT_LENGTH ? (item.detailText | slice: 0 : 100) + '...' : item.detailText }} + {{ item.taskNumber }} {{ item.testCaseName }}Student Error{{ item.errorCategory }} + +
-
+
+ + +
+ +
+
diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.scss b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.scss new file mode 100644 index 000000000000..64756aa92d8b --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.scss @@ -0,0 +1,9 @@ +.position-relative { + position: relative; +} + +.search-icon { + position: absolute; + right: 10px; + pointer-events: none; +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts index 7e1d48121f1c..24855955f5b7 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts @@ -1,34 +1,170 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { FeedbackAnalysisService, FeedbackDetail } from './feedback-analysis.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AlertService } from 'app/core/util/alert.service'; +import { faFilter, faSort, faSortDown, faSortUp, faUpRightAndDownLeftFromCenter } from '@fortawesome/free-solid-svg-icons'; +import { SearchResult, SortingOrder } from 'app/shared/table/pageable-table'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { FeedbackModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component'; +import { FeedbackFilterModalComponent, FilterData } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; +import { LocalStorageService } from 'ngx-webstorage'; +import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; @Component({ selector: 'jhi-feedback-analysis', templateUrl: './feedback-analysis.component.html', + styleUrls: ['./feedback-analysis.component.scss'], standalone: true, - imports: [ArtemisSharedModule], + imports: [ArtemisSharedCommonModule], providers: [FeedbackAnalysisService], }) -export class FeedbackAnalysisComponent implements OnInit { - @Input() exerciseTitle: string; - @Input() exerciseId: number; - feedbackDetails: FeedbackDetail[] = []; +export class FeedbackAnalysisComponent { + exerciseTitle = input.required(); + exerciseId = input.required(); - constructor( - private feedbackAnalysisService: FeedbackAnalysisService, - private alertService: AlertService, - ) {} + private feedbackAnalysisService = inject(FeedbackAnalysisService); + private alertService = inject(AlertService); + private modalService = inject(NgbModal); + private localStorage = inject(LocalStorageService); - ngOnInit(): void { - this.loadFeedbackDetails(this.exerciseId); + readonly page = signal(1); + readonly pageSize = signal(20); + readonly searchTerm = signal(''); + readonly sortingOrder = signal(SortingOrder.DESCENDING); + readonly sortedColumn = signal('count'); + + readonly content = signal>({ resultsOnPage: [], numberOfPages: 0 }); + readonly totalItems = signal(0); + readonly collectionsSize = computed(() => this.content().numberOfPages * this.pageSize()); + + readonly faSort = faSort; + readonly faSortUp = faSortUp; + readonly faSortDown = faSortDown; + readonly faFilter = faFilter; + readonly faUpRightAndDownLeftFromCenter = faUpRightAndDownLeftFromCenter; + readonly SortingOrder = SortingOrder; + readonly MAX_FEEDBACK_DETAIL_TEXT_LENGTH = 150; + readonly sortIcon = computed(() => (this.sortingOrder() === SortingOrder.ASCENDING ? this.faSortUp : this.faSortDown)); + + readonly FILTER_TASKS_KEY = 'feedbackAnalysis.tasks'; + readonly FILTER_TEST_CASES_KEY = 'feedbackAnalysis.testCases'; + readonly FILTER_OCCURRENCE_KEY = 'feedbackAnalysis.occurrence'; + readonly selectedFiltersCount = signal(0); + readonly totalAmountOfTasks = signal(0); + readonly testCaseNames = signal([]); + readonly minCount = signal(0); + readonly maxCount = signal(0); + + private readonly debounceLoadData = BaseApiHttpService.debounce(this.loadData.bind(this), 300); + + constructor() { + effect(() => { + untracked(async () => { + await this.loadData(); + }); + }); } - async loadFeedbackDetails(exerciseId: number): Promise { + private async loadData(): Promise { + const savedTasks = this.localStorage.retrieve(this.FILTER_TASKS_KEY) || []; + const savedTestCases = this.localStorage.retrieve(this.FILTER_TEST_CASES_KEY) || []; + const savedOccurrence = this.localStorage.retrieve(this.FILTER_OCCURRENCE_KEY) || []; + + const state = { + page: this.page(), + pageSize: this.pageSize(), + searchTerm: this.searchTerm() || '', + sortingOrder: this.sortingOrder(), + sortedColumn: this.sortedColumn(), + }; + try { - this.feedbackDetails = await this.feedbackAnalysisService.getFeedbackDetailsForExercise(exerciseId); + const response = await this.feedbackAnalysisService.search(state, { + exerciseId: this.exerciseId(), + filters: { + tasks: this.selectedFiltersCount() !== 0 ? savedTasks : [], + testCases: this.selectedFiltersCount() !== 0 ? savedTestCases : [], + occurrence: this.selectedFiltersCount() !== 0 ? savedOccurrence : [], + }, + }); + this.content.set(response.feedbackDetails); + this.totalItems.set(response.totalItems); + this.totalAmountOfTasks.set(response.totalAmountOfTasks); + this.testCaseNames.set(response.testCaseNames); } catch (error) { - this.alertService.error(`artemisApp.programmingExercise.configureGrading.feedbackAnalysis.error`); + this.alertService.error('artemisApp.programmingExercise.configureGrading.feedbackAnalysis.error'); + } + } + + setPage(newPage: number): void { + this.page.set(newPage); + this.loadData(); + } + + async search(searchTerm: string): Promise { + this.page.set(1); + this.searchTerm.set(searchTerm); + this.debounceLoadData(); + } + + openFeedbackModal(feedbackDetail: FeedbackDetail): void { + const modalRef = this.modalService.open(FeedbackModalComponent, { centered: true }); + modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); + } + + isSortableColumn(column: string): boolean { + return ['count', 'detailText', 'testCaseName'].includes(column); + } + + setSortedColumn(column: string): void { + if (this.sortedColumn() === column) { + this.sortingOrder.set(this.sortingOrder() === SortingOrder.ASCENDING ? SortingOrder.DESCENDING : SortingOrder.ASCENDING); + } else { + this.sortedColumn.set(column); + this.sortingOrder.set(SortingOrder.ASCENDING); + } + this.loadData(); + } + + async openFilterModal(): Promise { + const savedTasks = this.localStorage.retrieve(this.FILTER_TASKS_KEY); + const savedTestCases = this.localStorage.retrieve(this.FILTER_TEST_CASES_KEY); + const savedOccurrence = this.localStorage.retrieve(this.FILTER_OCCURRENCE_KEY); + this.minCount.set(0); + this.maxCount.set(await this.feedbackAnalysisService.getMaxCount(this.exerciseId())); + + const modalRef = this.modalService.open(FeedbackFilterModalComponent, { centered: true, size: 'lg' }); + + modalRef.componentInstance.exerciseId = this.exerciseId; + modalRef.componentInstance.totalAmountOfTasks = this.totalAmountOfTasks; + modalRef.componentInstance.testCaseNames = this.testCaseNames; + modalRef.componentInstance.maxCount = this.maxCount; + modalRef.componentInstance.filters = { + tasks: this.selectedFiltersCount() !== 0 ? savedTasks : [], + testCases: this.selectedFiltersCount() !== 0 ? savedTestCases : [], + occurrence: this.selectedFiltersCount() !== 0 ? savedOccurrence : [this.minCount(), this.maxCount()], + }; + modalRef.componentInstance.filterApplied.subscribe((filters: any) => { + this.applyFilters(filters); + }); + } + + applyFilters(filters: FilterData): void { + this.selectedFiltersCount.set(this.countAppliedFilters(filters)); + this.loadData(); + } + + countAppliedFilters(filters: FilterData): number { + let count = 0; + if (filters.tasks && filters.tasks.length > 0) { + count += filters.tasks.length; + } + if (filters.testCases && filters.testCases.length > 0) { + count += filters.testCases.length; + } + if (filters.occurrence && (filters.occurrence[0] !== 0 || filters.occurrence[1] !== this.maxCount())) { + count++; } + return count; } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts index 4fa81cf289d3..bb235de5a24c 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts @@ -1,19 +1,40 @@ import { Injectable } from '@angular/core'; +import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; +import { HttpParams } from '@angular/common/http'; +import { FilterData } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; +export interface FeedbackAnalysisResponse { + feedbackDetails: SearchResult; + totalItems: number; + totalAmountOfTasks: number; + testCaseNames: string[]; +} export interface FeedbackDetail { count: number; relativeCount: number; detailText: string; testCaseName: string; - taskNumber: number; + taskNumber: string; + errorCategory: string; } - @Injectable() export class FeedbackAnalysisService extends BaseApiHttpService { - private readonly EXERCISE_RESOURCE_URL = 'exercises'; + search(pageable: SearchTermPageableSearch, options: { exerciseId: number; filters: FilterData }): Promise { + const params = new HttpParams() + .set('page', pageable.page.toString()) + .set('pageSize', pageable.pageSize.toString()) + .set('searchTerm', pageable.searchTerm || '') + .set('sortingOrder', pageable.sortingOrder) + .set('sortedColumn', pageable.sortedColumn) + .set('filterTasks', options.filters.tasks.join(',')) + .set('filterTestCases', options.filters.testCases.join(',')) + .set('filterOccurrence', options.filters.occurrence.join(',')); + + return this.get(`exercises/${options.exerciseId}/feedback-details`, { params }); + } - getFeedbackDetailsForExercise(exerciseId: number): Promise { - return this.get(`${this.EXERCISE_RESOURCE_URL}/${exerciseId}/feedback-details`); + getMaxCount(exerciseId: number): Promise { + return this.get(`exercises/${exerciseId}/feedback-details-max-count`); } } diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index e3a86ea5c71d..f26d5955a9ef 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -351,7 +351,23 @@ "testcase": "Testfall", "errorCategory": "Fehlerkategorie", "totalItems": "Insgesamt {{count}} Elemente", - "error": "Beim Laden des Feedbacks ist ein Fehler aufgetreten." + "error": "Beim Laden des Feedbacks ist ein Fehler aufgetreten.", + "search": "Suche ...", + "filter": "Filter", + "feedbackModal": { + "header": "Fehlerdetails", + "feedbackTitle": "Feedback zu Testfällen", + "ok": "Ok" + }, + "filterModal": { + "modalTitle": "Filteroptionen", + "task": "Tasks", + "testcase": "Testfälle", + "occurrence": "Häufigkeit", + "clear": "Filter zurücksetzen", + "cancel": "Abbrechen", + "apply": "Filter anwenden" + } }, "help": { "name": "Aufgabennamen werden fett geschrieben, während Testnamen normal sind. Ob es ein Aufgabenname oder Testname ist hängt davon ab, ob die Reihe eine Aufgabe oder einen Test darstellt.", diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 6929f51511f7..7659f48e78b2 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -351,7 +351,23 @@ "testcase": "Test Case", "errorCategory": "Error Category", "totalItems": "In total {{count}} items", - "error": "An error occurred while loading the feedback." + "error": "An error occurred while loading the feedback.", + "search": "Search ...", + "filter": "Filters", + "feedbackModal": { + "header": "Error Details", + "feedbackTitle": "Test Case Feedback", + "ok": "Ok" + }, + "filterModal": { + "modalTitle": "Filter Options", + "task": "Tasks", + "testcase": "Test Cases", + "occurrence": "Occurrence", + "clear": "Clear Filter", + "cancel": "Cancel", + "apply": "Apply Filter" + } }, "help": { "name": "Task names are written in bold whereas Test names are normal. Task or test name depending on whether the row is a task or test.", diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java index c9ce9e923042..1d04c504db90 100644 --- a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java @@ -30,6 +30,7 @@ import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion; import de.tum.cit.aet.artemis.assessment.domain.GradingInstruction; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackAnalysisResponseDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO; import de.tum.cit.aet.artemis.assessment.dto.ResultWithPointsPerGradingCriterionDTO; import de.tum.cit.aet.artemis.assessment.repository.FeedbackRepository; @@ -729,8 +730,6 @@ void testGetAssessmentCountByCorrectionRoundForProgrammingExercise() { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetAllFeedbackDetailsForExercise() throws Exception { ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); - StudentParticipation participation = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); - Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); testCase.setId(1L); @@ -738,26 +737,35 @@ void testGetAllFeedbackDetailsForExercise() throws Exception { feedback.setPositive(false); feedback.setDetailText("Some feedback"); feedback.setTestCase(testCase); + + StudentParticipation participation = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + + Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); + participationUtilService.addFeedbackToResult(feedback, result); - List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + String url = "/api/exercises/" + programmingExercise.getId() + "/feedback-details" + "?page=1&pageSize=10&sortedColumn=detailText&sortingOrder=ASCENDING" + + "&searchTerm=&filterTasks=&filterTestCases=&filterOccurrence="; + + FeedbackAnalysisResponseDTO response = request.get(url, HttpStatus.OK, FeedbackAnalysisResponseDTO.class); - assertThat(response).isNotEmpty(); - FeedbackDetailDTO feedbackDetail = response.getFirst(); + assertThat(response.feedbackDetails().getResultsOnPage()).isNotEmpty(); + FeedbackDetailDTO feedbackDetail = response.feedbackDetails().getResultsOnPage().getFirst(); assertThat(feedbackDetail.count()).isEqualTo(1); assertThat(feedbackDetail.relativeCount()).isEqualTo(100.0); assertThat(feedbackDetail.detailText()).isEqualTo("Some feedback"); assertThat(feedbackDetail.testCaseName()).isEqualTo("test1"); - assertThat(feedbackDetail.taskNumber()).isEqualTo(1); + + assertThat(response.totalItems()).isEqualTo(1); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetAllFeedbackDetailsForExerciseWithMultipleFeedback() throws Exception { ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); - StudentParticipation participation = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + StudentParticipation participation1 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); StudentParticipation participation2 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student2"); - Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); + Result result1 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation1); Result result2 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation2); ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); testCase.setId(1L); @@ -766,7 +774,7 @@ void testGetAllFeedbackDetailsForExerciseWithMultipleFeedback() throws Exception feedback1.setPositive(false); feedback1.setDetailText("Some feedback"); feedback1.setTestCase(testCase); - participationUtilService.addFeedbackToResult(feedback1, result); + participationUtilService.addFeedbackToResult(feedback1, result1); Feedback feedback2 = new Feedback(); feedback2.setPositive(false); @@ -778,37 +786,79 @@ void testGetAllFeedbackDetailsForExerciseWithMultipleFeedback() throws Exception feedback3.setPositive(false); feedback3.setDetailText("Some different feedback"); feedback3.setTestCase(testCase); - participationUtilService.addFeedbackToResult(feedback3, result); + participationUtilService.addFeedbackToResult(feedback3, result1); + + String url = "/api/exercises/" + programmingExercise.getId() + "/feedback-details" + "?page=1&pageSize=10&sortedColumn=detailText&sortingOrder=ASCENDING" + + "&searchTerm=&filterTasks=&filterTestCases=&filterOccurrence="; - List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + FeedbackAnalysisResponseDTO response = request.get(url, HttpStatus.OK, FeedbackAnalysisResponseDTO.class); - assertThat(response).hasSize(2); + List feedbackDetails = response.feedbackDetails().getResultsOnPage(); + assertThat(feedbackDetails).hasSize(2); - FeedbackDetailDTO firstFeedbackDetail = response.stream().filter(feedbackDetail -> "Some feedback".equals(feedbackDetail.detailText())).findFirst().orElseThrow(); + FeedbackDetailDTO firstFeedbackDetail = feedbackDetails.stream().filter(feedbackDetail -> "Some feedback".equals(feedbackDetail.detailText())).findFirst().orElseThrow(); - FeedbackDetailDTO secondFeedbackDetail = response.stream().filter(feedbackDetail -> "Some different feedback".equals(feedbackDetail.detailText())).findFirst() + FeedbackDetailDTO secondFeedbackDetail = feedbackDetails.stream().filter(feedbackDetail -> "Some different feedback".equals(feedbackDetail.detailText())).findFirst() .orElseThrow(); assertThat(firstFeedbackDetail.count()).isEqualTo(2); assertThat(firstFeedbackDetail.relativeCount()).isEqualTo(100.0); assertThat(firstFeedbackDetail.detailText()).isEqualTo("Some feedback"); assertThat(firstFeedbackDetail.testCaseName()).isEqualTo("test1"); - assertThat(firstFeedbackDetail.taskNumber()).isEqualTo(1); assertThat(secondFeedbackDetail.count()).isEqualTo(1); assertThat(secondFeedbackDetail.relativeCount()).isEqualTo(50.0); assertThat(secondFeedbackDetail.detailText()).isEqualTo("Some different feedback"); assertThat(secondFeedbackDetail.testCaseName()).isEqualTo("test1"); - assertThat(secondFeedbackDetail.taskNumber()).isEqualTo(1); + + assertThat(response.totalItems()).isEqualTo(2); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGetAllFeedbackDetailsForExercise_NoParticipation() throws Exception { + void testGetMaxCountForExercise() throws Exception { ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); - List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + StudentParticipation participation = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); + ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); + testCase.setId(1L); + + Feedback feedback = new Feedback(); + feedback.setPositive(false); + feedback.setDetailText("Some feedback"); + feedback.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback, result); - assertThat(response).isEmpty(); + long maxCount = request.get("/api/exercises/" + programmingExercise.getId() + "/feedback-details-max-count", HttpStatus.OK, Long.class); + + assertThat(maxCount).isEqualTo(1); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetMaxCountForExerciseWithMultipleFeedback() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + StudentParticipation participation1 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + StudentParticipation participation2 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student2"); + Result result1 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation1); + Result result2 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation2); + ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); + testCase.setId(1L); + + Feedback feedback1 = new Feedback(); + feedback1.setPositive(false); + feedback1.setDetailText("Some feedback"); + feedback1.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback1, result1); + + Feedback feedback2 = new Feedback(); + feedback2.setPositive(false); + feedback2.setDetailText("Some feedback"); + feedback2.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback2, result2); + + long maxCount = request.get("/api/exercises/" + programmingExercise.getId() + "/feedback-details-max-count", HttpStatus.OK, Long.class); + + assertThat(maxCount).isEqualTo(2); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseTaskIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseTaskIntegrationTest.java index c4def2a98eb6..55f7d322a023 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseTaskIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseTaskIntegrationTest.java @@ -4,6 +4,7 @@ import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -114,7 +115,7 @@ void testTaskExtractionForProgrammingExercise() throws Exception { programmingExerciseTaskService.updateTasksFromProblemStatement(programmingExercise); request.get("/api/programming-exercises/" + programmingExercise.getId() + "/tasks", HttpStatus.OK, Set.class); - Set extractedTasks = taskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(programmingExercise.getId()); + List extractedTasks = taskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(programmingExercise.getId()); Optional task1Optional = extractedTasks.stream().filter(task -> task.getTaskName().equals(taskName1)).findFirst(); Optional task2Optional = extractedTasks.stream().filter(task -> task.getTaskName().equals(taskName2)).findFirst(); assertThat(task1Optional).isPresent(); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts index 0e9387b93e5b..00ab084dd543 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts @@ -3,63 +3,214 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { ArtemisTestModule } from '../../../test.module'; import { FeedbackAnalysisComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component'; -import { FeedbackAnalysisService } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; -import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { FeedbackAnalysisResponse, FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { LocalStorageService } from 'ngx-webstorage'; +import '@angular/localize/init'; +import { FeedbackFilterModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; describe('FeedbackAnalysisComponent', () => { let fixture: ComponentFixture; let component: FeedbackAnalysisComponent; let feedbackAnalysisService: FeedbackAnalysisService; - let getFeedbackDetailsSpy: jest.SpyInstance; + let searchSpy: jest.SpyInstance; + let localStorageService: LocalStorageService; const feedbackMock: FeedbackDetail[] = [ - { detailText: 'Test feedback 1 detail', testCaseName: 'test1', count: 10, relativeCount: 50, taskNumber: 1 }, - { detailText: 'Test feedback 2 detail', testCaseName: 'test2', count: 5, relativeCount: 25, taskNumber: 2 }, + { detailText: 'Test feedback 1 detail', testCaseName: 'test1', count: 10, relativeCount: 50, taskNumber: '1', errorCategory: 'StudentError' }, + { detailText: 'Test feedback 2 detail', testCaseName: 'test2', count: 5, relativeCount: 25, taskNumber: '2', errorCategory: 'StudentError' }, ]; + const feedbackResponseMock: FeedbackAnalysisResponse = { + feedbackDetails: { resultsOnPage: feedbackMock, numberOfPages: 1 }, + totalItems: 2, + totalAmountOfTasks: 1, + testCaseNames: ['test1', 'test2'], + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ArtemisTestModule, TranslateModule.forRoot(), FeedbackAnalysisComponent], - declarations: [], providers: [ { provide: TranslateService, useClass: MockTranslateService, }, FeedbackAnalysisService, + LocalStorageService, ], }).compileComponents(); + fixture = TestBed.createComponent(FeedbackAnalysisComponent); component = fixture.componentInstance; - component.exerciseId = 1; feedbackAnalysisService = fixture.debugElement.injector.get(FeedbackAnalysisService); - getFeedbackDetailsSpy = jest.spyOn(feedbackAnalysisService, 'getFeedbackDetailsForExercise').mockResolvedValue(feedbackMock); + localStorageService = fixture.debugElement.injector.get(LocalStorageService); + + jest.spyOn(localStorageService, 'retrieve').mockReturnValue([]); + + searchSpy = jest.spyOn(feedbackAnalysisService, 'search').mockResolvedValue(feedbackResponseMock); + + fixture.componentRef.setInput('exerciseId', 1); + fixture.componentRef.setInput('exerciseTitle', 'Sample Exercise Title'); + + fixture.detectChanges(); }); - describe('ngOnInit', () => { - it('should call loadFeedbackDetails when exerciseId is provided', async () => { - component.ngOnInit(); - await fixture.whenStable(); + afterEach(() => { + jest.restoreAllMocks(); + }); - expect(getFeedbackDetailsSpy).toHaveBeenCalledWith(1); - expect(component.feedbackDetails).toEqual(feedbackMock); + describe('on init', () => { + it('should load data on initialization', async () => { + await fixture.whenStable(); + expect(searchSpy).toHaveBeenCalledOnce(); + expect(component.content().resultsOnPage).toEqual(feedbackMock); + expect(component.totalItems()).toBe(2); }); }); - describe('loadFeedbackDetails', () => { - it('should load feedback details and update the component state', async () => { - await component.loadFeedbackDetails(1); - expect(component.feedbackDetails).toEqual(feedbackMock); + describe('loadData', () => { + it('should load feedback details and update state correctly', async () => { + await component['loadData'](); + expect(searchSpy).toHaveBeenCalledTimes(2); + expect(component.content().resultsOnPage).toEqual(feedbackMock); + expect(component.totalItems()).toBe(2); }); it('should handle error while loading feedback details', async () => { - getFeedbackDetailsSpy.mockRejectedValue(new Error('Error loading feedback details')); + searchSpy.mockRejectedValueOnce(new Error('Error loading feedback details')); try { - await component.loadFeedbackDetails(1); + await component['loadData'](); } catch { - expect(component.feedbackDetails).toEqual([]); + expect(component.content().resultsOnPage).toEqual([]); + expect(component.totalItems()).toBe(0); } }); }); + + describe('setPage', () => { + it('should update page and reload data', async () => { + const loadDataSpy = jest.spyOn(component, 'loadData' as any); + + component.setPage(2); + expect(component.page()).toBe(2); + expect(loadDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('setSortedColumn', () => { + it('should update sortedColumn and sortingOrder, and reload data', async () => { + const loadDataSpy = jest.spyOn(component, 'loadData' as any); + + component.setSortedColumn('testCaseName'); + expect(component.sortedColumn()).toBe('testCaseName'); + expect(component.sortingOrder()).toBe('ASCENDING'); + expect(loadDataSpy).toHaveBeenCalledOnce(); + + component.setSortedColumn('testCaseName'); + expect(component.sortingOrder()).toBe('DESCENDING'); + expect(loadDataSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('search', () => { + beforeEach(() => { + jest.spyOn(component, 'debounceLoadData' as any).mockImplementation(() => { + component['loadData'](); + }); + }); + + it('should reset page and load data when searching', async () => { + const loadDataSpy = jest.spyOn(component, 'loadData' as any); + component.searchTerm.set('test'); + await component.search(component.searchTerm()); + expect(component.page()).toBe(1); + expect(loadDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('openFeedbackModal', () => { + it('should open feedback modal with correct feedback detail', () => { + const modalService = fixture.debugElement.injector.get(NgbModal); + const modalSpy = jest.spyOn(modalService, 'open').mockReturnValue({ componentInstance: {} } as any); + + const feedbackDetail = feedbackMock[0]; + component.openFeedbackModal(feedbackDetail); + + expect(modalSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('openFilterModal', () => { + it('should open filter modal and pass correct form values and properties', async () => { + const modalService = fixture.debugElement.injector.get(NgbModal); + const modalSpy = jest.spyOn(modalService, 'open').mockReturnValue({ + componentInstance: { + filterApplied: { subscribe: jest.fn() }, + }, + } as any); + const getMaxCountSpy = jest.spyOn(feedbackAnalysisService, 'getMaxCount').mockResolvedValue(10); + jest.spyOn(localStorageService, 'retrieve').mockReturnValueOnce(['task1']).mockReturnValueOnce(['testCase1']).mockReturnValueOnce([component.minCount(), 5]); + + component.maxCount.set(5); + component.selectedFiltersCount.set(1); + await component.openFilterModal(); + + expect(getMaxCountSpy).toHaveBeenCalledWith(1); + expect(modalSpy).toHaveBeenCalledWith(FeedbackFilterModalComponent, { centered: true, size: 'lg' }); + const modalInstance = modalSpy.mock.results[0].value.componentInstance; + expect(modalInstance.filters).toEqual({ + tasks: ['task1'], + testCases: ['testCase1'], + occurrence: [component.minCount(), 5], + }); + expect(modalInstance.totalAmountOfTasks).toBe(component.totalAmountOfTasks); + expect(modalInstance.testCaseNames).toBe(component.testCaseNames); + expect(modalInstance.exerciseId).toBe(component.exerciseId); + expect(modalInstance.maxCount).toBe(component.maxCount); + }); + }); + + describe('applyFilters', () => { + it('should apply filters, update filter count, and reload data', () => { + const loadDataSpy = jest.spyOn(component, 'loadData' as any); + const countAppliedFiltersSpy = jest.spyOn(component, 'countAppliedFilters').mockReturnValue(2); + + const filters = { + tasks: ['task1'], + testCases: ['testCase1'], + occurrence: [component.minCount(), 10], + }; + + component.applyFilters(filters); + expect(countAppliedFiltersSpy).toHaveBeenCalledWith(filters); + expect(component.selectedFiltersCount()).toBe(2); + expect(loadDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('countAppliedFilters', () => { + it('should count the applied filters correctly', () => { + component.maxCount.set(10); + const filters = { + tasks: ['task1', 'task2'], + testCases: ['testCase1'], + occurrence: [component.minCount(), component.maxCount()], + }; + const count = component.countAppliedFilters(filters); + + expect(count).toBe(3); + }); + + it('should return 0 if no filters are applied', () => { + const filters = { + tasks: [], + testCases: [], + occurrence: [component.minCount(), component.maxCount()], + }; + const count = component.countAppliedFilters(filters); + expect(count).toBe(0); + }); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts index 893d3e598b2f..5233bfbd52bf 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts @@ -2,19 +2,26 @@ import { TestBed } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; import { provideHttpClient } from '@angular/common/http'; +import { SortingOrder } from 'app/shared/table/pageable-table'; describe('FeedbackAnalysisService', () => { let service: FeedbackAnalysisService; let httpMock: HttpTestingController; const feedbackDetailsMock: FeedbackDetail[] = [ - { detailText: 'Feedback 1', testCaseName: 'test1', count: 5, relativeCount: 25.0, taskNumber: 1 }, - { detailText: 'Feedback 2', testCaseName: 'test2', count: 3, relativeCount: 15.0, taskNumber: 2 }, + { detailText: 'Feedback 1', testCaseName: 'test1', count: 5, relativeCount: 25.0, taskNumber: '1', errorCategory: 'StudentError' }, + { detailText: 'Feedback 2', testCaseName: 'test2', count: 3, relativeCount: 15.0, taskNumber: '2', errorCategory: 'StudentError' }, ]; + const feedbackAnalysisResponseMock = { + feedbackDetails: { resultsOnPage: feedbackDetailsMock, numberOfPages: 1 }, + totalItems: 2, + totalAmountOfTasks: 2, + testCaseNames: ['test1', 'test2'], + }; + beforeEach(() => { TestBed.configureTestingModule({ - imports: [], providers: [provideHttpClient(), provideHttpClientTesting(), FeedbackAnalysisService], }); @@ -26,16 +33,39 @@ describe('FeedbackAnalysisService', () => { httpMock.verify(); }); - describe('getFeedbackDetailsForExercise', () => { + describe('search', () => { it('should retrieve feedback details for a given exercise', async () => { - const responsePromise = service.getFeedbackDetailsForExercise(1); + const pageable = { + page: 1, + pageSize: 10, + searchTerm: '', + sortingOrder: SortingOrder.ASCENDING, + sortedColumn: 'detailText', + }; + const filters = { tasks: [], testCases: [], occurrence: [] }; + const responsePromise = service.search(pageable, { exerciseId: 1, filters }); + + const req = httpMock.expectOne( + 'api/exercises/1/feedback-details?page=1&pageSize=10&searchTerm=&sortingOrder=ASCENDING&sortedColumn=detailText&filterTasks=&filterTestCases=&filterOccurrence=', + ); + expect(req.request.method).toBe('GET'); + req.flush(feedbackAnalysisResponseMock); + + const result = await responsePromise; + expect(result).toEqual(feedbackAnalysisResponseMock); + }); + }); + + describe('getMaxCount', () => { + it('should retrieve the max count for an exercise', async () => { + const responsePromise = service.getMaxCount(1); - const req = httpMock.expectOne('api/exercises/1/feedback-details'); + const req = httpMock.expectOne('api/exercises/1/feedback-details-max-count'); expect(req.request.method).toBe('GET'); - req.flush(feedbackDetailsMock); + req.flush(10); const result = await responsePromise; - expect(result).toEqual(feedbackDetailsMock); + expect(result).toBe(10); }); }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-filter-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-filter-modal.component.spec.ts new file mode 100644 index 000000000000..369f7c42231e --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-filter-modal.component.spec.ts @@ -0,0 +1,97 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FeedbackFilterModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; +import { LocalStorageService } from 'ngx-webstorage'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; + +describe('FeedbackFilterModalComponent', () => { + let fixture: ComponentFixture; + let component: FeedbackFilterModalComponent; + let localStorageService: LocalStorageService; + let activeModal: NgbActiveModal; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), ArtemisSharedCommonModule, RangeSliderComponent, FeedbackFilterModalComponent], + providers: [{ provide: LocalStorageService, useValue: { store: jest.fn(), clear: jest.fn(), retrieve: jest.fn() } }, NgbActiveModal], + }).compileComponents(); + + fixture = TestBed.createComponent(FeedbackFilterModalComponent); + component = fixture.componentInstance; + localStorageService = TestBed.inject(LocalStorageService); + activeModal = TestBed.inject(NgbActiveModal); + component.minCount.set(0); + component.maxCount.set(10); + fixture.detectChanges(); + }); + + it('should initialize filters correctly', () => { + component.filters = { + tasks: [], + testCases: [], + occurrence: [component.minCount(), component.maxCount()], + }; + + expect(component.filters).toEqual({ + tasks: [], + testCases: [], + occurrence: [0, 10], + }); + }); + + it('should call localStorage store when applying filters', () => { + const storeSpy = jest.spyOn(localStorageService, 'store'); + const emitSpy = jest.spyOn(component.filterApplied, 'emit'); + const closeSpy = jest.spyOn(activeModal, 'close'); + + component.filters.occurrence = [component.minCount(), component.maxCount()]; + component.applyFilter(); + + expect(storeSpy).toHaveBeenCalledWith(component.FILTER_TASKS_KEY, []); + expect(storeSpy).toHaveBeenCalledWith(component.FILTER_TEST_CASES_KEY, []); + expect(storeSpy).toHaveBeenCalledWith(component.FILTER_OCCURRENCE_KEY, [0, 10]); + expect(emitSpy).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledOnce(); + }); + + it('should clear filters and reset them correctly', () => { + const clearSpy = jest.spyOn(localStorageService, 'clear'); + const emitSpy = jest.spyOn(component.filterApplied, 'emit'); + const closeSpy = jest.spyOn(activeModal, 'close'); + + component.clearFilter(); + + expect(clearSpy).toHaveBeenCalledWith(component.FILTER_TASKS_KEY); + expect(clearSpy).toHaveBeenCalledWith(component.FILTER_TEST_CASES_KEY); + expect(clearSpy).toHaveBeenCalledWith(component.FILTER_OCCURRENCE_KEY); + + expect(component.filters).toEqual({ + tasks: [], + testCases: [], + occurrence: [0, 10], + }); + expect(emitSpy).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledOnce(); + }); + + it('should update filters when checkboxes change', () => { + const event = { target: { checked: true, value: 'test-task' } } as unknown as Event; + component.onCheckboxChange(event, 'tasks'); + expect(component.filters.tasks).toEqual(['test-task']); + }); + + it('should remove the value from filters when checkbox is unchecked', () => { + component.filters.tasks = ['test-task', 'task-2']; + const event = { target: { checked: false, value: 'test-task' } } as unknown as Event; + component.onCheckboxChange(event, 'tasks'); + expect(component.filters.tasks).toEqual(['task-2']); + }); + + it('should dismiss modal when closeModal is called', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.closeModal(); + expect(dismissSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-modal.component.spec.ts new file mode 100644 index 000000000000..66f0d3d33aeb --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-modal.component.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FeedbackModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('FeedbackModalComponent', () => { + let fixture: ComponentFixture; + let component: FeedbackModalComponent; + let activeModal: NgbActiveModal; + + const mockFeedbackDetail: FeedbackDetail = { + count: 5, + relativeCount: 25.0, + detailText: 'Some feedback detail', + testCaseName: 'testCase1', + taskNumber: '1', + errorCategory: 'StudentError', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), FeedbackModalComponent], + providers: [NgbActiveModal], + }).compileComponents(); + fixture = TestBed.createComponent(FeedbackModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + fixture.componentRef.setInput('feedbackDetail', mockFeedbackDetail); + fixture.detectChanges(); + }); + + it('should initialize with the provided feedback detail', () => { + expect(component.feedbackDetail()).toEqual(mockFeedbackDetail); + expect(component.feedbackDetail().detailText).toBe('Some feedback detail'); + expect(component.feedbackDetail().testCaseName).toBe('testCase1'); + }); + + it('should call close on activeModal when close is triggered', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.activeModal.close(); + expect(closeSpy).toHaveBeenCalledOnce(); + }); +});