diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java index 6559c28b9d93..738ef8a3f2dd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java @@ -20,7 +20,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import org.apache.velocity.exception.ResourceNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -382,7 +381,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc throw new BadRequestAlertException("Not intended for the use in exams", "participation", "preconditions not met"); } if (exercise.getDueDate() != null && now().isAfter(exercise.getDueDate())) { - throw new BadRequestAlertException("The due date is over", "participation", "preconditions not met"); + throw new BadRequestAlertException("The due date is over", "participation", "feedbackRequestAfterDueDate", true); } if (exercise instanceof ProgrammingExercise) { ((ProgrammingExercise) exercise).validateSettingsForFeedbackRequest(); @@ -393,7 +392,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc StudentParticipation participation = (exercise instanceof ProgrammingExercise) ? programmingExerciseParticipationService.findStudentParticipationByExerciseAndStudentId(exercise, principal.getName()) : studentParticipationRepository.findByExerciseIdAndStudentLogin(exercise.getId(), principal.getName()) - .orElseThrow(() -> new ResourceNotFoundException("Participation not found")); + .orElseThrow(() -> new BadRequestAlertException("Participation not found", "participation", "noSubmissionExists", true)); checkAccessPermissionOwner(participation, user); participation = studentParticipationRepository.findByIdWithResultsElseThrow(participation.getId()); @@ -406,7 +405,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc } else if (exercise instanceof ProgrammingExercise) { if (participation.findLatestLegalResult() == null) { - throw new BadRequestAlertException("User has not reached the conditions to submit a feedback request", "participation", "preconditions not met"); + throw new BadRequestAlertException("You need to submit at least once and have the build results", "participation", "noSubmissionExists", true); } } @@ -414,7 +413,7 @@ else if (exercise instanceof ProgrammingExercise) { var currentDate = now(); var participationIndividualDueDate = participation.getIndividualDueDate(); if (participationIndividualDueDate != null && currentDate.isAfter(participationIndividualDueDate)) { - throw new BadRequestAlertException("Request has already been sent", "participation", "already sent"); + throw new BadRequestAlertException("Request has already been sent", "participation", "feedbackRequestAlreadySent", true); } // Process feedback request diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java index 8c92446d22d0..311b4d4913e4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java @@ -111,7 +111,7 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici var submissionOptional = programmingExerciseParticipationService.findProgrammingExerciseParticipationWithLatestSubmissionAndResult(participation.getId()) .findLatestSubmission(); if (submissionOptional.isEmpty()) { - throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmission"); + throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmissionÊxists"); } var submission = submissionOptional.get(); @@ -225,15 +225,10 @@ private void checkRateLimitOrThrow(ProgrammingExerciseStudentParticipation parti List athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); - long countOfAthenaResultsInProcessOrSuccessful = athenaResults.stream().filter(result -> result.isSuccessful() == null || result.isSuccessful() == Boolean.TRUE).count(); - long countOfSuccessfulRequests = athenaResults.stream().filter(result -> result.isSuccessful() == Boolean.TRUE).count(); - if (countOfAthenaResultsInProcessOrSuccessful >= 3) { - throw new BadRequestAlertException("Cannot send additional AI feedback requests now. Try again later!", "participation", "preconditions not met"); - } if (countOfSuccessfulRequests >= 20) { - throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "preconditions not met"); + throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "maxAthenaResultsReached", true); } } } diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html b/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html index 29e3ded8363c..7737158d8ce5 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html @@ -1,3 +1,6 @@ +@if (!!participation()?.exercise) { + +} @if (commitState === CommitState.CONFLICT) {
diff --git a/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts b/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts index 1b36ab7e6f5d..7b6aab3d5f7c 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts +++ b/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts @@ -6,9 +6,10 @@ import { OrionExerciseDetailsStudentActionsComponent } from 'app/orion/participa import { ExerciseDetailsStudentActionsComponent } from 'app/overview/exercise-details/exercise-details-student-actions.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisSharedPipesModule } from 'app/shared/pipes/shared-pipes.module'; +import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component'; @NgModule({ - imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisSharedPipesModule, OrionModule, FeatureToggleModule], + imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisSharedPipesModule, OrionModule, FeatureToggleModule, RequestFeedbackButtonComponent], declarations: [ExerciseDetailsStudentActionsComponent, OrionExerciseDetailsStudentActionsComponent], exports: [ExerciseDetailsStudentActionsComponent, OrionExerciseDetailsStudentActionsComponent], }) diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html index 53a99d1f440a..6e2df76cbef9 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html @@ -135,30 +135,8 @@ } - @if (exercise.allowFeedbackRequests) { - @if (athenaEnabled) { - - - Send automatic feedback request - - } @else { - - - Send manual feedback request - - } + @if (exercise.allowFeedbackRequests && gradedParticipation && exercise.type === ExerciseType.PROGRAMMING) { + } } diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts index fcdf131a87c4..5fd64ee3d6b6 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts @@ -110,6 +110,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges this.profileService.getProfileInfo().subscribe((profileInfo) => { this.localVCEnabled = profileInfo.activeProfiles?.includes(PROFILE_LOCALVC); this.athenaEnabled = profileInfo.activeProfiles?.includes(PROFILE_ATHENA); + // The online IDE is only available with correct SpringProfile and if it's enabled for this exercise if (profileInfo.activeProfiles?.includes(PROFILE_THEIA) && this.programmingExercise) { this.theiaEnabled = true; @@ -135,9 +136,6 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges }); } else if (this.exercise.type === ExerciseType.MODELING) { this.editorLabel = 'openModelingEditor'; - this.profileService.getProfileInfo().subscribe((profileInfo) => { - this.athenaEnabled = profileInfo.activeProfiles?.includes(PROFILE_ATHENA); - }); } else if (this.exercise.type === ExerciseType.TEXT) { this.editorLabel = 'openTextEditor'; this.profileService.getProfileInfo().subscribe((profileInfo) => { @@ -257,6 +255,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges }); } + // this method will be removed once text and modeling support new component requestFeedback() { if (!this.assureConditionsSatisfied()) return; if (this.exercise.type === ExerciseType.PROGRAMMING) { @@ -341,6 +340,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges * 3. There is no already pending feedback request. * @returns {boolean} `true` if all conditions are satisfied, otherwise `false`. */ + // this method will be removed once text and modeling support new component assureConditionsSatisfied(): boolean { this.updateParticipations(); if (this.exercise.type === ExerciseType.PROGRAMMING) { @@ -388,27 +388,12 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges hasAthenaResultForlatestSubmission(): boolean { if (this.gradedParticipation?.submissions && this.gradedParticipation?.results) { - const sortedSubmissions = this.gradedParticipation.submissions.slice().sort((a, b) => { - const dateA = this.getDateValue(a.submissionDate) ?? -Infinity; - const dateB = this.getDateValue(b.submissionDate) ?? -Infinity; - return dateB - dateA; - }); - - return this.gradedParticipation.results.some((result) => result.submission?.id === sortedSubmissions[0]?.id); + // submissions.results is always undefined so this is neccessary + return ( + this.gradedParticipation.submissions.last()?.id === + this.gradedParticipation?.results.filter((result) => result.assessmentType == AssessmentType.AUTOMATIC_ATHENA).first()?.submission?.id + ); } return false; } - - private getDateValue = (date: any): number => { - if (dayjs.isDayjs(date)) { - return date.valueOf(); - } - if (date instanceof Date) { - return date.valueOf(); - } - if (typeof date === 'string') { - return new Date(date).valueOf(); - } - return -Infinity; // fallback for null, undefined, or invalid dates - }; } diff --git a/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html new file mode 100644 index 000000000000..6d6addcc2b84 --- /dev/null +++ b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html @@ -0,0 +1,39 @@ +@if (!isExamExercise) { + @if (athenaEnabled) { + @if (exercise().type === ExerciseType.TEXT) { + + } @else { + + + + + } + } @else { + + + + + } +} diff --git a/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts new file mode 100644 index 000000000000..d300c0c4397c --- /dev/null +++ b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts @@ -0,0 +1,124 @@ +import { Component, OnInit, inject, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faPenSquare } from '@fortawesome/free-solid-svg-icons'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_ATHENA } from 'app/app.constants'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; +import { AssessmentType } from 'app/entities/assessment-type.model'; +import { TranslateService } from '@ngx-translate/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { isExamExercise } from 'app/shared/util/utils'; +import { ExerciseDetailsType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; + +@Component({ + selector: 'jhi-request-feedback-button', + standalone: true, + imports: [CommonModule, ArtemisSharedCommonModule, NgbTooltipModule, FontAwesomeModule], + templateUrl: './request-feedback-button.component.html', +}) +export class RequestFeedbackButtonComponent implements OnInit { + faPenSquare = faPenSquare; + athenaEnabled = false; + isExamExercise: boolean; + participation?: StudentParticipation; + + isGeneratingFeedback = input(); + smallButtons = input(false); + exercise = input.required(); + generatingFeedback = output(); + + private feedbackSent = false; + private profileService = inject(ProfileService); + private alertService = inject(AlertService); + private courseExerciseService = inject(CourseExerciseService); + private translateService = inject(TranslateService); + private exerciseService = inject(ExerciseService); + private participationService = inject(ParticipationService); + + protected readonly ExerciseType = ExerciseType; + + ngOnInit() { + this.profileService.getProfileInfo().subscribe((profileInfo) => { + this.athenaEnabled = profileInfo.activeProfiles?.includes(PROFILE_ATHENA); + }); + this.isExamExercise = isExamExercise(this.exercise()); + if (this.isExamExercise || !this.exercise().id) { + return; + } + this.updateParticipation(); + } + + private updateParticipation() { + if (this.exercise().id) { + this.exerciseService.getExerciseDetails(this.exercise().id!).subscribe({ + next: (exerciseResponse: HttpResponse) => { + this.participation = this.participationService.getSpecificStudentParticipation(exerciseResponse.body!.exercise.studentParticipations ?? [], false); + }, + error: (error: HttpErrorResponse) => { + this.alertService.error(`artemisApp.${error.error.entityName}.errors.${error.error.errorKey}`); + }, + }); + } + } + + requestFeedback() { + if (!this.assureConditionsSatisfied()) { + return; + } + + if (this.exercise().type === ExerciseType.PROGRAMMING) { + const confirmLockRepository = this.translateService.instant('artemisApp.exercise.lockRepositoryWarning'); + if (!window.confirm(confirmLockRepository)) { + return; + } + } + + this.courseExerciseService.requestFeedback(this.exercise().id!).subscribe({ + next: (participation: StudentParticipation) => { + if (participation) { + this.generatingFeedback.emit(); + this.feedbackSent = true; + this.alertService.success('artemisApp.exercise.feedbackRequestSent'); + } + }, + error: (error: HttpErrorResponse) => { + this.alertService.error(`artemisApp.exercise.${error.error.errorKey}`); + }, + }); + } + + /** + * Checks if the conditions for requesting automatic non-graded feedback are satisfied. + * The student can request automatic non-graded feedback under the following conditions: + * 1. They have a graded submission. + * 2. The deadline for the exercise has not been exceeded. + * 3. There is no already pending feedback request. + * @returns {boolean} `true` if all conditions are satisfied, otherwise `false`. + */ + assureConditionsSatisfied(): boolean { + if (this.exercise().type !== ExerciseType.PROGRAMMING && this.hasAthenaResultForLatestSubmission()) { + const submitFirstWarning = this.translateService.instant('artemisApp.exercise.submissionAlreadyHasAthenaResult'); + this.alertService.warning(submitFirstWarning); + return false; + } + return true; + } + + hasAthenaResultForLatestSubmission(): boolean { + if (this.participation?.submissions && this.participation?.results) { + // submissions.results is always undefined so this is neccessary + return ( + this.participation.submissions?.last()?.id === + this.participation.results?.filter((result) => result.assessmentType == AssessmentType.AUTOMATIC_ATHENA).first()?.submission?.id + ); + } + return false; + } +} diff --git a/src/main/webapp/i18n/de/exercise.json b/src/main/webapp/i18n/de/exercise.json index d8801ed0bb5e..363b5198a69c 100644 --- a/src/main/webapp/i18n/de/exercise.json +++ b/src/main/webapp/i18n/de/exercise.json @@ -168,7 +168,7 @@ "resumeProgrammingExercise": "Die Aufgabe wurde wieder aufgenommen. Du kannst nun weiterarbeiten!", "feedbackRequestSent": "Deine Feedbackanfrage wurde gesendet.", "feedbackRequestAlreadySent": "Deine Feedbackanfrage wurde bereits gesendet.", - "notEnoughPoints": "Um eine Feedbackanfrage zu senden, brauchst du mindestens eine Abgabe.", + "noSubmissionExists": "Um eine Feedbackanfrage zu senden, brauchst du mindestens eine Abgabe.", "lockRepositoryWarning": "Dein Repository wird gesperrt. Du kannst erst weiterarbeiten wenn deine Feedbackanfrage beantwortet wird.", "feedbackRequestAfterDueDate": "Du kannst nach der Abgabefrist keine weiteren Anfragen einreichen.", "maxAthenaResultsReached": "Du hast die maximale Anzahl an KI-Feedbackanfragen erreicht.", diff --git a/src/main/webapp/i18n/en/exercise.json b/src/main/webapp/i18n/en/exercise.json index 6a06631fc343..f50e61efcbb8 100644 --- a/src/main/webapp/i18n/en/exercise.json +++ b/src/main/webapp/i18n/en/exercise.json @@ -168,7 +168,7 @@ "resumeProgrammingExercise": "The exercise has been resumed. You can now continue working on the exercise!", "feedbackRequestSent": "Your feedback request has been sent.", "feedbackRequestAlreadySent": "Your feedback request has already been sent.", - "notEnoughPoints": "You have to submit your work at least once.", + "noSubmissionExists": "You have to submit your work at least once.", "lockRepositoryWarning": "Your repository will be locked. You can only continue working after you receive an answer.", "feedbackRequestAfterDueDate": "You cannot submit feedback requests after the due date.", "maxAthenaResultsReached": "You have reached the maximum number of AI feedback requests.", diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java index 03beb447adad..6fa5a1146440 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; @@ -1615,7 +1616,7 @@ void whenFeedbackRequestedAndDeadlinePassed_thenFail() throws Exception { result.setCompletionDate(ZonedDateTime.now()); resultRepository.save(result); - request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "preconditions not met"); + request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "feedbackRequestAfterDueDate"); localRepo.resetLocalRepo(); } @@ -1643,18 +1644,19 @@ void whenFeedbackRequestedAndRateLimitExceeded_thenFail() throws Exception { resultRepository.save(result); // generate 5 athena results - for (int i = 0; i < 5; i++) { + for (int i = 0; i < 20; i++) { var athenaResult = ParticipationFactory.generateResult(false, 100).participation(participation); athenaResult.setCompletionDate(ZonedDateTime.now()); athenaResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); resultRepository.save(athenaResult); } - request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "preconditions not met"); + request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "maxAthenaResultsReached"); localRepo.resetLocalRepo(); } + @Disabled // will be re-enabled in https://github.com/ls1intum/Artemis/pull/9324/ @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void whenFeedbackRequestedAndRateLimitStillUnknownDueRequestsInProgress_thenFail() throws Exception { diff --git a/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts index 45ce49fc2b4d..3a9102c72e8e 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts @@ -20,6 +20,7 @@ import { TranslateService } from '@ngx-translate/core'; import { facSaveSuccess, facSaveWarning } from 'src/main/webapp/content/icons/icons'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ExamLiveEventsButtonComponent } from 'app/exam/participate/events/exam-live-events-button.component'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; describe('ExamNavigationSidebarComponent', () => { let fixture: ComponentFixture; @@ -33,7 +34,7 @@ describe('ExamNavigationSidebarComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, TranslateTestingModule, MockModule(NgbTooltipModule)], + imports: [ArtemisTestModule, TranslateTestingModule, MockModule(NgbTooltipModule), MockModule(ArtemisSharedCommonModule)], declarations: [ExamNavigationSidebarComponent, MockComponent(ExamTimerComponent), MockComponent(ExamLiveEventsButtonComponent)], providers: [ ExamParticipationService, diff --git a/src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts b/src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts new file mode 100644 index 000000000000..692d9dc153bc --- /dev/null +++ b/src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts @@ -0,0 +1,279 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { MockProvider } from 'ng-mocks'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { Observable, of } from 'rxjs'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { AlertService } from 'app/core/util/alert.service'; +import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockProfileService } from '../../../../helpers/mocks/service/mock-profile.service'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; + +describe('RequestFeedbackButtonComponent', () => { + let component: RequestFeedbackButtonComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + let profileService: ProfileService; + let alertService: AlertService; + let courseExerciseService: CourseExerciseService; + let exerciseService: ExerciseService; + + beforeEach(() => { + return TestBed.configureTestingModule({ + imports: [ArtemisTestModule, RequestFeedbackButtonComponent], + providers: [{ provide: ProfileService, useClass: MockProfileService }, MockProvider(HttpClient)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(RequestFeedbackButtonComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + courseExerciseService = debugElement.injector.get(CourseExerciseService); + exerciseService = debugElement.injector.get(ExerciseService); + profileService = debugElement.injector.get(ProfileService); + alertService = debugElement.injector.get(AlertService); + }); + }); + + function setAthenaEnabled(enabled: boolean) { + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of({ activeProfiles: enabled ? ['athena'] : [] } as ProfileInfo)); + } + + function mockExerciseDetails(exercise: Exercise) { + jest.spyOn(exerciseService, 'getExerciseDetails').mockReturnValue(of(new HttpResponse({ body: { exercise: exercise } }))); + } + + it('should handle errors when requestFeedback fails', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: true }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, course: undefined, studentParticipations: [participation] } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + jest.spyOn(component, 'assureConditionsSatisfied').mockReturnValue(true); + jest.spyOn(courseExerciseService, 'requestFeedback').mockReturnValue( + new Observable((subscriber) => { + subscriber.error({ error: { errorKey: 'someError' } }); + }), + ); + jest.spyOn(alertService, 'error'); + + component.requestFeedback(); + tick(); + + expect(alertService.error).toHaveBeenCalledWith('artemisApp.exercise.someError'); + })); + + it('should display the button when Athena is enabled and it is not an exam exercise', fakeAsync(() => { + setAthenaEnabled(true); + const exercise = { id: 1, type: ExerciseType.TEXT, course: {} } as Exercise; // course undefined means exam exercise + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeTrue(); + })); + + it('should not display the button when it is an exam exercise', fakeAsync(() => { + setAthenaEnabled(true); + fixture.componentRef.setInput('exercise', { id: 1, type: ExerciseType.TEXT, course: undefined } as Exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + const link = debugElement.query(By.css('a')); + expect(button).toBeNull(); + expect(link).toBeNull(); + })); + + it('should disable the button when participation is missing', fakeAsync(() => { + setAthenaEnabled(true); + const exercise = { id: 1, type: ExerciseType.TEXT, course: {}, studentParticipations: undefined } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeTrue(); + })); + + it('should display the correct button label and style when Athena is enabled', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: true }], + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, course: {}, studentParticipations: [participation] } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + component.isExamExercise = false; + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + + const span = button.query(By.css('span')); + expect(span.nativeElement.textContent).toContain('artemisApp.exerciseActions.requestAutomaticFeedback'); + })); + + it('should call requestFeedback() when button is clicked', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: false }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.PROGRAMMING, studentParticipations: [participation], course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + jest.spyOn(component, 'requestFeedback'); + jest.spyOn(window, 'confirm').mockReturnValue(false); + + const button = debugElement.query(By.css('a')); + button.nativeElement.click(); + tick(); + + expect(component.requestFeedback).toHaveBeenCalled(); + })); + + it('should not proceed when confirmation is cancelled for programming exercise', fakeAsync(() => { + setAthenaEnabled(false); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: false }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.PROGRAMMING, studentParticipations: [participation], course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + jest.spyOn(component, 'assureConditionsSatisfied').mockReturnValue(true); + jest.spyOn(window, 'confirm').mockReturnValue(false); + jest.spyOn(courseExerciseService, 'requestFeedback'); + + component.requestFeedback(); + tick(); + + expect(window.confirm).toHaveBeenCalled(); + expect(courseExerciseService.requestFeedback).not.toHaveBeenCalled(); + })); + + it('should proceed when confirmation is accepted for programming exercise', fakeAsync(() => { + setAthenaEnabled(false); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: false }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.PROGRAMMING, studentParticipations: [participation], course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + jest.spyOn(component, 'assureConditionsSatisfied').mockReturnValue(true); + jest.spyOn(window, 'confirm').mockReturnValue(true); + jest.spyOn(courseExerciseService, 'requestFeedback').mockReturnValue(of(participation)); + jest.spyOn(alertService, 'success'); + + component.requestFeedback(); + tick(); + + expect(window.confirm).toHaveBeenCalled(); + expect(courseExerciseService.requestFeedback).toHaveBeenCalledWith(exercise.id); + expect(alertService.success).toHaveBeenCalledWith('artemisApp.exercise.feedbackRequestSent'); + })); + + it('should show an alert when requestFeedback() is called and conditions are not satisfied', fakeAsync(() => { + setAthenaEnabled(true); + + const exercise = { id: 1, type: ExerciseType.TEXT, course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + + jest.spyOn(component, 'hasAthenaResultForLatestSubmission').mockReturnValue(true); + jest.spyOn(alertService, 'warning'); + + component.requestFeedback(); + + expect(alertService.warning).toHaveBeenCalled(); + })); + + it('should disable the button if latest submission is not submitted or feedback is generating', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: false }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, studentParticipations: [participation], course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + fixture.componentRef.setInput('isGeneratingFeedback', false); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeTrue(); + })); + + it('should enable the button if latest submission is submitted and feedback is not generating', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: true }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, course: {}, studentParticipations: [participation] } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + fixture.componentRef.setInput('isGeneratingFeedback', false); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeFalse(); + })); +}); diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts index d8f9e0bb8126..7aded7b4350b 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts @@ -75,6 +75,7 @@ import { AlertService } from 'app/core/util/alert.service'; import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; +import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component'; describe('CodeEditorContainerIntegration', () => { let container: CodeEditorContainerComponent; @@ -123,6 +124,7 @@ describe('CodeEditorContainerIntegration', () => { TreeviewItemComponent, MockPipe(ArtemisDatePipe), MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), + MockComponent(RequestFeedbackButtonComponent), ], providers: [ ChangeDetectorRef,