diff --git a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html index 11e5ee4c828f..ba2349c564d3 100644 --- a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html +++ b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html @@ -1,7 +1,7 @@ @if (headlines?.length && headlines.length > 1) { } -@for (section of sections; track section) { +@for (section of sections(); track section) {

{{ section.headline | artemisTranslate }}

@for (detail of section.details; track $index) { diff --git a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts index 25a5a6ca72e6..27eeca162243 100644 --- a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts +++ b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts @@ -1,7 +1,7 @@ -import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewEncapsulation, inject, input } from '@angular/core'; import { isEmpty } from 'lodash-es'; import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service'; -import { ButtonSize, TooltipPlacement } from 'app/shared/components/button.component'; +import { ButtonSize } from 'app/shared/components/button.component'; import { IrisSubSettingsType } from 'app/entities/iris/settings/iris-sub-settings.model'; import { ModelingExerciseService } from 'app/exercises/modeling/manage/modeling-exercise.service'; import { AlertService } from 'app/core/util/alert.service'; @@ -50,11 +50,13 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { protected readonly FeatureToggle = FeatureToggle; protected readonly ButtonSize = ButtonSize; protected readonly ProgrammingExerciseParticipationType = ProgrammingExerciseParticipationType; + protected readonly CHAT = IrisSubSettingsType.CHAT; - readonly CHAT = IrisSubSettingsType.CHAT; + private readonly modelingExerciseService = inject(ModelingExerciseService); + private readonly alertService = inject(AlertService); + private readonly profileService = inject(ProfileService); - @Input() - sections: DetailOverviewSection[]; + sections = input.required(); // headline list for navigation bar headlines: { id: string; translationKey: string }[]; @@ -64,14 +66,8 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { profileSubscription: Subscription; isLocalVC = false; - constructor( - private modelingExerciseService: ModelingExerciseService, - private alertService: AlertService, - private profileService: ProfileService, - ) {} - ngOnInit() { - this.headlines = this.sections.map((section) => { + this.headlines = this.sections().map((section) => { return { id: section.headline.replaceAll('.', '-'), translationKey: section.headline, @@ -98,6 +94,4 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { ngOnDestroy() { this.profileSubscription?.unsubscribe(); } - - protected readonly TooltipPlacement = TooltipPlacement; } diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts index 1a1a95462530..baed01da2383 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { SafeHtml } from '@angular/platform-browser'; import { ProgrammingExerciseBuildConfig } from 'app/entities/programming/programming-exercise-build.config'; -import { Subject, Subscription } from 'rxjs'; +import { Subject, Subscription, of } from 'rxjs'; import { ProgrammingExercise, ProgrammingLanguage } from 'app/entities/programming/programming-exercise.model'; import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; import { AlertService, AlertType } from 'app/core/util/alert.service'; @@ -57,6 +57,9 @@ import { IrisSubSettingsType } from 'app/entities/iris/settings/iris-sub-setting import { Detail } from 'app/detail-overview-list/detail.model'; import { Competency } from 'app/entities/competency.model'; import { AeolusService } from 'app/exercises/programming/shared/service/aeolus.service'; +import { mergeMap, tap } from 'rxjs/operators'; +import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; +import { BuildLogStatisticsDTO } from 'app/entities/programming/build-log-statistics-dto'; @Component({ selector: 'jhi-programming-exercise-detail', @@ -65,15 +68,32 @@ import { AeolusService } from 'app/exercises/programming/shared/service/aeolus.s encapsulation: ViewEncapsulation.None, }) export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { - readonly dayjs = dayjs; - readonly ActionType = ActionType; - readonly ProgrammingExerciseParticipationType = ProgrammingExerciseParticipationType; - readonly FeatureToggle = FeatureToggle; - readonly ProgrammingLanguage = ProgrammingLanguage; - readonly PROGRAMMING = ExerciseType.PROGRAMMING; - readonly ButtonSize = ButtonSize; - readonly AssessmentType = AssessmentType; - readonly documentationType: DocumentationType = 'Programming'; + protected readonly dayjs = dayjs; + protected readonly ActionType = ActionType; + protected readonly ProgrammingExerciseParticipationType = ProgrammingExerciseParticipationType; + protected readonly FeatureToggle = FeatureToggle; + protected readonly ProgrammingLanguage = ProgrammingLanguage; + protected readonly PROGRAMMING = ExerciseType.PROGRAMMING; + protected readonly ButtonSize = ButtonSize; + protected readonly AssessmentType = AssessmentType; + protected readonly documentationType: DocumentationType = 'Programming'; + + protected readonly faUndo = faUndo; + protected readonly faTrash = faTrash; + protected readonly faBook = faBook; + protected readonly faWrench = faWrench; + protected readonly faCheckDouble = faCheckDouble; + protected readonly faTable = faTable; + protected readonly faExclamationTriangle = faExclamationTriangle; + protected readonly faFileSignature = faFileSignature; + protected readonly faListAlt = faListAlt; + protected readonly faChartBar = faChartBar; + protected readonly faLightbulb = faLightbulb; + protected readonly faPencilAlt = faPencilAlt; + protected readonly faUsers = faUsers; + protected readonly faEye = faEye; + protected readonly faUserCheck = faUserCheck; + protected readonly faRobot = faRobot; programmingExercise: ProgrammingExercise; programmingExerciseBuildConfig?: ProgrammingExerciseBuildConfig; @@ -106,10 +126,7 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { private activatedRouteSubscription: Subscription; private templateAndSolutionParticipationSubscription: Subscription; - private profileInfoSubscription: Subscription; private irisSettingsSubscription: Subscription; - private submissionPolicySubscription: Subscription; - private buildLogsSubscription: Subscription; private exerciseStatisticsSubscription: Subscription; private dialogErrorSource = new Subject(); @@ -117,24 +134,6 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { exerciseDetailSections: DetailOverviewSection[]; - // Icons - faUndo = faUndo; - faTrash = faTrash; - faBook = faBook; - faWrench = faWrench; - faCheckDouble = faCheckDouble; - faTable = faTable; - faExclamationTriangle = faExclamationTriangle; - faFileSignature = faFileSignature; - faListAlt = faListAlt; - faChartBar = faChartBar; - faLightbulb = faLightbulb; - faPencilAlt = faPencilAlt; - faUsers = faUsers; - faEye = faEye; - faUserCheck = faUserCheck; - faRobot = faRobot; - constructor( private activatedRoute: ActivatedRoute, private accountService: AccountService, @@ -184,13 +183,15 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { this.templateAndSolutionParticipationSubscription = this.programmingExerciseService .findWithTemplateAndSolutionParticipationAndLatestResults(programmingExercise.id!) - .subscribe((updatedProgrammingExercise) => { - this.programmingExercise = updatedProgrammingExercise.body!; - - this.setLatestCoveredLineRatio(); - this.loadingTemplateParticipationResults = false; - this.loadingSolutionParticipationResults = false; - this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { + .pipe( + tap((updatedProgrammingExercise) => { + this.programmingExercise = updatedProgrammingExercise.body!; + this.setLatestCoveredLineRatio(); + this.loadingTemplateParticipationResults = false; + this.loadingSolutionParticipationResults = false; + }), + mergeMap(() => this.profileService.getProfileInfo()), + tap((profileInfo) => { if (profileInfo) { if (this.programmingExercise.projectKey && this.programmingExercise.templateParticipation?.buildPlanId) { this.programmingExercise.templateParticipation.buildPlanUrl = createBuildPlanUrl( @@ -215,38 +216,41 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { if (this.irisEnabled) { this.irisSettingsSubscription = this.irisSettingsService.getCombinedCourseSettings(this.courseId).subscribe((settings) => { this.irisChatEnabled = settings?.irisChatSettings?.enabled ?? false; - this.exerciseDetailSections = this.getExerciseDetails(); }); } } + }), + mergeMap(() => this.programmingExerciseSubmissionPolicyService.getSubmissionPolicyOfProgrammingExercise(exerciseId)), + tap((submissionPolicy) => { + this.programmingExercise.submissionPolicy = submissionPolicy; + }), + mergeMap(() => this.programmingExerciseService.getDiffReport(exerciseId)), + tap((gitDiffReport) => { + this.processGitDiffReport(gitDiffReport, false); + }), + mergeMap(() => + this.programmingExercise.isAtLeastEditor ? this.programmingExerciseService.getBuildLogStatistics(exerciseId!) : of([] as BuildLogStatisticsDTO), + ), + tap((buildLogStatistics) => { + if (this.programmingExercise.isAtLeastEditor) { + this.programmingExercise.buildLogStatistics = buildLogStatistics; + } + }), + ) + .subscribe({ + next: () => { + this.setLatestCoveredLineRatio(); + this.checkAndAlertInconsistencies(); + this.plagiarismCheckSupported = this.programmingLanguageFeatureService.getProgrammingLanguageFeature( + programmingExercise.programmingLanguage, + ).plagiarismCheckSupported; + + /** we make sure to await the results of the subscriptions (switchMap) to only call {@link getExerciseDetails} once */ this.exerciseDetailSections = this.getExerciseDetails(); - }); - - this.submissionPolicySubscription = this.programmingExerciseSubmissionPolicyService - .getSubmissionPolicyOfProgrammingExercise(exerciseId!) - .subscribe((submissionPolicy) => { - this.programmingExercise.submissionPolicy = submissionPolicy; - this.exerciseDetailSections = this.getExerciseDetails(); - }); - - this.loadGitDiffReport(); - - // the build logs endpoint requires at least editor privileges - if (this.programmingExercise.isAtLeastEditor) { - this.buildLogsSubscription = this.programmingExerciseService - .getBuildLogStatistics(exerciseId!) - .subscribe((buildLogStatistics) => (this.programmingExercise.buildLogStatistics = buildLogStatistics)); - this.exerciseDetailSections = this.getExerciseDetails(); - } - - this.setLatestCoveredLineRatio(); - - this.checkAndAlertInconsistencies(); - - this.plagiarismCheckSupported = this.programmingLanguageFeatureService.getProgrammingLanguageFeature( - programmingExercise.programmingLanguage, - ).plagiarismCheckSupported; - this.exerciseDetailSections = this.getExerciseDetails(); + }, + error: (error) => { + this.alertService.error(error.message); + }, }); this.exerciseStatisticsSubscription = this.statisticsService.getExerciseStatistics(exerciseId!).subscribe((statistics: ExerciseManagementStatisticsDto) => { @@ -259,13 +263,17 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { this.dialogErrorSource.unsubscribe(); this.activatedRouteSubscription?.unsubscribe(); this.templateAndSolutionParticipationSubscription?.unsubscribe(); - this.profileInfoSubscription?.unsubscribe(); this.irisSettingsSubscription?.unsubscribe(); - this.submissionPolicySubscription?.unsubscribe(); - this.buildLogsSubscription?.unsubscribe(); this.exerciseStatisticsSubscription?.unsubscribe(); } + /** + * BE CAREFUL WHEN CALLING THIS METHOD!
+ * This method can cause child components to re-render, which can lead to re-initializations resulting + * in unnecessary requests putting load on the server. + * + * When adding a new call to this method, make sure that no duplicated and unnecessary requests are made. + */ getExerciseDetails(): DetailOverviewSection[] { const exercise = this.programmingExercise; exercise.buildConfig = this.programmingExerciseBuildConfig; @@ -780,29 +788,37 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { return link; } - loadGitDiffReport() { - this.programmingExerciseService.getDiffReport(this.programmingExercise.id!).subscribe((gitDiffReport) => { - if ( - gitDiffReport && - (this.programmingExercise.gitDiffReport?.templateRepositoryCommitHash !== gitDiffReport.templateRepositoryCommitHash || - this.programmingExercise.gitDiffReport?.solutionRepositoryCommitHash !== gitDiffReport.solutionRepositoryCommitHash) - ) { - this.programmingExercise.gitDiffReport = gitDiffReport; - gitDiffReport.programmingExercise = this.programmingExercise; - this.addedLineCount = - gitDiffReport.entries - ?.map((entry) => entry.lineCount) - .filter((lineCount) => lineCount) - .map((lineCount) => lineCount!) - .reduce((lineCount1, lineCount2) => lineCount1 + lineCount2, 0) ?? 0; - this.removedLineCount = - gitDiffReport.entries - ?.map((entry) => entry.previousLineCount) - .filter((lineCount) => lineCount) - .map((lineCount) => lineCount!) - .reduce((lineCount1, lineCount2) => lineCount1 + lineCount2, 0) ?? 0; + /** + * + * @param gitDiffReport + * @param updateDetailSections set to false when called from OnInit, as another method will take care to update the + * {@link exerciseDetailSections} to prevent unnecessary renderings and duplicated requests, + * see description of {@link getExerciseDetails} + */ + private processGitDiffReport(gitDiffReport: ProgrammingExerciseGitDiffReport | undefined, updateDetailSections: boolean = true): void { + const isGitDiffReportUpdated = + gitDiffReport && + (this.programmingExercise.gitDiffReport?.templateRepositoryCommitHash !== gitDiffReport.templateRepositoryCommitHash || + this.programmingExercise.gitDiffReport?.solutionRepositoryCommitHash !== gitDiffReport.solutionRepositoryCommitHash); + if (isGitDiffReportUpdated) { + this.programmingExercise.gitDiffReport = gitDiffReport; + gitDiffReport.programmingExercise = this.programmingExercise; + + const calculateLineCount = (entries: { lineCount?: number; previousLineCount?: number }[] = [], key: 'lineCount' | 'previousLineCount') => + entries.map((entry) => entry[key] ?? 0).reduce((sum, count) => sum + count, 0); + + this.addedLineCount = calculateLineCount(gitDiffReport.entries, 'lineCount'); + this.removedLineCount = calculateLineCount(gitDiffReport.entries, 'previousLineCount'); + + if (updateDetailSections) { this.exerciseDetailSections = this.getExerciseDetails(); } + } + } + + loadGitDiffReport() { + this.programmingExerciseService.getDiffReport(this.programmingExercise.id!).subscribe((gitDiffReport) => { + this.processGitDiffReport(gitDiffReport); }); } diff --git a/src/test/javascript/spec/component/detail-overview-list.component.spec.ts b/src/test/javascript/spec/component/detail-overview-list.component.spec.ts index 61e891b11e35..8d5998e3053a 100644 --- a/src/test/javascript/spec/component/detail-overview-list.component.spec.ts +++ b/src/test/javascript/spec/component/detail-overview-list.component.spec.ts @@ -56,7 +56,7 @@ describe('DetailOverviewList', () => { }); it('should initialize and destroy', () => { - component.sections = sections; + fixture.componentRef.setInput('sections', sections); fixture.detectChanges(); expect(component.headlines).toStrictEqual([{ id: 'headline-1', translationKey: 'headline.1' }]); expect(component.headlinesRecord).toStrictEqual({ 'headline.1': 'headline-1' }); @@ -67,7 +67,7 @@ describe('DetailOverviewList', () => { }); it('should escape all falsy values', () => { - component.sections = [ + fixture.componentRef.setInput('sections', [ { headline: 'some-section', details: [ @@ -81,7 +81,7 @@ describe('DetailOverviewList', () => { }, ], }, - ]; + ]); fixture.detectChanges(); const detailListTitleDOMElements = fixture.nativeElement.querySelectorAll('dt[id^=detail-title]'); expect(detailListTitleDOMElements).toHaveLength(1); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-detail.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-detail.component.spec.ts index 8563723b42b8..f355ba91a806 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-detail.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-detail.component.spec.ts @@ -22,9 +22,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { MockSyncStorage } from '../../helpers/mocks/service/mock-sync-storage.service'; import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { MockProgrammingExerciseGradingService } from '../../helpers/mocks/service/mock-programming-exercise-grading.service'; -import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; import { ProgrammingExerciseSolutionEntry } from 'app/entities/hestia/programming-exercise-solution-entry.model'; -import { BuildLogStatisticsDTO } from 'app/entities/programming/build-log-statistics-dto'; import { TemplateProgrammingExerciseParticipation } from 'app/entities/participation/template-programming-exercise-participation.model'; import { SolutionProgrammingExerciseParticipation } from 'app/entities/participation/solution-programming-exercise-participation.model'; import { HttpResponse } from '@angular/common/http'; @@ -34,17 +32,24 @@ import { ProgrammingLanguageFeatureService, } from 'app/exercises/programming/shared/service/programming-language-feature/programming-language-feature.service'; import { MockRouter } from '../../helpers/mocks/mock-router'; +import { BuildConfig } from '../../../../../main/webapp/app/entities/programming/build-config.model'; +import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; +import { BuildLogStatisticsDTO } from 'app/entities/programming/build-log-statistics-dto'; +import { SubmissionPolicyService } from '../../../../../main/webapp/app/exercises/programming/manage/services/submission-policy.service'; -describe('ProgrammingExercise Management Detail Component', () => { +describe('ProgrammingExerciseDetailComponent', () => { let comp: ProgrammingExerciseDetailComponent; let fixture: ComponentFixture; let statisticsService: StatisticsService; let exerciseService: ProgrammingExerciseService; let alertService: AlertService; let profileService: ProfileService; + let submissionPolicyService: SubmissionPolicyService; let programmingLanguageFeatureService: ProgrammingLanguageFeatureService; let statisticsServiceStub: jest.SpyInstance; let gitDiffReportStub: jest.SpyInstance; + let profileServiceStub: jest.SpyInstance; + let submissionPolicyServiceStub: jest.SpyInstance; let buildLogStatisticsStub: jest.SpyInstance; let findWithTemplateAndSolutionParticipationStub: jest.SpyInstance; let router: Router; @@ -59,6 +64,9 @@ describe('ProgrammingExercise Management Detail Component', () => { solutionParticipation: { id: 2, } as SolutionProgrammingExerciseParticipation, + buildConfig: { + testwiseCoverageEnabled: true, + } as BuildConfig, } as ProgrammingExercise; const exerciseStatistics = { @@ -128,6 +136,8 @@ describe('ProgrammingExercise Management Detail Component', () => { alertService = fixture.debugElement.injector.get(AlertService); exerciseService = fixture.debugElement.injector.get(ProgrammingExerciseService); profileService = fixture.debugElement.injector.get(ProfileService); + submissionPolicyService = fixture.debugElement.injector.get(SubmissionPolicyService); + programmingLanguageFeatureService = fixture.debugElement.injector.get(ProgrammingLanguageFeatureService); router = fixture.debugElement.injector.get(Router); modalService = fixture.debugElement.injector.get(NgbModal); @@ -136,6 +146,8 @@ describe('ProgrammingExercise Management Detail Component', () => { .spyOn(exerciseService, 'findWithTemplateAndSolutionParticipationAndLatestResults') .mockReturnValue(of(new HttpResponse({ body: mockProgrammingExercise }))); gitDiffReportStub = jest.spyOn(exerciseService, 'getDiffReport').mockReturnValue(of(gitDiffReport)); + profileServiceStub = jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfo)); + submissionPolicyServiceStub = jest.spyOn(submissionPolicyService, 'getSubmissionPolicyOfProgrammingExercise').mockReturnValue(of(undefined)); buildLogStatisticsStub = jest.spyOn(exerciseService, 'getBuildLogStatistics').mockReturnValue(of(buildLogStatistics)); jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfo)); @@ -148,6 +160,19 @@ describe('ProgrammingExercise Management Detail Component', () => { jest.restoreAllMocks(); }); + it('should reload on participation change', fakeAsync(() => { + const loadDiffSpy = jest.spyOn(comp, 'loadGitDiffReport'); + jest.spyOn(exerciseService, 'getLatestResult').mockReturnValue({ successful: true }); + jest.spyOn(exerciseService, 'getLatestFullTestwiseCoverageReport').mockReturnValue(of({ coveredLineRatio: 0.5 })); + comp.programmingExercise = mockProgrammingExercise; + comp.programmingExerciseBuildConfig = mockProgrammingExercise.buildConfig; + comp.onParticipationChange(); + tick(); + expect(loadDiffSpy).toHaveBeenCalledOnce(); + expect(gitDiffReportStub).toHaveBeenCalledOnce(); + expect(comp.programmingExercise.coveredLinesRatio).toBe(0.5); + })); + describe('onInit for course exercise', () => { const programmingExercise = new ProgrammingExercise(new Course(), undefined); programmingExercise.id = 123; @@ -163,6 +188,8 @@ describe('ProgrammingExercise Management Detail Component', () => { // THEN expect(findWithTemplateAndSolutionParticipationStub).toHaveBeenCalledOnce(); + expect(profileServiceStub).toHaveBeenCalledTimes(2); + expect(submissionPolicyServiceStub).toHaveBeenCalledOnce(); expect(gitDiffReportStub).toHaveBeenCalledOnce(); expect(statisticsServiceStub).toHaveBeenCalledOnce(); await Promise.resolve(); @@ -285,18 +312,6 @@ describe('ProgrammingExercise Management Detail Component', () => { expect(comp.isBuildPlanEditable).toBe(editable); }); - it('should reload on participation change', fakeAsync(() => { - const loadDiffSpy = jest.spyOn(comp, 'loadGitDiffReport'); - jest.spyOn(exerciseService, 'getLatestResult').mockReturnValue({ successful: true }); - jest.spyOn(exerciseService, 'getLatestFullTestwiseCoverageReport').mockReturnValue(of({ coveredLineRatio: 0.5 })); - comp.programmingExercise = mockProgrammingExercise; - comp.programmingExercise.buildConfig!.testwiseCoverageEnabled = true; - comp.onParticipationChange(); - tick(); - expect(loadDiffSpy).toHaveBeenCalledOnce(); - expect(comp.programmingExercise.coveredLinesRatio).toBe(0.5); - })); - it('should combine template commit', () => { const combineCommitsSpy = jest.spyOn(exerciseService, 'combineTemplateRepositoryCommits').mockReturnValue(of(new HttpResponse({ body: null }))); const successSpy = jest.spyOn(alertService, 'success');