From fa2e539a5685f6b4975e8168faee1c0085f8ff4b Mon Sep 17 00:00:00 2001 From: Aniruddh Zaveri <92953467+az108@users.noreply.github.com> Date: Sat, 7 Sep 2024 08:55:17 +0200 Subject: [PATCH] Programming exercises: Add visualization of test case errors (#9213) --- .../StudentParticipationRepository.java | 50 +++++++++++ .../www1/artemis/service/ResultService.java | 43 ++++++++- .../www1/artemis/web/rest/ResultResource.java | 16 ++++ .../rest/dto/feedback/FeedbackDetailDTO.java | 7 ++ .../feedback-analysis.component.html | 27 ++++++ .../feedback-analysis.component.ts | 34 +++++++ .../feedback-analysis.service.ts | 19 ++++ ...-exercise-configure-grading.component.html | 40 ++++++--- ...ng-exercise-configure-grading.component.ts | 6 +- .../programming-exercise-grading.module.ts | 2 + .../webapp/i18n/de/programmingExercise.json | 14 ++- .../webapp/i18n/en/programmingExercise.json | 12 +++ .../ResultServiceIntegrationTest.java | 89 +++++++++++++++++++ .../feedback-analysis.component.spec.ts | 65 ++++++++++++++ .../feedback-analysis.service.spec.ts | 50 +++++++++++ 15 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java create mode 100644 src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html create mode 100644 src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts create mode 100644 src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts create mode 100644 src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts create mode 100644 src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts diff --git a/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java index f339eb61f51d..83c8b716db94 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java @@ -39,6 +39,7 @@ import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.quiz.QuizSubmittedAnswerCount; import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; /** * Spring Data JPA repository for the Participation entity. @@ -1230,4 +1231,53 @@ SELECT COALESCE(AVG(p.presentationScore), 0) AND p.presentationScore IS NOT NULL """) 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. + *
+ * The relative count and task number are initially set to 0 and are calculated in a separate step in the service layer. + * + * @param exerciseId Exercise ID. + * @return a list of {@link FeedbackDetailDTO} objects, with the relative count and task number set to 0. + */ + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO( + COUNT(f.id), + 0, + f.detailText, + f.testCase.testName, + 0 + ) + FROM StudentParticipation p + JOIN p.results r + 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 + """) + List findAggregatedFeedbackByExerciseId(@Param("exerciseId") long exerciseId); + + /** + * Counts the distinct number of latest results for a given exercise, excluding those in practice mode. + * + * @param exerciseId Exercise ID. + * @return The count of distinct latest results for the exercise. + */ + @Query(""" + SELECT COUNT(DISTINCT r.id) + FROM StudentParticipation p + JOIN p.results r + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + AND r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + ) + """) + long countDistinctResultsByExerciseId(@Param("exerciseId") long exerciseId); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java index 3ec13cb3d5e0..139a3b01b01c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java @@ -34,6 +34,7 @@ import de.tum.in.www1.artemis.domain.enumeration.BuildPlanType; import de.tum.in.www1.artemis.domain.enumeration.FeedbackType; import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.hestia.ProgrammingExerciseTask; import de.tum.in.www1.artemis.domain.participation.Participation; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; @@ -49,11 +50,15 @@ import de.tum.in.www1.artemis.repository.ResultRepository; import de.tum.in.www1.artemis.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.in.www1.artemis.repository.StudentExamRepository; +import de.tum.in.www1.artemis.repository.StudentParticipationRepository; import de.tum.in.www1.artemis.repository.TemplateProgrammingExerciseParticipationRepository; import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.repository.hestia.ProgrammingExerciseTaskRepository; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.service.connectors.localci.dto.ResultBuildJob; import de.tum.in.www1.artemis.service.connectors.lti.LtiNewResultService; +import de.tum.in.www1.artemis.service.hestia.ProgrammingExerciseTaskService; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.websocket.ResultWebsocketService; @@ -99,6 +104,10 @@ public class ResultService { private final BuildLogEntryService buildLogEntryService; + private final StudentParticipationRepository studentParticipationRepository; + + private final ProgrammingExerciseTaskService programmingExerciseTaskService; + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository, @@ -106,7 +115,8 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository, - BuildJobRepository buildJobRepository, BuildLogEntryService buildLogEntryService) { + BuildJobRepository buildJobRepository, BuildLogEntryService buildLogEntryService, StudentParticipationRepository studentParticipationRepository, + ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ProgrammingExerciseTaskService programmingExerciseTaskService) { this.userRepository = userRepository; this.resultRepository = resultRepository; this.ltiNewResultService = ltiNewResultService; @@ -125,6 +135,8 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos this.studentExamRepository = studentExamRepository; this.buildJobRepository = buildJobRepository; this.buildLogEntryService = buildLogEntryService; + this.studentParticipationRepository = studentParticipationRepository; + this.programmingExerciseTaskService = programmingExerciseTaskService; } /** @@ -513,4 +525,33 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { return result; } } + + /** + * 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. + *
+ * 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. + * + * @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). + */ + public List findAggregatedFeedbackByExerciseId(long 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); + }).toList(); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java index 37b9afb94432..bd901ccd824b 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java @@ -45,12 +45,14 @@ import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastTutor; +import de.tum.in.www1.artemis.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.ParticipationAuthorizationCheckService; import de.tum.in.www1.artemis.service.ParticipationService; import de.tum.in.www1.artemis.service.ResultService; import de.tum.in.www1.artemis.service.exam.ExamDateService; import de.tum.in.www1.artemis.web.rest.dto.ResultWithPointsPerGradingCriterionDTO; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; @@ -276,4 +278,18 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo return ResponseEntity.created(new URI("/api/results/" + savedResult.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, savedResult.getId().toString())).body(savedResult); } + + /** + * 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. + * + * @param exerciseId The ID of the exercise for which feedback details should be retrieved. + * @return A ResponseEntity containing a list of {@link FeedbackDetailDTO}s + */ + @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)); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java new file mode 100644 index 000000000000..d9e1f86d231d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.web.rest.dto.feedback; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, int taskNumber) { +} 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 new file mode 100644 index 000000000000..4c76747e8e96 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html @@ -0,0 +1,27 @@ +
+

+ + + + + + + + + + + + @for (item of feedbackDetails; track item) { + + + + + + + + + } + +
{{ item.count }} ({{ item.relativeCount | number: '1.0-0' }}%){{ item.detailText }}{{ item.taskNumber }}{{ item.testCaseName }}Student Error
+
+
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 new file mode 100644 index 000000000000..7e1d48121f1c --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts @@ -0,0 +1,34 @@ +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 { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-feedback-analysis', + templateUrl: './feedback-analysis.component.html', + standalone: true, + imports: [ArtemisSharedModule], + providers: [FeedbackAnalysisService], +}) +export class FeedbackAnalysisComponent implements OnInit { + @Input() exerciseTitle: string; + @Input() exerciseId: number; + feedbackDetails: FeedbackDetail[] = []; + + constructor( + private feedbackAnalysisService: FeedbackAnalysisService, + private alertService: AlertService, + ) {} + + ngOnInit(): void { + this.loadFeedbackDetails(this.exerciseId); + } + + async loadFeedbackDetails(exerciseId: number): Promise { + try { + this.feedbackDetails = await this.feedbackAnalysisService.getFeedbackDetailsForExercise(exerciseId); + } catch (error) { + this.alertService.error(`artemisApp.programmingExercise.configureGrading.feedbackAnalysis.error`); + } + } +} 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 new file mode 100644 index 000000000000..4fa81cf289d3 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; + +export interface FeedbackDetail { + count: number; + relativeCount: number; + detailText: string; + testCaseName: string; + taskNumber: number; +} + +@Injectable() +export class FeedbackAnalysisService extends BaseApiHttpService { + private readonly EXERCISE_RESOURCE_URL = 'exercises'; + + getFeedbackDetailsForExercise(exerciseId: number): Promise { + return this.get(`${this.EXERCISE_RESOURCE_URL}/${exerciseId}/feedback-details`); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html index f637a537c0a6..147c35adea2f 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html @@ -1,3 +1,8 @@ + +
+ +
+

@@ -5,23 +10,31 @@

-
- Test Cases -
+ @if (programmingExercise.staticCodeAnalysisEnabled) { -
- Code Analysis -
+ + } + + @if (programmingExercise.isAtLeastEditor) { + } -
- Submission Policy -
- @if (activeTab !== 'submission-policy') { + @if (activeTab === 'test-cases' || activeTab === 'code-analysis') { } - @if (programmingExercise.isAtLeastInstructor) { + @if (programmingExercise.isAtLeastInstructor && activeTab !== 'feedback-analysis') {
}

+
+ @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis') { + + } +
}
diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts index be99194cbd3c..c5ef675338f5 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts @@ -56,8 +56,7 @@ const DefaultFieldValues: { [key: string]: number } = { [EditableField.MAX_PENALTY]: 0, }; -export type GradingTab = 'test-cases' | 'code-analysis' | 'submission-policy'; - +export type GradingTab = 'test-cases' | 'code-analysis' | 'submission-policy' | 'feedback-analysis'; export type Table = 'testCases' | 'codeAnalysis'; @Component({ @@ -232,7 +231,8 @@ export class ProgrammingExerciseConfigureGradingComponent implements OnInit, OnD this.isLoading = false; } - if (params['tab'] === 'test-cases' || params['tab'] === 'code-analysis' || params['tab'] === 'submission-policy') { + const gradingTabs: GradingTab[] = ['test-cases', 'code-analysis', 'submission-policy', 'feedback-analysis']; + if (gradingTabs.includes(params['tab'])) { this.selectTab(params['tab']); } else { this.selectTab('test-cases'); diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts index 3f91d95f8fc2..ea2a8633af73 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts @@ -19,6 +19,7 @@ import { SubmissionPolicyUpdateModule } from 'app/exercises/shared/submission-po import { ProgrammingExerciseGradingTasksTableComponent } from 'app/exercises/programming/manage/grading/tasks/programming-exercise-grading-tasks-table.component'; import { BarChartModule } from '@swimlane/ngx-charts'; import { ProgrammingExerciseTaskComponent } from 'app/exercises/programming/manage/grading/tasks/programming-exercise-task/programming-exercise-task.component'; +import { FeedbackAnalysisComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component'; @NgModule({ imports: [ @@ -33,6 +34,7 @@ import { ProgrammingExerciseTaskComponent } from 'app/exercises/programming/mana ArtemisProgrammingExerciseActionsModule, SubmissionPolicyUpdateModule, BarChartModule, + FeedbackAnalysisComponent, ], declarations: [ ProgrammingExerciseConfigureGradingComponent, diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index 9409c371c61a..4e493f7d4daf 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -273,7 +273,8 @@ "settingNegative": "Der Testfall {{testCase}} darf keine Einstellungen mit negativen Werten haben." }, "categories": { - "title": "Code-Analyse-Kategorien", + "titleHeader": "Quelltext-Analyse", + "title": "Quelltext-Analyse-Kategorien", "notGraded": "Nicht bewertet.", "noFeedback": "Ohne sichtbares Feedback.", "updated": "Die Kategorien wurden erfolgreich gespeichert.", @@ -319,6 +320,17 @@ "testType": "Type", "passedPercent": "Bestanden %" }, + "feedbackAnalysis": { + "titleHeader": "Feedback Analyse", + "title": "Fehleranalyse für {{exerciseTitle}}", + "occurrence": "Häufigkeit", + "feedback": "Feedback", + "task": "Aufgabe", + "testcase": "Testfall", + "errorCategory": "Fehlerkategorie", + "totalItems": "Insgesamt {{count}} Elemente", + "error": "Beim Laden des Feedback ist ein Fehler aufgetreten." + }, "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.", "state": "Gibt an, ob Issues in dieser Kategorie den Studierenden angezeigt und bewertet werden sollen.", diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 06f826607ba7..1883d8294abc 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -275,6 +275,7 @@ "settingNegative": "Test case {{testCase}} must not have settings set to negative values." }, "categories": { + "titleHeader": "Code Analysis", "title": "Code Analysis Categories", "notGraded": "Not graded.", "noFeedback": "No visible feedback.", @@ -321,6 +322,17 @@ "testType": "Type", "passedPercent": "Passed %" }, + "feedbackAnalysis": { + "titleHeader": "Feedback Analysis", + "title": "Feedback Analysis for {{exerciseTitle}}", + "occurrence": "Occurrence", + "feedback": "Feedback", + "task": "Task", + "testcase": "Test Case", + "errorCategory": "Error Category", + "totalItems": "In total {{count}} items", + "error": "An error occurred while loading the feedback." + }, "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.", "state": "Determines whether issues in this category should be shown to the students and used for grading.", diff --git a/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java index 56e3d0339bda..2c8e7c82b389 100644 --- a/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java @@ -33,6 +33,7 @@ import de.tum.in.www1.artemis.domain.GradingCriterion; import de.tum.in.www1.artemis.domain.GradingInstruction; import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.ProgrammingExerciseTestCase; import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.Result; import de.tum.in.www1.artemis.domain.Submission; @@ -72,6 +73,7 @@ import de.tum.in.www1.artemis.repository.SubmissionRepository; import de.tum.in.www1.artemis.repository.TextExerciseRepository; import de.tum.in.www1.artemis.web.rest.dto.ResultWithPointsPerGradingCriterionDTO; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; class ResultServiceIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { @@ -722,4 +724,91 @@ void testGetAssessmentCountByCorrectionRoundForProgrammingExercise() { assertThat(assessments[0].inTime()).isEqualTo(1); // correction round 1 assertThat(assessments[1].inTime()).isEqualTo(1); // correction round 2 } + + @Test + @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); + + Feedback feedback = new Feedback(); + feedback.setPositive(false); + feedback.setDetailText("Some feedback"); + feedback.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback, result); + + List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + + assertThat(response).isNotEmpty(); + FeedbackDetailDTO feedbackDetail = response.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); + } + + @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 participation2 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student2"); + Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); + 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, result); + + Feedback feedback2 = new Feedback(); + feedback2.setPositive(false); + feedback2.setDetailText("Some feedback"); + feedback2.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback2, result2); + + Feedback feedback3 = new Feedback(); + feedback3.setPositive(false); + feedback3.setDetailText("Some different feedback"); + feedback3.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback3, result); + + List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + + assertThat(response).hasSize(2); + + FeedbackDetailDTO firstFeedbackDetail = response.stream().filter(feedbackDetail -> "Some feedback".equals(feedbackDetail.detailText())).findFirst().orElseThrow(); + + FeedbackDetailDTO secondFeedbackDetail = response.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); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetAllFeedbackDetailsForExercise_NoParticipation() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + + assertThat(response).isEmpty(); + } + } 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 new file mode 100644 index 000000000000..0e9387b93e5b --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts @@ -0,0 +1,65 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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'; + +describe('FeedbackAnalysisComponent', () => { + let fixture: ComponentFixture; + let component: FeedbackAnalysisComponent; + let feedbackAnalysisService: FeedbackAnalysisService; + let getFeedbackDetailsSpy: jest.SpyInstance; + + 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 }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, TranslateModule.forRoot(), FeedbackAnalysisComponent], + declarations: [], + providers: [ + { + provide: TranslateService, + useClass: MockTranslateService, + }, + FeedbackAnalysisService, + ], + }).compileComponents(); + fixture = TestBed.createComponent(FeedbackAnalysisComponent); + component = fixture.componentInstance; + component.exerciseId = 1; + feedbackAnalysisService = fixture.debugElement.injector.get(FeedbackAnalysisService); + getFeedbackDetailsSpy = jest.spyOn(feedbackAnalysisService, 'getFeedbackDetailsForExercise').mockResolvedValue(feedbackMock); + }); + + describe('ngOnInit', () => { + it('should call loadFeedbackDetails when exerciseId is provided', async () => { + component.ngOnInit(); + await fixture.whenStable(); + + expect(getFeedbackDetailsSpy).toHaveBeenCalledWith(1); + expect(component.feedbackDetails).toEqual(feedbackMock); + }); + }); + + describe('loadFeedbackDetails', () => { + it('should load feedback details and update the component state', async () => { + await component.loadFeedbackDetails(1); + expect(component.feedbackDetails).toEqual(feedbackMock); + }); + + it('should handle error while loading feedback details', async () => { + getFeedbackDetailsSpy.mockRejectedValue(new Error('Error loading feedback details')); + + try { + await component.loadFeedbackDetails(1); + } catch { + expect(component.feedbackDetails).toEqual([]); + } + }); + }); +}); 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 new file mode 100644 index 000000000000..a4f9a2a3ee42 --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; + +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 }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [FeedbackAnalysisService], + }); + + service = TestBed.inject(FeedbackAnalysisService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('getFeedbackDetailsForExercise', () => { + it('should retrieve feedback details for a given exercise', async () => { + const responsePromise = service.getFeedbackDetailsForExercise(1); + + const req = httpMock.expectOne('api/exercises/1/feedback-details'); + expect(req.request.method).toBe('GET'); + req.flush(feedbackDetailsMock); + + const result = await responsePromise; + expect(result).toEqual(feedbackDetailsMock); + }); + + it('should handle errors while retrieving feedback details', async () => { + const responsePromise = service.getFeedbackDetailsForExercise(1); + + const req = httpMock.expectOne('api/exercises/1/feedback-details'); + expect(req.request.method).toBe('GET'); + req.flush('Something went wrong', { status: 500, statusText: 'Server Error' }); + + await expect(responsePromise).rejects.toThrow('Internal server error'); + }); + }); +});