ov-components: Refactor ActionService and related tests to improve dialog handling and mock implementations

pull/856/head
Carlos Santos 2025-11-14 12:02:42 +01:00
parent 0f075008a4
commit bea3b8e70a
6 changed files with 197 additions and 116 deletions

View File

@ -3,7 +3,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActionService } from '../../services/action/action.service'; import { ActionService } from '../../services/action/action.service';
import { ActionServiceMock } from '../../services/action/action.service.mock'; import { ActionServiceMock } from '../../../test-helpers/action.service.mock';
import { ChatService } from '../../services/chat/chat.service'; import { ChatService } from '../../services/chat/chat.service';
import { ChatServiceMock } from '../../services/chat/chat.service.mock'; import { ChatServiceMock } from '../../services/chat/chat.service.mock';

View File

@ -1,18 +0,0 @@
import { Injectable } from '@angular/core';
import { INotificationOptions } from '../../models/notification-options.model';
@Injectable()
export class ActionServiceMock {
constructor() {}
launchNotification(options: INotificationOptions, callback): void {
}
openConnectionDialog(titleMessage: string, descriptionMessage: string, allowClose = true) {
}
closeConnectionDialog() {
}
}

View File

@ -1,81 +1,128 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { ActionService } from './action.service'; import { ActionService } from './action.service';
import { TranslateService } from '../translate/translate.service'; import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateServiceMock } from '../translate/translate.service.mock'; import { MatDialogMock } from '../../../test-helpers/action.service.mock';
import { TranslateServiceMock } from '../../../test-helpers/translate.service.mock';
export class MatDialogMock { describe('ActionService (characterization)', () => {
open() {
return { close: () => {} } as MatDialogRef<any>;
}
}
describe('ActionService', () => {
let service: ActionService; let service: ActionService;
let dialog: MatDialog; let dialog: MatDialogMock;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [MatSnackBarModule], imports: [MatSnackBarModule],
providers: [ providers: [
{ provide: MatDialog, useClass: MatDialogMock }, { provide: MatDialog, useClass: MatDialogMock },
{ provide: TranslateService, useClass: TranslateServiceMock }, { provide: 'TranslateService', useClass: TranslateServiceMock },
{ provide: 'OPENVIDU_COMPONENTS_CONFIG', useValue: { production: false } } { provide: 'OPENVIDU_COMPONENTS_CONFIG', useValue: { production: false } }
] ]
}); });
service = TestBed.inject(ActionService); service = TestBed.inject(ActionService);
dialog = TestBed.inject(MatDialog); dialog = TestBed.inject(MatDialog) as unknown as MatDialogMock;
}); });
it('should be created', () => { it('opens a connection dialog when requested', () => {
expect(service).toBeTruthy(); const spy = spyOn(dialog, 'open').and.callThrough();
service.openConnectionDialog('Title', 'Description', false);
expect(spy).toHaveBeenCalledTimes(1);
// observable behavior: a MatDialogRef was created (do not assert internal state)
expect(dialog.lastRef).toBeTruthy();
expect(typeof dialog.lastRef!.close).toBe('function');
}); });
it('should open connection dialog', fakeAsync(() => { it('does not open a new dialog if one is already open (repeated calls)', () => {
const dialogSpy = spyOn(dialog, 'open').and.callThrough(); const spy = spyOn(dialog, 'open').and.callThrough();
service.openConnectionDialog('Test Title', 'Test Description', false);
expect(dialogSpy).toHaveBeenCalled();
expect(service['isConnectionDialogOpen']).toBeTrue();
}));
it('should not open connection dialog if one is already open', () => { service.openConnectionDialog('Title', 'Description', false);
service['isConnectionDialogOpen'] = true; // repeated calls simulate concurrent/repeated user attempts
const dialogSpy = spyOn(dialog, 'open').and.callThrough(); service.openConnectionDialog('Title', 'Description', false);
service.openConnectionDialog('Test Title', 'Test Description', false); service.openConnectionDialog('Title', 'Description', false);
expect(dialogSpy).not.toHaveBeenCalled(); // observed behavior: open called only once
expect(spy).toHaveBeenCalledTimes(1);
}); });
it('should close connection dialog and reset state', fakeAsync(() => { it('closes the opened dialog when requested and allows opening a new one afterwards', fakeAsync(() => {
service.openConnectionDialog('Test Title', 'Test Description', false); const openSpy = spyOn(dialog, 'open').and.callThrough();
tick(2000); service.openConnectionDialog('T', 'D', false);
tick(10); // advance microtasks if the service uses timers/async internally
expect(service['isConnectionDialogOpen']).toBeTrue(); // Behavior: closing should invoke close() on the MatDialogRef
const ref = dialog.lastRef!;
expect(ref).toBeTruthy();
expect(ref.close).not.toHaveBeenCalled();
service.closeConnectionDialog(); service.closeConnectionDialog();
expect(ref.close).toHaveBeenCalledTimes(1);
expect(service['isConnectionDialogOpen']).toBeFalse(); // After closing, opening again should create another instance (another open call)
service.openConnectionDialog('T', 'D', false);
expect(openSpy).toHaveBeenCalledTimes(2);
})); }));
it('should open connection dialog only once', fakeAsync(() => { it('handles rapid consecutive calls by creating a single dialog (reentrancy protection)', fakeAsync(() => {
// Spy on the dialog open method const spy = spyOn(dialog, 'open').and.callThrough();
const dialogSpy = spyOn(dialog, 'open').and.callThrough();
service.openConnectionDialog('Test Title', 'Test Description', false); // several almost-simultaneous calls
// Verify that the dialog has been called only once service.openConnectionDialog('T', 'D', false);
expect(dialogSpy).toHaveBeenCalledTimes(1); service.openConnectionDialog('T', 'D', false);
expect(service['isConnectionDialogOpen']).toBeTrue(); tick(0);
service.openConnectionDialog('T', 'D', false);
tick(0);
// Try to open the dialog again expect(spy).toHaveBeenCalledTimes(1);
service.openConnectionDialog('Test Title', 'Test Description', false); }));
service.openConnectionDialog('Test Title', 'Test Description', false);
service.openConnectionDialog('Test Title', 'Test Description', false);
// Verify that the dialog has been called only once it('launchNotification uses snackbar and triggers callback on action', fakeAsync(() => {
expect(dialogSpy).toHaveBeenCalledTimes(1); const snackBar = TestBed.inject(
(window as any).ng && (window as any).ng.material
? (window as any).ng.material.MatSnackBar
: (require('@angular/material/snack-bar') as any).MatSnackBar
) as any;
// Fallback: inject via TestBed
const snack = TestBed.inject(MatSnackBar);
const openSpy = spyOn(snack, 'open').and.returnValue({ onAction: () => of(null).pipe(delay(0)) } as any);
const callback = jasmine.createSpy('callback');
service.launchNotification({ message: 'hello', buttonActionText: 'OK' }, callback);
// allow the deferred observable to emit
tick();
expect(openSpy).toHaveBeenCalled();
expect(callback).toHaveBeenCalled();
}));
it('openDeleteRecordingDialog calls success callback when dialog closes with true', fakeAsync(() => {
const success = jasmine.createSpy('success');
service.openDeleteRecordingDialog(success);
// MatDialogRefMock.afterClosed returns of(true) so the subscription should call the callback
tick();
expect(success).toHaveBeenCalledTimes(1);
}));
it('openRecordingPlayerDialog triggers error handler when dialog returns manageError', fakeAsync(() => {
// Arrange: make dialog.open return a ref that afterClosed emits an object with manageError:true
const returnRef = {
afterClosed: () => ({ subscribe: (fn: any) => fn({ manageError: true, error: { code: 1 } }) }),
close: jasmine.createSpy('close')
} as any;
const openSpy = spyOn(dialog, 'open').and.returnValue(returnRef);
const handleSpy = spyOn<any>(service as any, 'handleRecordingPlayerError').and.callThrough();
// Act
service.openRecordingPlayerDialog('someSrc', true);
tick();
// Assert
expect(openSpy).toHaveBeenCalled();
expect(handleSpy).toHaveBeenCalled();
})); }));
}); });

View File

@ -19,9 +19,8 @@ export class ActionService {
private dialogRef: private dialogRef:
| MatDialogRef<DialogTemplateComponent | RecordingDialogComponent | DeleteDialogComponent | ProFeatureDialogTemplateComponent> | MatDialogRef<DialogTemplateComponent | RecordingDialogComponent | DeleteDialogComponent | ProFeatureDialogTemplateComponent>
| undefined; | undefined;
private dialogSubscription: Subscription;
private connectionDialogRef: MatDialogRef<DialogTemplateComponent> | undefined; private connectionDialogRef: MatDialogRef<DialogTemplateComponent> | undefined;
private isConnectionDialogOpen: boolean = false; private isConnectionDialogOpen = false;
constructor( constructor(
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
@ -29,7 +28,7 @@ export class ActionService {
private translateService: TranslateService private translateService: TranslateService
) {} ) {}
launchNotification(options: INotificationOptions, callback): void { launchNotification(options: INotificationOptions, callback?: () => void): void {
if (!options.config) { if (!options.config) {
options.config = { options.config = {
duration: 3000, duration: 3000,
@ -41,28 +40,23 @@ export class ActionService {
const notification = this.snackBar.open(options.message, options.buttonActionText, options.config); const notification = this.snackBar.open(options.message, options.buttonActionText, options.config);
if (callback) { if (callback) {
notification.onAction().subscribe(() => { // subscribe and complete immediately after calling callback
const sub = notification.onAction().subscribe(() => {
sub.unsubscribe();
callback(); callback();
}); });
} }
} }
openDialog(titleMessage: string, descriptionMessage: string, allowClose = true) { openDialog(titleMessage: string, descriptionMessage: string, allowClose = true) {
try { this.closeDialog();
this.closeDialog(); const config: MatDialogConfig = {
} catch (error) { minWidth: '250px',
} finally { data: { title: titleMessage, description: descriptionMessage, showActionButtons: allowClose },
const config: MatDialogConfig = { disableClose: !allowClose
minWidth: '250px', };
data: { title: titleMessage, description: descriptionMessage, showActionButtons: allowClose }, this.dialogRef = this.dialog.open(DialogTemplateComponent, config);
disableClose: !allowClose this.dialogRef.afterClosed().subscribe(() => (this.dialogRef = undefined));
};
this.dialogRef = this.dialog.open(DialogTemplateComponent, config);
this.dialogSubscription = this.dialogRef.afterClosed().subscribe((result) => {
this.dialogRef = undefined;
if (this.dialogSubscription) this.dialogSubscription.unsubscribe();
});
}
} }
openConnectionDialog(titleMessage: string, descriptionMessage: string, allowClose = false) { openConnectionDialog(titleMessage: string, descriptionMessage: string, allowClose = false) {
@ -75,47 +69,44 @@ export class ActionService {
this.connectionDialogRef = this.dialog.open(DialogTemplateComponent, config); this.connectionDialogRef = this.dialog.open(DialogTemplateComponent, config);
this.isConnectionDialogOpen = true; this.isConnectionDialogOpen = true;
this.connectionDialogRef.afterClosed().subscribe(() => {
this.isConnectionDialogOpen = false;
this.connectionDialogRef = undefined;
});
} }
openDeleteRecordingDialog(succsessCallback) { openDeleteRecordingDialog(successCallback: () => void) {
try { this.closeDialog();
this.closeDialog(); this.dialogRef = this.dialog.open(DeleteDialogComponent);
} catch (error) { this.dialogRef.afterClosed().subscribe((result) => {
} finally { if (result) {
this.dialogRef = this.dialog.open(DeleteDialogComponent); successCallback();
}
this.dialogSubscription = this.dialogRef.afterClosed().subscribe((result) => { this.dialogRef = undefined;
if (result) { });
succsessCallback();
if (this.dialogSubscription) this.dialogSubscription.unsubscribe();
}
});
}
} }
openRecordingPlayerDialog(src: string, allowClose = true) { openRecordingPlayerDialog(src: string, allowClose = true) {
try { this.closeDialog();
this.closeDialog(); const config: MatDialogConfig = {
} catch (error) { minWidth: '250px',
} finally { data: { src, showActionButtons: allowClose },
const config: MatDialogConfig = { disableClose: !allowClose
minWidth: '250px', };
data: { src, showActionButtons: allowClose }, this.dialogRef = this.dialog.open(RecordingDialogComponent, config);
disableClose: !allowClose this.dialogRef.afterClosed().subscribe((data: { manageError: boolean; error: MediaError | null }) => {
}; if (data && data.manageError) {
this.dialogRef = this.dialog.open(RecordingDialogComponent, config); this.handleRecordingPlayerError(data.error);
}
this.dialogSubscription = this.dialogRef.afterClosed().subscribe((data: { manageError: boolean; error: MediaError | null }) => { this.dialogRef = undefined;
if (data.manageError) { });
this.handleRecordingPlayerError(data.error);
}
if (this.dialogSubscription) this.dialogSubscription.unsubscribe();
});
}
} }
closeDialog() { closeDialog() {
this.dialogRef?.close(); if (this.dialogRef) {
this.dialogRef.close();
this.dialogRef = undefined;
}
} }
closeConnectionDialog() { closeConnectionDialog() {

View File

@ -0,0 +1,51 @@
import { MatDialogRef } from '@angular/material/dialog';
import { Subject, of } from 'rxjs';
export class ActionServiceMock {
openConnectionDialog(title?: string, description?: string, allowClose?: boolean): void {}
closeConnectionDialog(): void {}
openDialog(title?: string, description?: string, allowClose?: boolean): void {}
openDeleteRecordingDialog(callback?: () => void): void {
if (callback) callback();
}
openRecordingPlayerDialog(src?: string, allowClose?: boolean): void {}
launchNotification(options?: any, callback?: () => void): void {
if (callback) callback();
}
}
export class MatDialogRefMock {
private closed$ = new Subject<boolean>();
// expose a jasmine spy for close so tests can assert it was called
close = jasmine.createSpy('close').and.callFake(() => {
// when close is called, emit and complete the closed observable
this.closed$.next(true);
this.closed$.complete();
});
afterClosed() {
// return an observable that only emits when close() is called
return this.closed$.asObservable();
}
}
export class MatDialogMock {
opens = 0;
lastRef: MatDialogRefMock | null = null;
open(component?: any) {
this.opens++;
// If the consumer opens the DeleteDialogComponent, return a ref that emits immediately
// (some tests expect afterClosed to already have emitted for confirm/delete dialogs)
if (component && component.name === 'DeleteDialogComponent') {
const immediateRef: any = {
close: jasmine.createSpy('close'),
afterClosed: () => of(true)
};
this.lastRef = immediateRef as unknown as MatDialogRefMock;
return immediateRef as unknown as MatDialogRef<any>;
}
this.lastRef = new MatDialogRefMock();
return this.lastRef as unknown as MatDialogRef<any>;
}
}

View File

@ -0,0 +1,10 @@
import { of } from 'rxjs';
export class TranslateServiceMock {
instant(key: string): string {
return key;
}
get(key: string) {
return of(key);
}
}