diff --git a/docs/dev/guidelines/client-design.rst b/docs/dev/guidelines/client-design.rst index 21f18733fb61..fd0aafc31b59 100644 --- a/docs/dev/guidelines/client-design.rst +++ b/docs/dev/guidelines/client-design.rst @@ -237,7 +237,7 @@ Example: .. code-block:: ts - this.themeService.applyThemeExplicitly(Theme.DARK); + this.themeService.applyThemePreference(Theme.DARK); diff --git a/src/main/webapp/app/app.module.ts b/src/main/webapp/app/app.module.ts index 8f90b73a34cb..1e43beff4fc1 100644 --- a/src/main/webapp/app/app.module.ts +++ b/src/main/webapp/app/app.module.ts @@ -21,7 +21,7 @@ import { OrionOutdatedComponent } from 'app/shared/orion/outdated-plugin-warning import { LoadingNotificationComponent } from 'app/shared/notification/loading-notification/loading-notification.component'; import { NotificationPopupComponent } from 'app/shared/notification/notification-popup/notification-popup.component'; import { UserSettingsModule } from 'app/shared/user-settings/user-settings.module'; -import { ThemeModule } from 'app/core/theme/theme.module'; +import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { artemisIconPack } from 'src/main/webapp/content/icons/icons'; @@ -42,7 +42,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling'; ArtemisComplaintsModule, ArtemisHeaderExercisePageWithDetailsModule, UserSettingsModule, - ThemeModule, + ThemeSwitchComponent, ArtemisSharedComponentModule, ScrollingModule, ], diff --git a/src/main/webapp/app/core/theme/theme-switch.component.html b/src/main/webapp/app/core/theme/theme-switch.component.html index 24772ad09ea6..55581a7c807b 100644 --- a/src/main/webapp/app/core/theme/theme-switch.component.html +++ b/src/main/webapp/app/core/theme/theme-switch.component.html @@ -2,9 +2,9 @@
-
{{ 'artemisApp.theme.sync' | artemisTranslate }}
+
- +
@@ -17,10 +17,10 @@ [triggers]="''" #popover="ngbPopover" [autoClose]="false" - [animation]="animate" - [placement]="popoverPlacement" + [animation]="true" + [placement]="popoverPlacement()" > -
+
- @if (highlightedElements && highlightedElements.size > 0) { + @if (highlightedElements() && highlightedElements().size > 0) {
-
+
@@ -160,7 +160,7 @@
} diff --git a/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts b/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts index 72328f495613..27d7cd03894d 100644 --- a/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts +++ b/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; +import { Component, OnInit, ViewChild, computed, effect, inject, signal, untracked } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AlertService } from 'app/core/util/alert.service'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; @@ -40,6 +40,8 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke @ViewChild(ModelingAssessmentComponent, { static: false }) assessmentEditor: ModelingAssessmentComponent; + private readonly themeService = inject(ThemeService); + isNewSubmission: boolean; assessmentMode = false; exerciseId: number; @@ -93,15 +95,14 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke return [...this.referencedFeedback, ...this.unreferencedFeedback]; } - highlightedElements = new Map(); + highlightedElements = signal>(new Map()); referencedExampleFeedback: Feedback[] = []; - highlightColor = 'lightblue'; + highlightColor = computed(() => (this.themeService.userPreference() === Theme.DARK ? 'darkblue' : 'lightblue')); // Icons faSave = faSave; faCircle = faCircle; faInfoCircle = faInfoCircle; - faExclamation = faExclamation; faCodeBranch = faCodeBranch; faChalkboardTeacher = faChalkboardTeacher; @@ -114,9 +115,19 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke private route: ActivatedRoute, private router: Router, private navigationUtilService: ArtemisNavigationUtilService, - private changeDetector: ChangeDetectorRef, - private themeService: ThemeService, - ) {} + ) { + effect(() => { + // Update highlighted elements as soon as current theme changes + const highlightColor = this.highlightColor(); + untracked(() => { + const updatedHighlights = new Map(); + this.highlightedElements().forEach((_, key) => { + updatedHighlights.set(key, highlightColor); + }); + this.highlightedElements.set(updatedHighlights); + }); + }); + } ngOnInit(): void { this.exerciseId = Number(this.route.snapshot.paramMap.get('exerciseId')); @@ -138,20 +149,6 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke this.assessmentMode = true; } this.loadAll(); - - this.themeService.getPreferenceObservable().subscribe((themeOrUndefined) => { - if (themeOrUndefined === Theme.DARK) { - this.highlightColor = 'darkblue'; - } else { - this.highlightColor = 'lightblue'; - } - - const updatedHighlights = new Map(); - this.highlightedElements.forEach((_, key) => { - updatedHighlights.set(key, this.highlightColor); - }); - this.highlightedElements = updatedHighlights; - }); } private loadAll(): void { @@ -478,11 +475,11 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke const missedReferencedExampleFeedbacks = this.referencedExampleFeedback.filter( (feedback) => !this.referencedFeedback.some((referencedFeedback) => referencedFeedback.reference === feedback.reference), ); - this.highlightedElements = new Map(); + const highlightedElements = new Map(); for (const feedback of missedReferencedExampleFeedbacks) { - this.highlightedElements.set(feedback.referenceId!, this.highlightColor); + highlightedElements.set(feedback.referenceId!, this.highlightColor()); } - this.changeDetector.detectChanges(); + this.highlightedElements.set(highlightedElements); } readAndUnderstood() { diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts b/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts index 9eac396fa2bc..d53b738b3ff1 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts @@ -11,6 +11,7 @@ import { ViewChild, ViewContainerRef, createComponent, + inject, } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ThemeService } from 'app/core/theme/theme.service'; @@ -39,6 +40,7 @@ import diff from 'html-diff-ts'; import { ProgrammingExerciseInstructionService } from 'app/exercises/programming/shared/instructions-render/service/programming-exercise-instruction.service'; import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; import { ProgrammingExerciseInstructionTaskStatusComponent } from 'app/exercises/programming/shared/instructions-render/task/programming-exercise-instruction-task-status.component'; +import { toObservable } from '@angular/core/rxjs-interop'; @Component({ selector: 'jhi-programming-exercise-instructions', @@ -46,6 +48,8 @@ import { ProgrammingExerciseInstructionTaskStatusComponent } from 'app/exercises styleUrls: ['./programming-exercise-instruction.scss'], }) export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDestroy { + private themeService = inject(ThemeService); + @Input() public exercise: ProgrammingExercise; @Input() public participation: Participation; @Input() generateHtmlEvents: Observable; @@ -84,8 +88,12 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes private injectableContentFoundSubscription: Subscription; private tasksSubscription: Subscription; private generateHtmlSubscription: Subscription; - private themeChangeSubscription: Subscription; private testCases?: ProgrammingExerciseTestCase[]; + private themeChangeSubscription = toObservable(this.themeService.currentTheme).subscribe(() => { + if (!this.isInitial) { + this.updateMarkdown(); + } + }); // Icons faSpinner = faSpinner; @@ -98,16 +106,12 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes private programmingExercisePlantUmlWrapper: ProgrammingExercisePlantUmlExtensionWrapper, private programmingExerciseParticipationService: ProgrammingExerciseParticipationService, private programmingExerciseGradingService: ProgrammingExerciseGradingService, - themeService: ThemeService, private sanitizer: DomSanitizer, private programmingExerciseInstructionService: ProgrammingExerciseInstructionService, private appRef: ApplicationRef, private injector: EnvironmentInjector, ) { this.programmingExerciseTaskWrapper.viewContainerRef = this.viewContainerRef; - this.themeChangeSubscription = themeService.getCurrentThemeObservable().subscribe(() => { - this.updateMarkdown(); - }); } /** diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/service/programming-exercise-plant-uml.service.ts b/src/main/webapp/app/exercises/programming/shared/instructions-render/service/programming-exercise-plant-uml.service.ts index 801bdaa4d19c..470c54d934dd 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/service/programming-exercise-plant-uml.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/service/programming-exercise-plant-uml.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@angular/core'; +import { Injectable, effect, inject } from '@angular/core'; import { HttpClient, HttpParameterCodec, HttpParams } from '@angular/common/http'; import { Cacheable } from 'ts-cacheable'; import { Observable, Subject } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { Theme, ThemeService } from 'app/core/theme/theme.service'; const themeChangedSubject = new Subject(); @@ -12,19 +12,20 @@ export class ProgrammingExercisePlantUmlService { private resourceUrl = 'api/plantuml'; private encoder: HttpParameterCodec; + private readonly themeService = inject(ThemeService); + private readonly http = inject(HttpClient); + /** * Cacheable configuration */ - constructor( - private http: HttpClient, - private themeService: ThemeService, - ) { + constructor() { this.encoder = new HttpUrlCustomEncoder(); - this.themeService - .getCurrentThemeObservable() - .pipe(tap(() => themeChangedSubject.next())) - .subscribe(); + effect(() => { + // Apply the theme as soon as the currentTheme changes + this.themeService.currentTheme(); + themeChangedSubject.next(); + }); } /** @@ -43,7 +44,7 @@ export class ProgrammingExercisePlantUmlService { getPlantUmlImage(plantUml: string) { return this.http .get(`${this.resourceUrl}/png`, { - params: new HttpParams({ encoder: this.encoder }).set('plantuml', plantUml).set('useDarkTheme', this.themeService.getCurrentTheme() === Theme.DARK), + params: new HttpParams({ encoder: this.encoder }).set('plantuml', plantUml).set('useDarkTheme', this.themeService.currentTheme() === Theme.DARK), responseType: 'arraybuffer', }) .pipe(map((res) => this.convertPlantUmlResponseToBase64(res))); @@ -64,7 +65,7 @@ export class ProgrammingExercisePlantUmlService { }) getPlantUmlSvg(plantUml: string): Observable { return this.http.get(`${this.resourceUrl}/svg`, { - params: new HttpParams({ encoder: this.encoder }).set('plantuml', plantUml).set('useDarkTheme', this.themeService.getCurrentTheme() === Theme.DARK), + params: new HttpParams({ encoder: this.encoder }).set('plantuml', plantUml).set('useDarkTheme', this.themeService.currentTheme() === Theme.DARK), responseType: 'text', }); } diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/step-wizard/programming-exercise-instruction-step-wizard.component.html b/src/main/webapp/app/exercises/programming/shared/instructions-render/step-wizard/programming-exercise-instruction-step-wizard.component.html index eaf81d0a62f8..3ac4387efbfc 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/step-wizard/programming-exercise-instruction-step-wizard.component.html +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/step-wizard/programming-exercise-instruction-step-wizard.component.html @@ -2,7 +2,7 @@
- @for (step of steps; track step; let i = $index) { + @for (step of steps; let i = $index; track i) {
{ + ) { + this.themeSubscription = toObservable(this.themeService.currentTheme).subscribe(() => { this.chooseProgressBarTextColor(); // Manually run change detection as it doesn't do it automatically for some reason @@ -60,7 +59,7 @@ export class ProgressBarComponent implements OnInit, OnChanges, OnDestroy { * Function to change the text color to indicate a finished status */ chooseProgressBarTextColor() { - switch (this.themeService.getCurrentTheme()) { + switch (this.themeService.currentTheme()) { case Theme.DARK: this.foregroundColorClass = 'text-white'; break; diff --git a/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html b/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html index 8566cb951bff..08cceca47792 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html +++ b/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html @@ -7,8 +7,8 @@ [color]="'var(--primary)'" [recent]="recent" [i18n]="{ search: 'artemisApp.metis.searchEmoji' | artemisTranslate, categories: { recent: 'artemisApp.metis.courseEmojiSelectionCategory' | artemisTranslate } }" - [darkMode]="dark" - [imageUrlFn]="singleImageFunction" + [darkMode]="dark()" + [imageUrlFn]="singleImageFunction()" [backgroundImageFn]="utils.EMOJI_SHEET_URL" (emojiSelect)="onEmojiSelect($event)" /> diff --git a/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.ts b/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.ts index 7dcaa9cfbfeb..e37e83152d1a 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.ts +++ b/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.ts @@ -1,6 +1,5 @@ -import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output, computed, inject } from '@angular/core'; import { Theme, ThemeService } from 'app/core/theme/theme.service'; -import { Subscription } from 'rxjs'; import { EmojiUtils } from 'app/shared/metis/emoji/emoji.utils'; import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; @@ -8,28 +7,17 @@ import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; selector: 'jhi-emoji-picker', templateUrl: './emoji-picker.component.html', }) -export class EmojiPickerComponent implements OnDestroy { +export class EmojiPickerComponent { + private themeService = inject(ThemeService); + @Input() emojisToShowFilter: (emoji: string | EmojiData) => boolean; @Input() categoriesIcons: { [key: string]: string }; @Input() recent: string[]; @Output() emojiSelect: EventEmitter = new EventEmitter(); utils = EmojiUtils; - singleImageFunction: (emoji: EmojiData | null) => string; - - dark = false; - themeSubscription: Subscription; - - constructor(private themeService: ThemeService) { - this.themeSubscription = themeService.getCurrentThemeObservable().subscribe((theme) => { - this.dark = theme === Theme.DARK; - this.singleImageFunction = this.dark ? EmojiUtils.singleDarkModeEmojiUrlFn : () => ''; - }); - } - - ngOnDestroy(): void { - this.themeSubscription.unsubscribe(); - } + dark = computed(() => this.themeService.currentTheme() === Theme.DARK); + singleImageFunction = computed(() => (this.dark() ? EmojiUtils.singleDarkModeEmojiUrlFn : () => '')); onEmojiSelect(event: any) { this.emojiSelect.emit(event); diff --git a/src/main/webapp/app/shared/metis/emoji/emoji.component.html b/src/main/webapp/app/shared/metis/emoji/emoji.component.html index 537b14712a91..ac71d2e4826a 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji.component.html +++ b/src/main/webapp/app/shared/metis/emoji/emoji.component.html @@ -1,7 +1,6 @@ -@if (!dark) { +@if (!dark()) { -} -@if (dark) { +} @else { } diff --git a/src/main/webapp/app/shared/metis/emoji/emoji.component.ts b/src/main/webapp/app/shared/metis/emoji/emoji.component.ts index 75d21024aad9..00e651bf19b7 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji.component.ts +++ b/src/main/webapp/app/shared/metis/emoji/emoji.component.ts @@ -1,6 +1,5 @@ -import { Component, Input, OnDestroy } from '@angular/core'; +import { Component, Input, computed, inject } from '@angular/core'; import { Theme, ThemeService } from 'app/core/theme/theme.service'; -import { Subscription } from 'rxjs'; import { EmojiUtils } from 'app/shared/metis/emoji/emoji.utils'; @Component({ @@ -8,21 +7,11 @@ import { EmojiUtils } from 'app/shared/metis/emoji/emoji.utils'; templateUrl: './emoji.component.html', styleUrls: ['./emoji.component.scss'], }) -export class EmojiComponent implements OnDestroy { - utils = EmojiUtils; +export class EmojiComponent { + private themeService = inject(ThemeService); + utils = EmojiUtils; @Input() emoji: string; - dark = false; - themeSubscription: Subscription; - - constructor(private themeService: ThemeService) { - this.themeSubscription = themeService.getCurrentThemeObservable().subscribe((theme) => { - this.dark = theme === Theme.DARK; - }); - } - - ngOnDestroy(): void { - this.themeSubscription.unsubscribe(); - } + dark = computed(() => this.themeService.currentTheme() === Theme.DARK); } diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts index 5bbb04e440fc..52a2b40a3693 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts @@ -2,7 +2,6 @@ import { Injectable, effect, inject } from '@angular/core'; import * as monaco from 'monaco-editor'; import { CUSTOM_MARKDOWN_CONFIG, CUSTOM_MARKDOWN_LANGUAGE, CUSTOM_MARKDOWN_LANGUAGE_ID } from 'app/shared/monaco-editor/model/languages/monaco-custom-markdown.language'; import { Theme, ThemeService } from 'app/core/theme/theme.service'; -import { toSignal } from '@angular/core/rxjs-interop'; import { MONACO_LIGHT_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-light.theme'; import { MonacoEditorTheme } from 'app/shared/monaco-editor/model/themes/monaco-editor-theme.model'; import { MONACO_DARK_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-dark.theme'; @@ -15,7 +14,7 @@ import { MONACO_DARK_THEME_DEFINITION } from 'app/shared/monaco-editor/model/the @Injectable({ providedIn: 'root' }) export class MonacoEditorService { private readonly themeService: ThemeService = inject(ThemeService); - private readonly currentTheme = toSignal(this.themeService.getCurrentThemeObservable(), { requireSync: true }); + private readonly currentTheme = this.themeService.currentTheme; private lightTheme: MonacoEditorTheme; private darkTheme: MonacoEditorTheme; diff --git a/src/test/javascript/spec/component/emoji/emoji-picker.component.spec.ts b/src/test/javascript/spec/component/emoji/emoji-picker.component.spec.ts index fce5fe967272..4e4d76f5a019 100644 --- a/src/test/javascript/spec/component/emoji/emoji-picker.component.spec.ts +++ b/src/test/javascript/spec/component/emoji/emoji-picker.component.spec.ts @@ -11,7 +11,6 @@ describe('EmojiPickerComponent', () => { let fixture: ComponentFixture; let comp: EmojiPickerComponent; let mockThemeService: ThemeService; - let themeSpy: jest.SpyInstance; beforeEach(() => { TestBed.configureTestingModule({ @@ -21,7 +20,6 @@ describe('EmojiPickerComponent', () => { .compileComponents() .then(() => { mockThemeService = TestBed.inject(ThemeService); - themeSpy = jest.spyOn(mockThemeService, 'getCurrentThemeObservable'); fixture = TestBed.createComponent(EmojiPickerComponent); comp = fixture.componentInstance; }); @@ -31,19 +29,14 @@ describe('EmojiPickerComponent', () => { jest.restoreAllMocks(); }); - it('should subscribe and unsubscribe to the theme service and react to changes', () => { - expect(themeSpy).toHaveBeenCalledOnce(); - expect(comp.dark).toBeFalse(); - expect(comp.themeSubscription).toBeDefined(); + it('should react to theme changes', () => { + expect(comp.dark()).toBeFalse(); + expect(comp.singleImageFunction()({ unified: '1F519' } as EmojiData)).toBe(''); - expect(comp.singleImageFunction({ unified: '1F519' } as EmojiData)).toBe(''); - mockThemeService.applyThemeExplicitly(Theme.DARK); - expect(comp.singleImageFunction({ unified: '1F519' } as EmojiData)).toBe('public/emoji/1f519.png'); + mockThemeService.applyThemePreference(Theme.DARK); - const subSpy = jest.spyOn(comp.themeSubscription, 'unsubscribe'); - - comp.ngOnDestroy(); - expect(subSpy).toHaveBeenCalledOnce(); + expect(comp.dark()).toBeTrue(); + expect(comp.singleImageFunction()({ unified: '1F519' } as EmojiData)).toBe('public/emoji/1f519.png'); }); it('should emit an event on emoji select', () => { diff --git a/src/test/javascript/spec/component/emoji/emoji.component.spec.ts b/src/test/javascript/spec/component/emoji/emoji.component.spec.ts deleted file mode 100644 index 887a27a7686d..000000000000 --- a/src/test/javascript/spec/component/emoji/emoji.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ArtemisTestModule } from '../../test.module'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { Theme, ThemeService } from 'app/core/theme/theme.service'; -import { of } from 'rxjs'; - -describe('EmojiComponent', () => { - let fixture: ComponentFixture; - let comp: EmojiComponent; - let mockThemeService: ThemeService; - let themeSpy: jest.SpyInstance; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule], - declarations: [], - providers: [], - }) - .compileComponents() - .then(() => { - mockThemeService = TestBed.inject(ThemeService); - themeSpy = jest.spyOn(mockThemeService, 'getCurrentThemeObservable').mockReturnValue(of(Theme.DARK)); - fixture = TestBed.createComponent(EmojiComponent); - comp = fixture.componentInstance; - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should subscribe and unsubscribe to the theme service and set dark flag', () => { - expect(themeSpy).toHaveBeenCalledOnce(); - expect(comp.dark).toBeTrue(); - expect(comp.themeSubscription).toBeDefined(); - - const subSpy = jest.spyOn(comp.themeSubscription, 'unsubscribe'); - - comp.ngOnDestroy(); - expect(subSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts index 3cfff55d7749..d0233fde1827 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts @@ -210,6 +210,7 @@ describe('ProgrammingExerciseInstructionComponent', () => { comp.participation = participation; comp.isInitial = false; triggerChanges(comp, { property: 'exercise', currentValue: { ...comp.exercise, problemStatement: newProblemStatement }, firstChange: false }); + fixture.detectChanges(); expect(comp.markdownExtensions).toHaveLength(2); expect(updateMarkdownStub).toHaveBeenCalledOnce(); expect(loadInitialResult).not.toHaveBeenCalled(); @@ -426,7 +427,6 @@ describe('ProgrammingExerciseInstructionComponent', () => { comp.updateMarkdown(); - fixture.detectChanges(); tick(); // first test should be green (successful), second red (failed) @@ -447,7 +447,13 @@ describe('ProgrammingExerciseInstructionComponent', () => { it('should update the markdown on a theme change', () => { const updateMarkdownStub = jest.spyOn(comp, 'updateMarkdown'); - themeService.applyThemeExplicitly(Theme.DARK); + + comp.isInitial = false; + themeService.applyThemePreference(Theme.DARK); + + fixture.detectChanges(); + + // toObservable triggers a effect in the background on initial detectChanges expect(updateMarkdownStub).toHaveBeenCalledOnce(); }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts index 1a5a59e69436..4951d70833b4 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts @@ -1,10 +1,9 @@ import { TestBed } from '@angular/core/testing'; import * as monaco from 'monaco-editor'; import { Theme, ThemeService } from 'app/core/theme/theme.service'; -import { MonacoEditorService } from '../../../../../../main/webapp/app/shared/monaco-editor/monaco-editor.service'; +import { MonacoEditorService } from 'app/shared/monaco-editor/monaco-editor.service'; import { ArtemisTestModule } from '../../../test.module'; import { CUSTOM_MARKDOWN_LANGUAGE_ID } from 'app/shared/monaco-editor/model/languages/monaco-custom-markdown.language'; -import { BehaviorSubject } from 'rxjs'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; import { MONACO_LIGHT_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-light.theme'; import { MONACO_DARK_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-dark.theme'; @@ -13,7 +12,8 @@ describe('MonacoEditorService', () => { let monacoEditorService: MonacoEditorService; let setThemeSpy: jest.SpyInstance; let registerLanguageSpy: jest.SpyInstance; - const themeSubject = new BehaviorSubject(Theme.LIGHT); + + let themeService: ThemeService; beforeEach(() => { TestBed.configureTestingModule({ @@ -25,8 +25,7 @@ describe('MonacoEditorService', () => { }); registerLanguageSpy = jest.spyOn(monaco.languages, 'register'); setThemeSpy = jest.spyOn(monaco.editor, 'setTheme'); - const themeService = TestBed.inject(ThemeService); - jest.spyOn(themeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); + themeService = TestBed.inject(ThemeService); monacoEditorService = TestBed.inject(MonacoEditorService); }); @@ -44,20 +43,26 @@ describe('MonacoEditorService', () => { // Initialization: The editor should be in light mode since that is what we initialized the themeSubject with expect(setThemeSpy).toHaveBeenCalledExactlyOnceWith(MONACO_LIGHT_THEME_DEFINITION.id); // Switch to dark theme - themeSubject.next(Theme.DARK); + themeService.applyThemePreference(Theme.DARK); TestBed.flushEffects(); expect(setThemeSpy).toHaveBeenCalledTimes(2); expect(setThemeSpy).toHaveBeenNthCalledWith(2, MONACO_DARK_THEME_DEFINITION.id); // Switch back to light theme - themeSubject.next(Theme.LIGHT); + themeService.applyThemePreference(Theme.LIGHT); TestBed.flushEffects(); expect(setThemeSpy).toHaveBeenCalledTimes(3); expect(setThemeSpy).toHaveBeenNthCalledWith(3, MONACO_LIGHT_THEME_DEFINITION.id); }); it.each([ - { className: 'monaco-editor', createFn: (element: HTMLElement) => monacoEditorService.createStandaloneCodeEditor(element) }, - { className: 'monaco-diff-editor', createFn: (element: HTMLElement) => monacoEditorService.createStandaloneDiffEditor(element) }, + { + className: 'monaco-editor', + createFn: (element: HTMLElement) => monacoEditorService.createStandaloneCodeEditor(element), + }, + { + className: 'monaco-diff-editor', + createFn: (element: HTMLElement) => monacoEditorService.createStandaloneDiffEditor(element), + }, ])( 'should insert an editor ($className) into the provided DOM element', ({ className, createFn }: { className: string; createFn: (element: HTMLElement) => monaco.editor.IStandaloneCodeEditor | monaco.editor.IStandaloneDiffEditor }) => { diff --git a/src/test/javascript/spec/component/shared/navbar.component.spec.ts b/src/test/javascript/spec/component/shared/navbar.component.spec.ts index 8b37f25b6f19..bc2e7cb37b92 100644 --- a/src/test/javascript/spec/component/shared/navbar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/navbar.component.spec.ts @@ -26,7 +26,6 @@ import { JhiConnectionWarningComponent } from 'app/shared/connection-warning/con import { AccountService } from 'app/core/auth/account.service'; import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; import { EntityTitleService, EntityType } from 'app/shared/layouts/navbar/entity-title.service'; -import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; import { Authority } from 'app/shared/constants/authority.constants'; import { User } from 'app/core/user/user.model'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; @@ -39,6 +38,9 @@ import { NgbCollapseMocksModule } from '../../helpers/mocks/directive/ngbCollaps import { NgbDropdownMocksModule } from '../../helpers/mocks/directive/ngbDropdownMocks.module'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { GuidedTourService } from 'app/guided-tour/guided-tour.service'; +import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; +import { mockThemeSwitcherComponentViewChildren } from '../../helpers/mocks/mock-instance.helper'; class MockBreadcrumb { label: string; @@ -91,6 +93,9 @@ describe('NavbarComponent', () => { activeProfiles: ['test'], } as ProfileInfo; + // Workaround for an error with MockComponent(). You can remove this once https://github.com/help-me-mom/ng-mocks/issues/8634 is resolved. + mockThemeSwitcherComponentViewChildren(); + beforeEach(() => { return TestBed.configureTestingModule({ imports: [NgbTooltipMocksModule, NgbCollapseMocksModule, NgbDropdownMocksModule], @@ -107,9 +112,9 @@ describe('NavbarComponent', () => { MockComponent(GuidedTourComponent), MockComponent(LoadingNotificationComponent), MockComponent(JhiConnectionWarningComponent), - MockComponent(ThemeSwitchComponent), MockComponent(SystemNotificationComponent), MockComponent(FaIconComponent), + MockComponent(ThemeSwitchComponent), ], providers: [ provideHttpClient(), @@ -123,12 +128,23 @@ describe('NavbarComponent', () => { { provide: SessionStorageService, useClass: MockSyncStorage }, { provide: TranslateService, useClass: MockTranslateService }, { provide: Router, useValue: router }, + { + provide: GuidedTourService, + useValue: { + getGuidedTourAvailabilityStream: () => of(false), + }, + }, { provide: ActivatedRoute, useValue: new MockActivatedRoute({ id: 123 }), }, ], }) + .overrideComponent(NavbarComponent, { + remove: { + imports: [ThemeSwitchComponent], + }, + }) .compileComponents() .then(() => { fixture = TestBed.createComponent(NavbarComponent); @@ -216,10 +232,22 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs).toHaveLength(3); - const systemBreadcrumb = { label: 'artemisApp.systemNotification.systemNotifications', translate: true, uri: '/admin/system-notification-management/' } as MockBreadcrumb; + const systemBreadcrumb = { + label: 'artemisApp.systemNotification.systemNotifications', + translate: true, + uri: '/admin/system-notification-management/', + } as MockBreadcrumb; expect(component.breadcrumbs[0]).toEqual(systemBreadcrumb); - expect(component.breadcrumbs[1]).toEqual({ label: '1', translate: false, uri: '/admin/system-notification-management/1/' } as MockBreadcrumb); - expect(component.breadcrumbs[2]).toEqual({ label: 'global.generic.edit', translate: true, uri: '/admin/system-notification-management/1/edit/' } as MockBreadcrumb); + expect(component.breadcrumbs[1]).toEqual({ + label: '1', + translate: false, + uri: '/admin/system-notification-management/1/', + } as MockBreadcrumb); + expect(component.breadcrumbs[2]).toEqual({ + label: 'global.generic.edit', + translate: true, + uri: '/admin/system-notification-management/1/edit/', + } as MockBreadcrumb); }); it('should build breadcrumbs for user management', () => { @@ -230,8 +258,16 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs).toHaveLength(2); - expect(component.breadcrumbs[0]).toEqual({ label: 'artemisApp.userManagement.home.title', translate: true, uri: '/admin/user-management/' } as MockBreadcrumb); - expect(component.breadcrumbs[1]).toEqual({ label: 'test_user', translate: false, uri: '/admin/user-management/test_user/' } as MockBreadcrumb); + expect(component.breadcrumbs[0]).toEqual({ + label: 'artemisApp.userManagement.home.title', + translate: true, + uri: '/admin/user-management/', + } as MockBreadcrumb); + expect(component.breadcrumbs[1]).toEqual({ + label: 'test_user', + translate: false, + uri: '/admin/user-management/test_user/', + } as MockBreadcrumb); }); it('should build breadcrumbs for organization management', () => { @@ -244,8 +280,16 @@ describe('NavbarComponent', () => { expect(entityTitleServiceStub).toHaveBeenCalledWith(EntityType.ORGANIZATION, [1]); expect(component.breadcrumbs).toHaveLength(2); - expect(component.breadcrumbs[0]).toEqual({ label: 'artemisApp.organizationManagement.title', translate: true, uri: '/admin/organization-management/' } as MockBreadcrumb); - expect(component.breadcrumbs[1]).toEqual({ label: 'Test Organization', translate: false, uri: '/admin/organization-management/1/' } as MockBreadcrumb); + expect(component.breadcrumbs[0]).toEqual({ + label: 'artemisApp.organizationManagement.title', + translate: true, + uri: '/admin/organization-management/', + } as MockBreadcrumb); + expect(component.breadcrumbs[1]).toEqual({ + label: 'Test Organization', + translate: false, + uri: '/admin/organization-management/1/', + } as MockBreadcrumb); }); it('should not error without translation', () => { @@ -256,7 +300,11 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs).toHaveLength(1); - expect(component.breadcrumbs[0]).toEqual({ label: 'route-without-translation', translate: false, uri: '/admin/route-without-translation/' } as MockBreadcrumb); + expect(component.breadcrumbs[0]).toEqual({ + label: 'route-without-translation', + translate: false, + uri: '/admin/route-without-translation/', + } as MockBreadcrumb); }); it('should hide breadcrumb when exam is started', () => { @@ -358,7 +406,11 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs[0]).toEqual(courseManagementCrumb); expect(component.breadcrumbs[1]).toEqual(testCourseCrumb); expect(component.breadcrumbs[2]).toEqual(programmingExercisesCrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exercise', translate: false, uri: '/course-management/1/programming-exercises/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exercise', + translate: false, + uri: '/course-management/1/programming-exercises/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(gradingCrumb); }); @@ -383,7 +435,11 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs[0]).toEqual(courseManagementCrumb); expect(component.breadcrumbs[1]).toEqual(testCourseCrumb); expect(component.breadcrumbs[2]).toEqual(programmingExercisesCrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exercise', translate: false, uri: '/course-management/1/programming-exercises/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exercise', + translate: false, + uri: '/course-management/1/programming-exercises/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(assessmentCrumb); }); @@ -391,9 +447,14 @@ describe('NavbarComponent', () => { const testUrl = '/course-management/1/exercises/2/exercise-hints/3'; router.setUrl(testUrl); - const findStub = jest - .spyOn(exerciseService, 'find') - .mockReturnValue(of({ body: { title: 'Test Exercise', type: ExerciseType.PROGRAMMING } } as HttpResponse)); + const findStub = jest.spyOn(exerciseService, 'find').mockReturnValue( + of({ + body: { + title: 'Test Exercise', + type: ExerciseType.PROGRAMMING, + }, + } as HttpResponse), + ); fixture.detectChanges(); @@ -419,8 +480,16 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs[0]).toEqual(courseManagementCrumb); expect(component.breadcrumbs[1]).toEqual(testCourseCrumb); - expect(component.breadcrumbs[2]).toEqual({ label: 'artemisApp.course.exercises', translate: true, uri: '/course-management/1/exercises/' } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exercise', translate: false, uri: '/course-management/1/programming-exercises/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[2]).toEqual({ + label: 'artemisApp.course.exercises', + translate: true, + uri: '/course-management/1/exercises/', + } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exercise', + translate: false, + uri: '/course-management/1/programming-exercises/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(hintsCrumb); expect(component.breadcrumbs[5]).toEqual(hintCrumb); }); @@ -484,7 +553,11 @@ describe('NavbarComponent', () => { translate: true, uri: '/course-management/1/modeling-exercises/', } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exercise', translate: false, uri: '/course-management/1/modeling-exercises/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exercise', + translate: false, + uri: '/course-management/1/modeling-exercises/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(submissionCrumb); expect(component.breadcrumbs[5]).toEqual(editorSubmissionCrumb); }); @@ -520,7 +593,11 @@ describe('NavbarComponent', () => { translate: true, uri: '/course-management/1/modeling-exercises/', } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exercise', translate: false, uri: '/course-management/1/modeling-exercises/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exercise', + translate: false, + uri: '/course-management/1/modeling-exercises/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(submissionCrumb); expect(component.breadcrumbs[5]).toEqual(editorSubmissionCrumb); }); @@ -551,8 +628,16 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs[0]).toEqual(courseManagementCrumb); expect(component.breadcrumbs[1]).toEqual(testCourseCrumb); - expect(component.breadcrumbs[2]).toEqual({ label: 'artemisApp.lecture.home.title', translate: true, uri: '/course-management/1/lectures/' } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Lecture', translate: false, uri: '/course-management/1/lectures/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[2]).toEqual({ + label: 'artemisApp.lecture.home.title', + translate: true, + uri: '/course-management/1/lectures/', + } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Lecture', + translate: false, + uri: '/course-management/1/lectures/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(unitManagementCrumb); expect(component.breadcrumbs[5]).toEqual(createCrumb); }); @@ -576,7 +661,11 @@ describe('NavbarComponent', () => { translate: true, uri: '/course-management/1/apollon-diagrams/', } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Diagram', translate: false, uri: '/course-management/1/apollon-diagrams/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Diagram', + translate: false, + uri: '/course-management/1/apollon-diagrams/2/', + } as MockBreadcrumb); }); it('exam exercise groups', () => { @@ -604,8 +693,16 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs[0]).toEqual(courseManagementCrumb); expect(component.breadcrumbs[1]).toEqual(testCourseCrumb); - expect(component.breadcrumbs[2]).toEqual({ label: 'artemisApp.examManagement.title', translate: true, uri: '/course-management/1/exams/' } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exam', translate: false, uri: '/course-management/1/exams/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[2]).toEqual({ + label: 'artemisApp.examManagement.title', + translate: true, + uri: '/course-management/1/exams/', + } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exam', + translate: false, + uri: '/course-management/1/exams/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(exerciseGroupsCrumb); expect(component.breadcrumbs[5]).toEqual(createCrumb); }); @@ -641,8 +738,16 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs[0]).toEqual(courseManagementCrumb); expect(component.breadcrumbs[1]).toEqual(testCourseCrumb); - expect(component.breadcrumbs[2]).toEqual({ label: 'artemisApp.examManagement.title', translate: true, uri: '/course-management/1/exams/' } as MockBreadcrumb); - expect(component.breadcrumbs[3]).toEqual({ label: 'Test Exam', translate: false, uri: '/course-management/1/exams/2/' } as MockBreadcrumb); + expect(component.breadcrumbs[2]).toEqual({ + label: 'artemisApp.examManagement.title', + translate: true, + uri: '/course-management/1/exams/', + } as MockBreadcrumb); + expect(component.breadcrumbs[3]).toEqual({ + label: 'Test Exam', + translate: false, + uri: '/course-management/1/exams/2/', + } as MockBreadcrumb); expect(component.breadcrumbs[4]).toEqual(exerciseGroupsCrumb); expect(component.breadcrumbs[5]).toEqual(exerciseCrumb); expect(component.breadcrumbs[6]).toEqual(plagiarismCrumb); @@ -663,28 +768,111 @@ describe('NavbarComponent', () => { expect(component.breadcrumbs).toHaveLength(4); expect(component.breadcrumbs[0]).toMatchObject({ uri: '/courses/', label: 'artemisApp.course.home.title' }); expect(component.breadcrumbs[1]).toMatchObject({ uri: '/courses/1/', label: 'Test Course' }); - expect(component.breadcrumbs[2]).toMatchObject({ uri: '/courses/1/exercises/', label: 'artemisApp.courseOverview.menu.exercises' }); + expect(component.breadcrumbs[2]).toMatchObject({ + uri: '/courses/1/exercises/', + label: 'artemisApp.courseOverview.menu.exercises', + }); expect(component.breadcrumbs[3]).toMatchObject({ uri: '/courses/1/exercises/2/', label: 'Test Exercise' }); }); }); it.each([ - { width: 1200, account: { login: 'test' }, roles: [Authority.ADMIN], expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 1100, account: { login: 'test' }, roles: [Authority.ADMIN], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 600, account: { login: 'test' }, roles: [Authority.ADMIN], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: true } }, - { width: 550, account: { login: 'test' }, roles: [Authority.ADMIN], expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true } }, - { width: 1000, account: { login: 'test' }, roles: [Authority.INSTRUCTOR], expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 850, account: { login: 'test' }, roles: [Authority.INSTRUCTOR], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 600, account: { login: 'test' }, roles: [Authority.INSTRUCTOR], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: true } }, - { width: 470, account: { login: 'test' }, roles: [Authority.INSTRUCTOR], expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true } }, - { width: 800, account: { login: 'test' }, roles: [Authority.USER], expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 650, account: { login: 'test' }, roles: [Authority.USER], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 600, account: { login: 'test' }, roles: [Authority.USER], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: true } }, - { width: 470, account: { login: 'test' }, roles: [Authority.USER], expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true } }, - { width: 520, account: undefined, roles: [], expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 500, account: undefined, roles: [], expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false } }, - { width: 450, account: undefined, roles: [], expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: false } }, - { width: 400, account: undefined, roles: [], expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true } }, + { + width: 1200, + account: { login: 'test' }, + roles: [Authority.ADMIN], + expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 1100, + account: { login: 'test' }, + roles: [Authority.ADMIN], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 600, + account: { login: 'test' }, + roles: [Authority.ADMIN], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: true }, + }, + { + width: 550, + account: { login: 'test' }, + roles: [Authority.ADMIN], + expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true }, + }, + { + width: 1000, + account: { login: 'test' }, + roles: [Authority.INSTRUCTOR], + expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 850, + account: { login: 'test' }, + roles: [Authority.INSTRUCTOR], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 600, + account: { login: 'test' }, + roles: [Authority.INSTRUCTOR], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: true }, + }, + { + width: 470, + account: { login: 'test' }, + roles: [Authority.INSTRUCTOR], + expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true }, + }, + { + width: 800, + account: { login: 'test' }, + roles: [Authority.USER], + expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 650, + account: { login: 'test' }, + roles: [Authority.USER], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 600, + account: { login: 'test' }, + roles: [Authority.USER], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: true }, + }, + { + width: 470, + account: { login: 'test' }, + roles: [Authority.USER], + expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true }, + }, + { + width: 520, + account: undefined, + roles: [], + expected: { isCollapsed: false, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 500, + account: undefined, + roles: [], + expected: { isCollapsed: true, isNavbarNavVertical: false, iconsMovedToMenu: false }, + }, + { + width: 450, + account: undefined, + roles: [], + expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: false }, + }, + { + width: 400, + account: undefined, + roles: [], + expected: { isCollapsed: true, isNavbarNavVertical: true, iconsMovedToMenu: true }, + }, ])('should calculate correct breakpoints', ({ width, account, roles, expected }) => { const accountService = TestBed.inject(AccountService); jest.spyOn(accountService, 'hasAnyAuthorityDirect').mockImplementation((authArray) => authArray.some((auth) => (roles as string[]).includes(auth))); @@ -694,6 +882,10 @@ describe('NavbarComponent', () => { component.onResize(); - expect({ isCollapsed: component.isCollapsed, isNavbarNavVertical: component.isNavbarNavVertical, iconsMovedToMenu: component.iconsMovedToMenu }).toEqual(expected); + expect({ + isCollapsed: component.isCollapsed, + isNavbarNavVertical: component.isNavbarNavVertical, + iconsMovedToMenu: component.iconsMovedToMenu, + }).toEqual(expected); }); }); diff --git a/src/test/javascript/spec/component/shared/progress-bar.component.spec.ts b/src/test/javascript/spec/component/shared/progress-bar.component.spec.ts index 8fc59c1a7e0e..e7d1805953df 100644 --- a/src/test/javascript/spec/component/shared/progress-bar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/progress-bar.component.spec.ts @@ -5,6 +5,7 @@ import { ArtemisTestModule } from '../../test.module'; import { SimpleChange } from '@angular/core'; import { MockDirective } from 'ng-mocks'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { MockThemeService } from '../../helpers/mocks/service/mock-theme.service'; describe('ProgressBarComponent', () => { let fixture: ComponentFixture; @@ -14,6 +15,12 @@ describe('ProgressBarComponent', () => { TestBed.configureTestingModule({ imports: [ArtemisTestModule, MockDirective(NgbTooltip)], declarations: [ProgressBarComponent], + providers: [ + { + class: ThemeService, + useClass: MockThemeService, + }, + ], }) .compileComponents() .then(() => { @@ -49,7 +56,10 @@ describe('ProgressBarComponent', () => { component.ngOnChanges({ percentage: {} as SimpleChange }); expect(component.foregroundColorClass).toBe('text-dark'); - themeService.applyThemeExplicitly(Theme.DARK); + themeService.applyThemePreference(Theme.DARK); + + fixture.detectChanges(); + expect(component.foregroundColorClass).toBe('text-white'); }); diff --git a/src/test/javascript/spec/component/theme/theme-switch.component.spec.ts b/src/test/javascript/spec/component/theme/theme-switch.component.spec.ts index 3bc9282cb8ed..4fccadf8b4e0 100644 --- a/src/test/javascript/spec/component/theme/theme-switch.component.spec.ts +++ b/src/test/javascript/spec/component/theme/theme-switch.component.spec.ts @@ -1,94 +1,85 @@ import { ArtemisTestModule } from '../../test.module'; import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; -import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; -import { ThemeService } from 'app/core/theme/theme.service'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Theme, ThemeService } from 'app/core/theme/theme.service'; import { MockLocalStorageService } from '../../helpers/mocks/service/mock-local-storage.service'; import { LocalStorageService } from 'ngx-webstorage'; import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; import { MockDirective } from 'ng-mocks'; +import { MockThemeService } from '../../helpers/mocks/service/mock-theme.service'; describe('ThemeSwitchComponent', () => { let component: ThemeSwitchComponent; let fixture: ComponentFixture; let themeService: ThemeService; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MockDirective(NgbPopover)], - declarations: [ThemeSwitchComponent], - providers: [{ provide: LocalStorageService, useClass: MockLocalStorageService }], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(ThemeSwitchComponent); - themeService = TestBed.inject(ThemeService); - component = fixture.componentInstance; - // @ts-ignore - component.popover = { open: jest.fn(), close: jest.fn() }; - }); - }); + let openSpy: jest.SpyInstance; + let closeSpy: jest.SpyInstance; - afterEach(() => jest.restoreAllMocks()); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, ThemeSwitchComponent, MockDirective(NgbPopover)], + declarations: [], + providers: [ + { provide: LocalStorageService, useClass: MockLocalStorageService }, + { + provide: ThemeService, + useClass: MockThemeService, + }, + ], + }).compileComponents(); - it('oninit: subscribe to theme service', fakeAsync(() => { - const subscribeSpy = jest.spyOn(themeService, 'getCurrentThemeObservable'); + themeService = TestBed.inject(ThemeService); - component.ngOnInit(); - expect(subscribeSpy).toHaveBeenCalledOnce(); - })); + fixture = TestBed.createComponent(ThemeSwitchComponent); + component = fixture.componentInstance; + + openSpy = jest.spyOn(component.popover(), 'open'); + closeSpy = jest.spyOn(component.popover(), 'close'); + + fixture.componentRef.setInput('popoverPlacement', ['bottom']); + }); + + afterEach(() => jest.restoreAllMocks()); it('theme toggles correctly', fakeAsync(() => { - component.ngOnInit(); - component.toggleTheme(); - expect(component.animate).toBeFalse(); - expect(component.openPopupAfterNextChange).toBeTrue(); + const applyThemePreferenceSpy = jest.spyOn(themeService, 'applyThemePreference'); - tick(); + component.toggleTheme(); - expectSwitchToDark(); + expect(applyThemePreferenceSpy).toHaveBeenCalledWith(Theme.DARK); - flush(); + expect(component.isDarkTheme()).toBeTrue(); + tick(250); + expect(openSpy).toHaveBeenCalledOnce(); })); it('os sync toggles correctly', fakeAsync(() => { - component.ngOnInit(); - component.toggleSynced(); - - tick(); + const applyThemePreferenceSpy = jest.spyOn(themeService, 'applyThemePreference'); - expect(component.isSynced).toBeFalse(); component.toggleSynced(); - tick(); - - expect(component.isSynced).toBeTrue(); + expect(applyThemePreferenceSpy).toHaveBeenCalledWith(Theme.LIGHT); + expect(component.isSyncedWithOS()).toBeFalse(); + component.toggleSynced(); - flush(); + expect(applyThemePreferenceSpy).toHaveBeenCalledWith(undefined); + expect(component.isSyncedWithOS()).toBeTrue(); })); it('opens and closes the popover', fakeAsync(() => { component.openPopover(); - expect(component.popover.open).toHaveBeenCalledOnce(); + expect(openSpy).toHaveBeenCalledOnce(); component.closePopover(); - expect(component.popover.close).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledOnce(); })); it('closes on mouse leave after 200ms', fakeAsync(() => { component.openPopover(); - expect(component.popover.open).toHaveBeenCalledOnce(); + expect(openSpy).toHaveBeenCalledOnce(); component.mouseLeave(); - expect(component.popover.close).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); tick(250); - expect(component.popover.close).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledOnce(); })); - - function expectSwitchToDark() { - expect(component.isDark).toBeTrue(); - expect(component.animate).toBeTrue(); - expect(component.openPopupAfterNextChange).toBeFalse(); - - tick(250); - - expect(component.popover.open).toHaveBeenCalledOnce(); - } }); diff --git a/src/test/javascript/spec/helpers/mocks/mock-instance.helper.ts b/src/test/javascript/spec/helpers/mocks/mock-instance.helper.ts index 8f7c213e5f6b..b84969f15363 100644 --- a/src/test/javascript/spec/helpers/mocks/mock-instance.helper.ts +++ b/src/test/javascript/spec/helpers/mocks/mock-instance.helper.ts @@ -1,6 +1,7 @@ import { MockInstance } from 'ng-mocks'; import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; import { signal } from '@angular/core'; +import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; /* * This file contains mock instances for the tests where they would otherwise fail due to the use of a signal-based viewChild or contentChild with MockComponent(SomeComponent). @@ -19,3 +20,10 @@ export function mockCodeEditorMonacoViewChildren() { inlineFeedbackSuggestionComponents: signal([]), })); } + +export function mockThemeSwitcherComponentViewChildren() { + MockInstance.scope('case'); + MockInstance(ThemeSwitchComponent, () => ({ + popover: signal({}), + })); +} diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-theme.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-theme.service.ts index 1bb4519a3ff0..a5ff955560b7 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-theme.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-theme.service.ts @@ -1,31 +1,16 @@ +import { signal } from '@angular/core'; import { Theme } from 'app/core/theme/theme.service'; -import { BehaviorSubject, Observable } from 'rxjs'; export class MockThemeService { - private currentTheme: Theme = Theme.LIGHT; - private currentThemeSubject: BehaviorSubject = new BehaviorSubject(Theme.LIGHT); - private preferenceSubject: BehaviorSubject = new BehaviorSubject(undefined); + private _currentTheme = signal(Theme.LIGHT); + public readonly currentTheme = this._currentTheme.asReadonly(); - public isByAutoDetection = false; + private _userPreference = signal(undefined); + public readonly userPreference = this._userPreference.asReadonly(); - public getCurrentTheme(): Theme { - return this.currentTheme; - } - - public getCurrentThemeObservable(): Observable { - return this.currentThemeSubject.asObservable(); - } - - public getPreferenceObservable(): Observable { - return this.preferenceSubject.asObservable(); - } - - public restoreTheme() {} - - public applyThemeExplicitly(theme: Theme) { - this.currentTheme = theme; - this.currentThemeSubject.next(theme); - this.preferenceSubject.next(theme); + public applyThemePreference(preference: Theme | undefined) { + this._userPreference.set(preference); + this._currentTheme.set(preference ?? Theme.LIGHT); } public print() {} diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts index a8ba374dd6b6..2569e345ff4c 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts @@ -3,7 +3,7 @@ import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { TranslateModule } from '@ngx-translate/core'; import { JhiLanguageHelper } from 'app/core/language/language.helper'; import { AccountService } from 'app/core/auth/account.service'; -import { ChangeDetectorRef, DebugElement } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { BehaviorSubject, Subject, of, throwError } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; @@ -122,7 +122,6 @@ describe('CodeEditorInstructorIntegration', () => { ], providers: [ JhiLanguageHelper, - ChangeDetectorRef, { provide: Router, useClass: MockRouter }, { provide: AccountService, useClass: MockAccountService }, { provide: ActivatedRoute, useClass: MockActivatedRouteWithSubjects }, diff --git a/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts b/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts index d439a5cb2347..07bb7bc471b9 100644 --- a/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts +++ b/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts @@ -87,7 +87,8 @@ describe('Guided tour integration', () => { MockComponent(CoursesComponent), MockComponent(SecuredImageComponent), MockComponent(SystemNotificationComponent), - MockComponent(ThemeSwitchComponent), + // Component can not be mocked at the moment https://github.com/help-me-mom/ng-mocks/issues/8634 + ThemeSwitchComponent, MockComponent(DocumentationButtonComponent), MockPipe(ArtemisTranslatePipe), MockPipe(ArtemisDatePipe), diff --git a/src/test/javascript/spec/service/theme.service.spec.ts b/src/test/javascript/spec/service/theme.service.spec.ts index 16441b4c4bd8..3ff8f86499ba 100644 --- a/src/test/javascript/spec/service/theme.service.spec.ts +++ b/src/test/javascript/spec/service/theme.service.spec.ts @@ -10,7 +10,7 @@ describe('ThemeService', () => { let linkElement: HTMLElement; let documentGetElementMock: jest.SpyInstance; let headElement: HTMLElement; - let documentgetElementsByTagNameMock: jest.SpyInstance; + let documentGetElementsByTagNameMock: jest.SpyInstance; let newElement: HTMLLinkElement; let documentCreateElementMock: jest.SpyInstance; let storeSpy: jest.SpyInstance; @@ -36,7 +36,7 @@ describe('ThemeService', () => { getElementsByTagName: jest.fn().mockReturnValue([{}, {}]), insertBefore: jest.fn(), } as any as HTMLElement; - documentgetElementsByTagNameMock = jest.spyOn(document, 'getElementsByTagName').mockReturnValue([headElement] as unknown as HTMLCollectionOf); + documentGetElementsByTagNameMock = jest.spyOn(document, 'getElementsByTagName').mockReturnValue([headElement] as unknown as HTMLCollectionOf); newElement = {} as HTMLLinkElement; documentCreateElementMock = jest.spyOn(document, 'createElement').mockReturnValue(newElement); @@ -61,13 +61,17 @@ describe('ThemeService', () => { }); it('applies theme changes correctly', () => { - service.applyThemeExplicitly(Theme.DARK); - + TestBed.flushEffects(); expect(documentGetElementMock).toHaveBeenCalledOnce(); + + service.applyThemePreference(Theme.DARK); + TestBed.flushEffects(); + + expect(documentGetElementMock).toHaveBeenCalledTimes(2); expect(documentGetElementMock).toHaveBeenCalledWith(THEME_OVERRIDE_ID); - expect(documentgetElementsByTagNameMock).toHaveBeenCalledOnce(); - expect(documentgetElementsByTagNameMock).toHaveBeenCalledWith('head'); + expect(documentGetElementsByTagNameMock).toHaveBeenCalledOnce(); + expect(documentGetElementsByTagNameMock).toHaveBeenCalledWith('head'); expect(documentCreateElementMock).toHaveBeenCalledOnce(); expect(documentCreateElementMock).toHaveBeenCalledWith('link'); @@ -80,30 +84,29 @@ describe('ThemeService', () => { expect(headElement.insertBefore).toHaveBeenCalledOnce(); expect(headElement.insertBefore).toHaveBeenCalledWith(newElement, undefined); - expect(service.getCurrentTheme()).toBe(Theme.LIGHT); + expect(service.currentTheme()).toBe(Theme.DARK); expect(storeSpy).toHaveBeenCalledWith(THEME_LOCAL_STORAGE_KEY, 'DARK'); - // @ts-ignore - newElement.onload(); - expect(linkElement.remove).toHaveBeenCalledOnce(); - expect(service.getCurrentTheme()).toBe(Theme.DARK); + expect(service.currentTheme()).toBe(Theme.DARK); - service.applyThemeExplicitly(Theme.LIGHT); + service.applyThemePreference(Theme.LIGHT); + TestBed.flushEffects(); - expect(documentGetElementMock).toHaveBeenCalledTimes(2); - expect(documentGetElementMock).toHaveBeenNthCalledWith(2, THEME_OVERRIDE_ID); + expect(documentGetElementMock).toHaveBeenCalledTimes(3); + expect(documentGetElementMock).toHaveBeenNthCalledWith(3, THEME_OVERRIDE_ID); expect(linkElement.remove).toHaveBeenCalledTimes(2); - expect(service.getCurrentTheme()).toBe(Theme.LIGHT); + expect(service.currentTheme()).toBe(Theme.LIGHT); }); it('restores stored theme correctly', () => { const retrieveSpy = jest.spyOn(localStorageService, 'retrieve').mockReturnValue('LIGHT'); service.initialize(); + TestBed.flushEffects(); - expect(retrieveSpy).toHaveBeenCalledTimes(2); - expect(service.getCurrentTheme()).toBe(Theme.LIGHT); + expect(retrieveSpy).toHaveBeenCalledOnce(); + expect(service.currentTheme()).toBe(Theme.LIGHT); }); it('applies dark OS preferences', () => { @@ -120,24 +123,26 @@ describe('ThemeService', () => { }); service.initialize(); + TestBed.flushEffects(); // @ts-ignore newElement?.onload(); - expect(retrieveSpy).toHaveBeenCalledTimes(2); + expect(retrieveSpy).toHaveBeenCalledOnce(); expect(windowMatchMediaSpy).toHaveBeenCalledOnce(); expect(windowMatchMediaSpy).toHaveBeenNthCalledWith(1, '(prefers-color-scheme: dark)'); - expect(service.getCurrentTheme()).toBe(Theme.DARK); + expect(service.currentTheme()).toBe(Theme.DARK); }); it('applies light OS preferences', () => { const retrieveSpy = jest.spyOn(localStorageService, 'retrieve').mockReturnValue(undefined); service.initialize(); + TestBed.flushEffects(); - expect(retrieveSpy).toHaveBeenCalledTimes(2); + expect(retrieveSpy).toHaveBeenCalledOnce(); expect(windowMatchMediaSpy).toHaveBeenCalledOnce(); expect(windowMatchMediaSpy).toHaveBeenNthCalledWith(1, '(prefers-color-scheme: dark)'); - expect(service.getCurrentTheme()).toBe(Theme.LIGHT); + expect(service.currentTheme()).toBe(Theme.LIGHT); }); it('does print correctly', fakeAsync(() => { @@ -149,12 +154,12 @@ describe('ThemeService', () => { service.print(); - expect(docSpy).toHaveBeenCalledOnce(); + expect(docSpy).toHaveBeenCalledTimes(2); expect(docSpy).toHaveBeenCalledWith(THEME_OVERRIDE_ID); expect(returnedElement.rel).toBe('none-tmp'); tick(250); expect(docSpy).toHaveBeenCalledWith('notification-sidebar'); - expect(docSpy).toHaveBeenCalledTimes(3); // 1x for theme override, 2x for notification sidebar (changing style to display: none and back to initial value) + expect(docSpy).toHaveBeenCalledTimes(4); // 1x for theme override, 2x for notification sidebar (changing style to display: none and back to initial value) expect(winSpy).toHaveBeenCalledOnce(); tick(250); expect(returnedElement.rel).toBe('stylesheet');