mirror of https://github.com/OpenVidu/openvidu.git
ov-components: Refactor ActionService and related tests to improve dialog handling and mock implementations
parent
0f075008a4
commit
bea3b8e70a
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
export class TranslateServiceMock {
|
||||||
|
instant(key: string): string {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
get(key: string) {
|
||||||
|
return of(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue