diff --git a/package-lock.json b/package-lock.json index c42ababae..4f00791e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "@angular-eslint/eslint-plugin-template": "~19.3.0", "@angular-eslint/schematics": "~19.3.0", "@angular-eslint/template-parser": "~19.3.0", - "@angular/cli": "^19.0.0", + "@angular/cli": "^19.2.23", "@angular/compiler": "^19.0.0", "@angular/compiler-cli": "^19.0.0", "@angular/language-service": "^19.0.0", diff --git a/package.json b/package.json index 35bc57d9b..d78d7a513 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@angular-eslint/eslint-plugin-template": "~19.3.0", "@angular-eslint/schematics": "~19.3.0", "@angular-eslint/template-parser": "~19.3.0", - "@angular/cli": "^19.0.0", + "@angular/cli": "^19.2.23", "@angular/compiler": "^19.0.0", "@angular/compiler-cli": "^19.0.0", "@angular/language-service": "^19.0.0", diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.html b/projects/v3/src/app/components/file-upload/file-upload.component.html index 0da0420ae..d72a8f48c 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.html +++ b/projects/v3/src/app/components/file-upload/file-upload.component.html @@ -52,12 +52,17 @@
- -

Compressing video... {{ compressionProgress }}%

+
+ +
+ +

Compressing video...

+

Please wait, this may take a moment

+

{{ compressionProgress }}%

- +
@@ -80,12 +85,17 @@
- -

Compressing video... {{ compressionProgress }}%

+
+ +
+ +

Compressing video...

+

Please wait, this may take a moment

+

{{ compressionProgress }}%

- +
diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.scss b/projects/v3/src/app/components/file-upload/file-upload.component.scss index 61e8583e7..7e3b3528d 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.scss +++ b/projects/v3/src/app/components/file-upload/file-upload.component.scss @@ -12,35 +12,57 @@ } .compression-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 100; + position: relative; + min-height: 200px; + z-index: 1; display: flex; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.95); border-radius: 8px; + border: 1px dashed var(--ion-color-medium, #ccc); .compression-status { text-align: center; - padding: 24px; + padding: 32px; + width: 100%; + max-width: 320px; + + .compression-icon { + margin-bottom: 16px; + + ion-icon { + font-size: 48px; + opacity: 0.6; + } + } ion-spinner { - width: 32px; - height: 32px; - margin-bottom: 12px; + width: 44px; + height: 44px; + margin-bottom: 16px; + } + + h3 { + margin: 0 0 4px; + font-weight: 600; } p { - margin-bottom: 12px; + margin: 0 0 20px; } ion-progress-bar { - width: 200px; + width: 100%; margin: 0 auto; + border-radius: 4px; + --background: var(--ion-color-light, #f0f0f0); + } + + .percentage { + margin: 8px 0 0; + font-weight: 500; + color: var(--ion-color-primary); } } } diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts b/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts index 165263004..729ca6e26 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts +++ b/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts @@ -1,534 +1,157 @@ -import { FormControl } from '@angular/forms'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { Subject } from 'rxjs'; -import { UppyUploaderService } from '../uppy-uploader/uppy-uploader.service'; +import { Uppy } from '@uppy/core'; import { FileUploadComponent } from './file-upload.component'; +import { UppyUploaderService } from '../uppy-uploader/uppy-uploader.service'; +import { CompressionProgress } from '../../services/ffmpeg.service'; describe('FileUploadComponent', () => { let component: FileUploadComponent; - let uppyUploaderService: jasmine.SpyObj; + let fixture: ComponentFixture; + let uppyServiceSpy: jasmine.SpyObj; + let cdrSpy: jasmine.SpyObj; + let compressionProgress$: Subject<{ uppy: Uppy; progress: CompressionProgress | null }>; + let mockUppy: any; + + beforeEach(async () => { + compressionProgress$ = new Subject(); + mockUppy = { + on: jasmine.createSpy('on').and.returnValue({ on: jasmine.createSpy('chainOn').and.callFake(function() { return this; }) }), + use: jasmine.createSpy('use').and.returnValue(mockUppy), + destroy: jasmine.createSpy('destroy'), + removeFile: jasmine.createSpy('removeFile'), + clear: jasmine.createSpy('clear'), + resetProgress: jasmine.createSpy('resetProgress'), + addPreProcessor: jasmine.createSpy('addPreProcessor'), + getFile: jasmine.createSpy('getFile'), + }; - beforeEach(() => { - uppyUploaderService = jasmine.createSpyObj('UppyUploaderService', ['createUppyInstance']); - component = new FileUploadComponent(uppyUploaderService); - component.control = new FormControl(''); + // make .on() chainable properly + mockUppy.on.and.returnValue(mockUppy); + + uppyServiceSpy = jasmine.createSpyObj('UppyUploaderService', ['createUppyInstance'], { + compressionProgress$, + uppyProps: { + inline: true, + width: '100%', + height: '200px', + singleFileFullScreen: true, + note: 'Upload files here', + proudlyDisplayPoweredByUppy: false, + hideRetryButton: false, + hidePauseResumeButton: false, + hideCancelButton: false, + showRemoveButtonAfterComplete: true, + hideProgressAfterFinish: false, + }, + }); + uppyServiceSpy.createUppyInstance.and.returnValue(mockUppy); + + await TestBed.configureTestingModule({ + declarations: [FileUploadComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { provide: UppyUploaderService, useValue: uppyServiceSpy }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FileUploadComponent); + component = fixture.componentInstance; + component.source = 'assessment'; component.submitActions$ = new Subject(); - component.question = { - id: 11, - name: 'Test Question', - description: '', - isRequired: false, - fileType: 'any', - audience: ['participant'], - canAnswer: true, - canComment: true, - } as any; - component.submissionId = 123; - component.reviewId = 456; - component.review = { answer: null, comment: 'old comment', file: {} }; - component.submission = { answer: null }; - component.uppy = jasmine.createSpyObj('Uppy', ['removeFile', 'clear', 'destroy']) as any; + cdrSpy = spyOn(component['cdr'], 'markForCheck'); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); - it('should return video note message for video fileType', () => { - component.question.fileType = 'video'; - - expect(component.noteMessage()).toContain('Videos only'); - }); - - it('should return image note message for image fileType', () => { - component.question.fileType = 'image'; - - expect(component.noteMessage()).toContain('Images only'); - }); - - it('should return default note message for any fileType', () => { - component.question.fileType = 'any'; - - expect(component.noteMessage()).toContain('Docs, images and videos only'); - }); - - it('should parse tus response body in onAfterResponse', () => { - const response = { - getBody: () => JSON.stringify({ path: '/uploads/a', bucket: 'b', cdnUrl: 'c', directUrl: 'd' }) - }; - - component.onAfterResponse({}, response); - - expect(component.tusResponse).toEqual({ path: '/uploads/a', bucket: 'b', cdnUrl: 'c', directUrl: 'd' }); + it('should have isCompressing false initially', () => { + expect(component.isCompressing).toBeFalse(); }); - it('should return empty object from fileRequestFormat when uploadedFile is empty', () => { - component.uploadedFile = null as any; - - expect(component.fileRequestFormat()).toEqual({} as any); + it('should have compressionProgress 0 initially', () => { + expect(component.compressionProgress).toBe(0); }); - it('should map uploadedFile in fileRequestFormat', () => { - component.uploadedFile = { - name: 'a.png', - type: 'image/png', - size: 10, - extension: 'png', - bucket: 'bucket', - path: '/uploads/a', - cdnUrl: 'https://cdn/a.png', - } as any; - - expect(component.fileRequestFormat()).toEqual({ - name: 'a.png', - type: 'image/png', - size: 10, - extension: 'png', - bucket: 'bucket', - path: '/uploads/a', - url: 'https://cdn/a.png', + describe('compression progress subscription', () => { + beforeEach(() => { + fixture.detectChanges(); // triggers ngOnInit }); - }); - - it('should set review answer and trigger save in onChange with type', () => { - component.doReview = true; - component.uploadedFile = { - name: 'a.pdf', - type: 'application/pdf', - size: 10, - extension: 'pdf', - bucket: 'bucket', - path: '/uploads/a', - cdnUrl: 'https://cdn/a.pdf', - } as any; - spyOn(component, 'triggerSave'); - - component.onChange('new comment', 'comment'); - - expect(component.innerValue.comment).toBe('new comment'); - expect(component.innerValue.file.url).toBe('https://cdn/a.pdf'); - expect(component.triggerSave).toHaveBeenCalled(); - }); - - it('should set assessment value and trigger save in onChange without type', () => { - component.doAssessment = true; - component.uploadedFile = { - name: 'a.pdf', - type: 'application/pdf', - size: 10, - extension: 'pdf', - bucket: 'bucket', - path: '/uploads/a', - cdnUrl: 'https://cdn/a.pdf', - } as any; - spyOn(component, 'triggerSave'); - - component.onChange(''); - - expect(component.innerValue.url).toBe('https://cdn/a.pdf'); - expect(component.triggerSave).toHaveBeenCalled(); - }); - - it('should create and emit review save action in triggerSave', () => { - component.doReview = true; - component.innerValue = { - file: { path: '/uploads/a' }, - comment: 'review comment' - }; - const submitNextSpy = spyOn(component.submitActions$, 'next'); - - component.triggerSave(); - expect(submitNextSpy).toHaveBeenCalled(); - expect((submitNextSpy.calls.mostRecent().args[0] as any).reviewSave).toEqual({ - reviewId: 456, - submissionId: 123, - questionId: 11, - file: { path: '/uploads/a' }, - comment: 'review comment', + it('should set isCompressing to true when progress is emitted for own uppy', () => { + compressionProgress$.next({ uppy: mockUppy, progress: { progress: 0.5, timeUs: 1000 } }); + expect(component.isCompressing).toBeTrue(); }); - }); - - it('should create and emit assessment save action in triggerSave', () => { - component.doAssessment = true; - component.innerValue = { path: '/uploads/a' }; - const submitNextSpy = spyOn(component.submitActions$, 'next'); - component.triggerSave(); - - expect(submitNextSpy).toHaveBeenCalled(); - expect((submitNextSpy.calls.mostRecent().args[0] as any).questionSave).toEqual({ - submissionId: 123, - questionId: 11, - file: { path: '/uploads/a' }, + it('should update compressionProgress percentage', () => { + compressionProgress$.next({ uppy: mockUppy, progress: { progress: 0.75, timeUs: 2000 } }); + expect(component.compressionProgress).toBe(75); }); - }); - - it('should add error when upload response status is not 200', () => { - component.tusResponse = { - bucket: 'bucket', - path: '/uploads/a', - cdnUrl: 'https://cdn/a', - directUrl: 'https://direct/a', - }; - component.doReview = true; - spyOn(component, 'onChange'); - component.onFileUploadCompleted({ - name: 'a.pdf', - type: 'application/pdf', - size: 10, - extension: 'pdf' - } as any, { - body: {} as XMLHttpRequest, - status: 500, - uploadURL: '' + it('should call markForCheck when progress updates', () => { + compressionProgress$.next({ uppy: mockUppy, progress: { progress: 0.3, timeUs: 500 } }); + expect(cdrSpy).toHaveBeenCalled(); }); - expect(component.uploadedFile.name).toBe('a.pdf'); - expect(component.errors.length).toBe(1); - expect(component.onChange).toHaveBeenCalledWith('', 'answer'); - }); - - it('should remove submission answer and clear uppy in removeSubmitFile for assessment', () => { - component.doAssessment = true; - component.submission = { answer: { path: '/uploads/a' } }; - spyOn(component, 'onChange'); - - component.removeSubmitFile({ handle: 'file-1' }); - - expect(component.submission.answer).toBeNull(); - expect(component.onChange).toHaveBeenCalledWith(''); - expect((component.uppy.removeFile as any)).toHaveBeenCalledWith('file-1'); - expect((component.uppy.clear as any)).toHaveBeenCalled(); - }); - - it('should remove review answer and clear uppy in removeSubmitFile for review', () => { - component.doReview = true; - component.review = { answer: { path: '/uploads/a' } } as any; - spyOn(component, 'onChange'); + it('should set isCompressing to false when progress is null (done)', () => { + compressionProgress$.next({ uppy: mockUppy, progress: { progress: 1.0, timeUs: 3000 } }); + expect(component.isCompressing).toBeTrue(); - component.removeSubmitFile({ handle: 'file-2' }); - - expect(component.review.answer).toBeNull(); - expect(component.onChange).toHaveBeenCalledWith('', 'answer'); - expect((component.uppy.removeFile as any)).toHaveBeenCalledWith('file-2'); - expect((component.uppy.clear as any)).toHaveBeenCalled(); - }); - - it('should return true when audience contains reviewer and has more than one role', () => { - component.question.audience = ['participant', 'reviewer']; - - expect(component.audienceContainReviewer()).toBeTrue(); - }); - - it('should return false when reviewer is not in audience', () => { - component.question.audience = ['participant']; - - expect(component.audienceContainReviewer()).toBeFalse(); - }); - - it('should extract filename from upload URL', () => { - const filename = component.extractFilenameFromUrl('https://file.practera.com/uploads/test-file+abc123'); - - expect(filename).toBe('test-file'); - }); - - it('should return null when filename pattern does not match', () => { - const filename = component.extractFilenameFromUrl('https://example.com/other-path/test-file'); - - expect(filename).toBeNull(); - }); - - it('should call uppy.removeFile in sendDeleteRequestForFile', () => { - component.sendDeleteRequestForFile({ id: 'id-1' }); - - expect((component.uppy.removeFile as any)).toHaveBeenCalledWith('id-1'); - }); - - it('should destroy uppy in ngOnDestroy', () => { - component.ngOnDestroy(); - - expect((component.uppy.destroy as any)).toHaveBeenCalled(); - }); - - describe('onChange() - markAsDirty behavior', () => { - it('should mark control as dirty in review mode (with type)', () => { - component.doReview = true; - component.uploadedFile = { - name: 'test.pdf', - type: 'application/pdf', - size: 100, - extension: 'pdf', - bucket: 'bucket', - path: '/uploads/test', - cdnUrl: 'https://cdn/test.pdf', - } as any; - spyOn(component, 'triggerSave'); - - component.onChange('review comment', 'comment'); - - expect(component.control.dirty).toBeTrue(); - expect(component.control.touched).toBeTrue(); + compressionProgress$.next({ uppy: mockUppy, progress: null }); + expect(component.isCompressing).toBeFalse(); + expect(component.compressionProgress).toBe(0); }); - it('should mark control as dirty in assessment mode (without type)', () => { - component.doAssessment = true; - component.uploadedFile = { - name: 'test.pdf', - type: 'application/pdf', - size: 100, - extension: 'pdf', - bucket: 'bucket', - path: '/uploads/test', - cdnUrl: 'https://cdn/test.pdf', - } as any; - spyOn(component, 'triggerSave'); - - component.onChange(''); - - expect(component.control.dirty).toBeTrue(); - expect(component.control.touched).toBeTrue(); - }); - - it('should set innerValue with file and type property in review mode', () => { - component.doReview = true; - component.uploadedFile = { - name: 'doc.pdf', - type: 'application/pdf', - size: 50, - extension: 'pdf', - bucket: 'b', - path: '/uploads/doc', - cdnUrl: 'https://cdn/doc.pdf', - } as any; - spyOn(component, 'triggerSave'); - - component.onChange('new answer', 'answer'); - - expect(component.innerValue.answer).toBe('new answer'); - expect(component.innerValue.file).toBeDefined(); - expect(component.innerValue.file.url).toBe('https://cdn/doc.pdf'); + it('should ignore progress from a different uppy instance', () => { + const otherUppy = {} as Uppy; + compressionProgress$.next({ uppy: otherUppy, progress: { progress: 0.9, timeUs: 5000 } }); + expect(component.isCompressing).toBeFalse(); + expect(component.compressionProgress).toBe(0); }); - it('should initialize innerValue if not set in review mode', () => { - component.doReview = true; - component.innerValue = null; - component.uploadedFile = { - name: 'a.pdf', - type: 'application/pdf', - size: 10, - extension: 'pdf', - bucket: 'b', - path: '/uploads/a', - cdnUrl: 'https://cdn/a.pdf', - } as any; - spyOn(component, 'triggerSave'); - - component.onChange('comment text', 'comment'); - - expect(component.innerValue.comment).toBe('comment text'); - expect(component.innerValue.file).toBeDefined(); + it('should not call markForCheck for a different uppy instance', () => { + const otherUppy = {} as Uppy; + compressionProgress$.next({ uppy: otherUppy, progress: { progress: 0.5, timeUs: 1000 } }); + expect(cdrSpy).not.toHaveBeenCalled(); }); }); - describe('_showSavedAnswers() - pristine check and uploadedFile restoration', () => { - describe('review mode', () => { - beforeEach(() => { - component.reviewStatus = 'in progress'; - component.doReview = true; - component.review = { - answer: { url: 'https://cdn/saved.pdf', name: 'saved.pdf' }, - comment: 'saved comment', - file: { url: 'https://cdn/saved.pdf', name: 'saved.pdf', path: '/uploads/saved' }, - }; - }); - - it('should use saved review data when control is pristine', () => { - component.control = new FormControl(''); - - component['_showSavedAnswers'](); - - expect(component.innerValue).toEqual({ - answer: component.review.answer, - comment: component.review.comment, - file: component.review.file, - }); - expect(component.comment).toBe('saved comment'); - }); - - it('should preserve control value when control is dirty', () => { - const dirtyValue = { - answer: 'user edited', - comment: 'user comment', - file: { url: 'https://cdn/edited.pdf', name: 'edited.pdf', path: '/uploads/edited' }, - }; - component.control = new FormControl(dirtyValue); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); - - expect(component.innerValue).toEqual(dirtyValue); - expect(component.comment).toBe('user comment'); - }); - - it('should restore uploadedFile from file.url when control is dirty with file data', () => { - const dirtyValue = { - answer: 'edited', - comment: 'edited comment', - file: { url: 'https://cdn/dirty-file.pdf', name: 'dirty-file.pdf', path: '/uploads/dirty' }, - }; - component.control = new FormControl(dirtyValue); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); - - expect(component.uploadedFile).toBeDefined(); - expect(component.uploadedFile.cdnUrl).toBe('https://cdn/dirty-file.pdf'); - }); - - it('should not set uploadedFile when dirty control has no file url', () => { - const dirtyValue = { - answer: 'edited', - comment: 'edited comment', - file: null, - }; - component.control = new FormControl(dirtyValue); - component.control.markAsDirty(); - component.uploadedFile = null as any; - - component['_showSavedAnswers'](); + describe('ngOnDestroy', () => { + it('should unsubscribe from compression progress', () => { + fixture.detectChanges(); + const sub = component['compressionSub']; + expect(sub).toBeTruthy(); - // uploadedFile should remain null since file has no url - expect(component.uploadedFile).toBeNull(); - }); - - it('should fallback to review comment when dirty control has no comment', () => { - const dirtyValue = { answer: 'edited', file: null }; - component.control = new FormControl(dirtyValue); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); - - expect(component.comment).toBe('saved comment'); - }); + spyOn(sub, 'unsubscribe'); + component.ngOnDestroy(); + expect(sub.unsubscribe).toHaveBeenCalled(); }); - describe('assessment mode', () => { - beforeEach(() => { - component.submissionStatus = 'in progress'; - component.doAssessment = true; - component.submission = { - answer: { url: 'https://cdn/submission.pdf', name: 'submission.pdf', path: '/uploads/sub' }, - }; - component.reviewStatus = ''; - component.doReview = false; - }); - - it('should use saved submission answer when control is pristine', () => { - component.control = new FormControl(''); - - component['_showSavedAnswers'](); - - expect(component.innerValue).toEqual(component.submission.answer); - }); - - it('should preserve control value when control is dirty', () => { - const dirtyValue = { url: 'https://cdn/user-edit.pdf', name: 'user-edit.pdf', path: '/uploads/user' }; - component.control = new FormControl(dirtyValue); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); - - expect(component.innerValue).toEqual(dirtyValue); - }); - - it('should restore uploadedFile from innerValue.url when control is dirty', () => { - const dirtyValue = { url: 'https://cdn/dirty.pdf', name: 'dirty.pdf', path: '/uploads/dirty' }; - component.control = new FormControl(dirtyValue); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); - - expect(component.uploadedFile).toBeDefined(); - expect(component.uploadedFile.cdnUrl).toBe('https://cdn/dirty.pdf'); - }); - - it('should not restore uploadedFile when dirty control has no url', () => { - component.control = new FormControl({}); - component.control.markAsDirty(); - component.uploadedFile = null as any; - - component['_showSavedAnswers'](); - - expect(component.uploadedFile).toBeNull(); - }); - }); - - it('should set control value at the end', () => { - component.submissionStatus = 'in progress'; - component.doAssessment = true; - component.submission = { answer: { url: 'https://cdn/test.pdf' } }; - component.control = new FormControl(''); - - component['_showSavedAnswers'](); - - expect(component.control.value).toEqual(component.submission.answer); - }); - - describe('review mode with "not start" status', () => { - it('should load review data when reviewStatus is "not start"', () => { - component.reviewStatus = 'not start'; - component.doReview = true; - component.review = { - answer: { url: 'https://cdn/r.pdf' }, - comment: 'review comment', - file: { url: 'https://cdn/r.pdf', name: 'r.pdf' }, - }; - component.control = new FormControl(''); - - component['_showSavedAnswers'](); - - expect(component.innerValue).toEqual({ - answer: component.review.answer, - comment: component.review.comment, - file: component.review.file, - }); - }); + it('should destroy the uppy instance', () => { + fixture.detectChanges(); + component.ngOnDestroy(); + expect(mockUppy.destroy).toHaveBeenCalled(); }); }); - describe('isDisplayOnly behavior via ngOnInit paths', () => { - it('should restore saved file from submission answer URL when control is dirty in assessment mode', () => { - component.submissionStatus = 'in progress'; - component.doAssessment = true; - const savedFile = { url: 'https://cdn/sub-file.pdf', name: 'sub-file.pdf', path: '/uploads/sub' }; - component.submission = { answer: savedFile }; - component.control = new FormControl(savedFile); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); - - expect(component.uploadedFile).toBeDefined(); - expect(component.uploadedFile.cdnUrl).toBe('https://cdn/sub-file.pdf'); + describe('noteMessage', () => { + it('should return video-specific message for video fileType', () => { + component.question = { ...component.question, fileType: 'video' }; + expect(component.noteMessage()).toContain('Videos only'); }); - it('should restore saved file from review file URL when control is dirty in review mode', () => { - component.reviewStatus = 'in progress'; - component.doReview = true; - const savedReview = { - answer: null, - comment: 'test', - file: { url: 'https://cdn/rev-file.pdf', name: 'rev-file.pdf', path: '/uploads/rev' }, - }; - component.review = savedReview; - component.control = new FormControl(savedReview); - component.control.markAsDirty(); - - component['_showSavedAnswers'](); + it('should return image-specific message for image fileType', () => { + component.question = { ...component.question, fileType: 'image' }; + expect(component.noteMessage()).toContain('Images only'); + }); - expect(component.uploadedFile).toBeDefined(); - expect(component.uploadedFile.cdnUrl).toBe('https://cdn/rev-file.pdf'); + it('should return generic message for any fileType', () => { + component.question = { ...component.question, fileType: 'any' }; + expect(component.noteMessage()).toContain('Docs, images and videos'); }); }); }); diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.ts b/projects/v3/src/app/components/file-upload/file-upload.component.ts index aaed22ffa..3c0a5102a 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.ts +++ b/projects/v3/src/app/components/file-upload/file-upload.component.ts @@ -1,5 +1,5 @@ import { UppyUploaderService, ALLOWED_FILE_TYPES } from './../uppy-uploader/uppy-uploader.service'; -import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { AbstractControl } from '@angular/forms'; import { Subject, Subscription } from 'rxjs'; import { Uppy, UppyFile } from '@uppy/core'; @@ -92,13 +92,12 @@ export class FileUploadComponent implements OnInit, OnDestroy { compressionProgress = 0; private compressionSub: Subscription | undefined; - /** exposes service isCompressing for template binding */ - get isCompressing(): boolean { - return this.uppyUploaderService.isCompressing; - } + /** true only when this component's own uppy instance is compressing */ + isCompressing = false; constructor( private uppyUploaderService: UppyUploaderService, + private cdr: ChangeDetectorRef, ) { } ngOnDestroy(): void { @@ -111,8 +110,11 @@ export class FileUploadComponent implements OnInit, OnDestroy { this.uppyProps.note = this.noteMessage(); this._showSavedAnswers(); - this.compressionSub = this.uppyUploaderService.compressionProgress$.subscribe(p => { - this.compressionProgress = p ? Math.round(p.progress * 100) : 0; + this.compressionSub = this.uppyUploaderService.compressionProgress$.subscribe(({ uppy, progress }) => { + if (uppy !== this.uppy) return; + this.isCompressing = progress !== null; + this.compressionProgress = progress ? Math.round(progress.progress * 100) : 0; + this.cdr.markForCheck(); }); } diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.html b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.html index c8f372ea5..464bbabd1 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.html +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.html @@ -1,13 +1,18 @@
- -

Compressing video... {{ compressionProgress }}%

+
+ +
+ +

Compressing video...

+

Please wait, this may take a moment

+

{{ compressionProgress }}%

- diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss index 777750f15..f3a91d13f 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss @@ -18,34 +18,56 @@ } .compression-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 100; display: flex; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.9); + width: 100%; + height: 100%; + min-height: 400px; + background: #fff; + border-radius: 8px; .compression-status { text-align: center; - padding: 24px; + padding: 32px; + width: 100%; + max-width: 320px; + + .compression-icon { + margin-bottom: 16px; + + ion-icon { + font-size: 48px; + opacity: 0.6; + } + } ion-spinner { - width: 32px; - height: 32px; - margin-bottom: 12px; + width: 44px; + height: 44px; + margin-bottom: 16px; + } + + h3 { + margin: 0 0 4px; + font-weight: 600; } p { - margin-bottom: 12px; + margin: 0 0 20px; } ion-progress-bar { - width: 200px; + width: 100%; margin: 0 auto; + border-radius: 4px; + --background: var(--ion-color-light, #f0f0f0); + } + + .percentage { + margin: 8px 0 0; + font-weight: 500; + color: var(--ion-color-primary); } } } diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts index c8f91fcaa..0312fc1dc 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts @@ -1,7 +1,7 @@ import { UppyFileData, UppyUploaderService, ALLOWED_FILE_TYPES } from './uppy-uploader.service'; import { environment } from '@v3/environments/environment'; import { NotificationsService } from './../../services/notifications.service'; -import { Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core'; import { Uppy, UppyFile, UppyOptions, } from '@uppy/core'; import { ModalController } from '@ionic/angular'; import { BrowserStorageService } from '../../services/storage.service'; @@ -31,10 +31,8 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { compressionProgress = 0; private compressionSub: Subscription | undefined; - /** exposes service isCompressing for template binding */ - get isCompressing(): boolean { - return this.uppyUploaderService.isCompressing; - } + /** true only when this component's own uppy instance is compressing */ + isCompressing = false; s3Info: { path: string; @@ -47,6 +45,7 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { private modalController: ModalController, private storageService: BrowserStorageService, private uppyUploaderService: UppyUploaderService, + private cdr: ChangeDetectorRef, ) { this.uppyProps = this.uppyUploaderService.uppyProps; this.uppyProps.height = '500px'; @@ -69,8 +68,11 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { allowedFileTypes: this.loadAllowedFileTypes(), }); - this.compressionSub = this.uppyUploaderService.compressionProgress$.subscribe(p => { - this.compressionProgress = p ? Math.round(p.progress * 100) : 0; + this.compressionSub = this.uppyUploaderService.compressionProgress$.subscribe(({ uppy, progress }) => { + if (uppy !== this.uppy) return; + this.isCompressing = progress !== null; + this.compressionProgress = progress ? Math.round(progress.progress * 100) : 0; + this.cdr.markForCheck(); }); } diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts index 34bb8f9c1..a66c8ae77 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts @@ -2,30 +2,21 @@ import { TestBed } from '@angular/core/testing'; import { ModalController } from '@ionic/angular'; import { UppyUploaderService } from './uppy-uploader.service'; import { BrowserStorageService } from '../../services/storage.service'; -import { Uppy, UppyFile } from '@uppy/core'; +import { Uppy } from '@uppy/core'; import { environment } from '../../../environments/environment'; import { FfmpegService } from '../../services/ffmpeg.service'; import { Subject } from 'rxjs'; describe('UppyUploaderService', () => { let service: UppyUploaderService; - let modalControllerSpy: jasmine.SpyObj; - let storageServiceSpy: jasmine.SpyObj; - let uppyInstanceSpy: jasmine.SpyObj>; let ffmpegServiceSpy: jasmine.SpyObj; - let modalSpy: any; + let modalCtrlSpy: jasmine.SpyObj; + let storageSpy: jasmine.SpyObj; beforeEach(() => { - modalSpy = jasmine.createSpyObj('HTMLIonModalElement', ['present']); - modalControllerSpy = jasmine.createSpyObj('ModalController', ['create']); - modalControllerSpy.create.and.returnValue(Promise.resolve(modalSpy)); - - storageServiceSpy = jasmine.createSpyObj('BrowserStorageService', ['getUser', 'clearByName']); - storageServiceSpy.getUser.and.returnValue({ apikey: 'test-api-key' }); - storageServiceSpy.clearByName.and.returnValue({}); - - uppyInstanceSpy = jasmine.createSpyObj('Uppy', ['use', 'on']); - uppyInstanceSpy.on.and.returnValue(uppyInstanceSpy); // to allow method chaining + modalCtrlSpy = jasmine.createSpyObj('ModalController', ['create']); + storageSpy = jasmine.createSpyObj('BrowserStorageService', ['getUser', 'clearByName']); + storageSpy.getUser.and.returnValue({ apikey: 'test-key' }); ffmpegServiceSpy = jasmine.createSpyObj('FfmpegService', [ 'shouldCompress', @@ -52,8 +43,8 @@ describe('UppyUploaderService', () => { TestBed.configureTestingModule({ providers: [ UppyUploaderService, - { provide: ModalController, useValue: modalControllerSpy }, - { provide: BrowserStorageService, useValue: storageServiceSpy }, + { provide: ModalController, useValue: modalCtrlSpy }, + { provide: BrowserStorageService, useValue: storageSpy }, { provide: FfmpegService, useValue: ffmpegServiceSpy }, ], }); @@ -65,6 +56,14 @@ describe('UppyUploaderService', () => { expect(service).toBeTruthy(); }); + it('should have compressingUppy null initially', () => { + expect(service.compressingUppy).toBeNull(); + }); + + it('should expose compressionProgress$ subject', () => { + expect(service.compressionProgress$).toBeTruthy(); + }); + describe('createUppyInstance', () => { it('should create an Uppy instance with correct options', () => { const events = { @@ -112,28 +111,26 @@ describe('UppyUploaderService', () => { }); describe('initializeEventHandlers', () => { - it('should set up event handlers on the Uppy instance', () => { - const onUploadSuccessSpy = jasmine.createSpy('onUploadSuccess'); - const file = { id: 'file-123' } as UppyFile; - const response = { status: 200 }; + let mockUppy: any; - (service as any).initializeEventHandlers(uppyInstanceSpy, onUploadSuccessSpy); - - // simulate the behavior that would happen when the handler is called - onUploadSuccessSpy(file, response); - - expect(onUploadSuccessSpy).toHaveBeenCalledWith(file, response); + beforeEach(() => { + mockUppy = { + on: jasmine.createSpy('on').and.callFake(function(this: any) { return this; }), + }; }); - it('should clear cache when upload completes successfully', () => { + it('should set up event handlers on the Uppy instance', () => { const onUploadSuccessSpy = jasmine.createSpy('onUploadSuccess'); + (service as any).initializeEventHandlers(mockUppy, onUploadSuccessSpy); + expect(mockUppy.on).toHaveBeenCalled(); + }); - (service as any).initializeEventHandlers(uppyInstanceSpy, onUploadSuccessSpy); - - // test the behavior by calling the method that the handler would trigger - service['storageService'].clearByName('file-123'); + it('should register upload-success handler', () => { + const onUploadSuccessSpy = jasmine.createSpy('onUploadSuccess'); + (service as any).initializeEventHandlers(mockUppy, onUploadSuccessSpy); - expect(storageServiceSpy.clearByName).toHaveBeenCalledWith('file-123'); + const registeredEvents = mockUppy.on.calls.allArgs().map((args: any[]) => args[0]); + expect(registeredEvents).toContain('upload-success'); }); }); @@ -148,19 +145,90 @@ describe('UppyUploaderService', () => { }); }); - it('should have isCompressing false initially', () => { - expect(service.isCompressing).toBeFalse(); - }); - - it('should expose compressionProgress$ subject', () => { - expect(service.compressionProgress$).toBeTruthy(); - }); - - describe('compressVideoFiles (via files-added event)', () => { + describe('compression preprocessor', () => { it('should skip non-video files', () => { ffmpegServiceSpy.shouldCompress.and.returnValue({ compress: false, reason: 'not a video file' }); - // the method is private but triggers via uppy event — verify no compression call + // the preprocessor is private — verify no compression call expect(ffmpegServiceSpy.compressVideo).not.toHaveBeenCalled(); }); }); + + describe('open', () => { + it('should create a modal with backdropDismiss false', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']); + mockModal.present.and.returnValue(Promise.resolve()); + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal)); + + await service.open('chat'); + + expect(modalCtrlSpy.create).toHaveBeenCalledWith(jasmine.objectContaining({ + backdropDismiss: false, + })); + }); + + it('should create a modal with canDismiss function', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']); + mockModal.present.and.returnValue(Promise.resolve()); + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal)); + + await service.open('chat'); + + const createArgs = modalCtrlSpy.create.calls.mostRecent().args[0]; + expect(createArgs.canDismiss).toBeDefined(); + expect(typeof createArgs.canDismiss).toBe('function'); + }); + + it('should allow dismiss when not compressing', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']); + mockModal.present.and.returnValue(Promise.resolve()); + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal)); + + await service.open('chat'); + + const createArgs = modalCtrlSpy.create.calls.mostRecent().args[0]; + service.compressingUppy = null; + const canDismissFn = createArgs.canDismiss as (data?: any, role?: string) => Promise; + const canDismiss = await canDismissFn(); + expect(canDismiss).toBeTrue(); + }); + + it('should block dismiss when compressing', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']); + mockModal.present.and.returnValue(Promise.resolve()); + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal)); + + await service.open('chat'); + + const createArgs = modalCtrlSpy.create.calls.mostRecent().args[0]; + service.compressingUppy = {} as Uppy; + const canDismissFn = createArgs.canDismiss as (data?: any, role?: string) => Promise; + const canDismiss = await canDismissFn(); + expect(canDismiss).toBeFalse(); + + // cleanup + service.compressingUppy = null; + }); + + it('should present the modal', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']); + mockModal.present.and.returnValue(Promise.resolve()); + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal)); + + await service.open('chat'); + + expect(mockModal.present).toHaveBeenCalled(); + }); + + it('should pass the source as component prop', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']); + mockModal.present.and.returnValue(Promise.resolve()); + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal)); + + await service.open('assessment'); + + expect(modalCtrlSpy.create).toHaveBeenCalledWith(jasmine.objectContaining({ + componentProps: { source: 'assessment' }, + })); + }); + }); }); diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts index 0f67ba4d4..53bd6459f 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import { ModalController } from '@ionic/angular'; -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { UploadResult, Uppy, UppyFile, UppyOptions } from '@uppy/core'; import Tus from '@uppy/tus'; import { BrowserStorageService } from '../../services/storage.service'; @@ -102,16 +102,17 @@ export class UppyUploaderService { }; }; - /** emits compression progress for ui consumption */ - readonly compressionProgress$ = new Subject(); + /** emits compression progress scoped to a specific uppy instance */ + readonly compressionProgress$ = new Subject<{ uppy: Uppy; progress: CompressionProgress | null }>(); - /** true while a video is being compressed */ - isCompressing = false; + /** the uppy instance currently compressing, or null when idle */ + compressingUppy: Uppy | null = null; constructor( private modalController: ModalController, private storageService: BrowserStorageService, private ffmpegService: FfmpegService, + private ngZone: NgZone, ) { } @@ -171,6 +172,7 @@ export class UppyUploaderService { }); this.initializeEventHandlers(uppy, events.onUploadSuccess); + this.registerCompressionPreProcessor(uppy); return uppy; } @@ -180,7 +182,6 @@ export class UppyUploaderService { console.log('file edit start', file); }).on('files-added', (files: any) => { console.log('files added', files); - this.compressVideoFiles(uppy, files); }).on('file-removed', (file: any) => { console.log('file removed', file); }).on('restriction-failed', (file: any, error: any) => { @@ -203,61 +204,79 @@ export class UppyUploaderService { } /** - * compress video files in-place within the uppy instance. - * replaces original video blob with the compressed version. + * register a preprocessor that compresses video files before upload. + * uppy waits for the returned promise to resolve before starting the upload. */ - private async compressVideoFiles(uppy: Uppy, files: UppyFile[]): Promise { - const videoFiles = files.filter(f => f.type?.startsWith('video/')); - if (videoFiles.length === 0) { - return; - } - - for (const uppyFile of videoFiles) { - const file = new File([uppyFile.data as Blob], uppyFile.name, { type: uppyFile.type }); - const check = this.ffmpegService.shouldCompress(file); + private registerCompressionPreProcessor(uppy: Uppy): void { + uppy.addPreProcessor(async (fileIDs: string[]) => { + const videoFileIDs = fileIDs.filter(id => { + const file = uppy.getFile(id); + return file?.type?.startsWith('video/'); + }); - if (!check.compress) { - console.log(`skipping compression for ${uppyFile.name}: ${check.reason}`); - continue; + if (videoFileIDs.length === 0) { + return; } - try { - this.isCompressing = true; - this.compressionProgress$.next({ progress: 0, timeUs: 0 }); - - // forward ffmpeg progress to our subject - const sub = this.ffmpegService.progress$.subscribe(p => { - this.compressionProgress$.next(p); - }); - - const result = await this.ffmpegService.compressVideo(file); - - sub.unsubscribe(); - this.compressionProgress$.next(null); - this.isCompressing = false; - - if (!result.skipped) { - console.log(`compressed ${uppyFile.name}: ${result.originalSize} → ${result.compressedSize} (${result.reductionPercent}% reduction)`); - - // replace the file data in uppy with the compressed version - uppy.setFileState(uppyFile.id, { - data: result.file, - size: result.compressedSize, - name: result.file.name, - type: 'video/mp4', - meta: { - ...uppyFile.meta, - name: result.file.name, - type: 'video/mp4', - }, + for (const id of videoFileIDs) { + const uppyFile = uppy.getFile(id); + if (!uppyFile) continue; + + const file = new File([uppyFile.data as Blob], uppyFile.name, { type: uppyFile.type }); + const check = this.ffmpegService.shouldCompress(file); + + if (!check.compress) { + console.log(`skipping compression for ${uppyFile.name}: ${check.reason}`); + continue; + } + + try { + this.ngZone.run(() => { + this.compressingUppy = uppy; + this.compressionProgress$.next({ uppy, progress: { progress: 0, timeUs: 0 } }); + }); + + const sub = this.ffmpegService.progress$.subscribe(p => { + this.compressionProgress$.next({ uppy, progress: p }); + }); + + const result = await this.ffmpegService.compressVideo(file); + + sub.unsubscribe(); + this.ngZone.run(() => { + this.compressionProgress$.next({ uppy, progress: null }); + this.compressingUppy = null; + }); + + if (!result.skipped) { + console.log(`compressed ${uppyFile.name}: ${result.originalSize} → ${result.compressedSize} (${result.reductionPercent}% reduction)`); + + // guard: file may have been removed during async compression + if (uppy.getFile(id)) { + uppy.setFileState(id, { + data: result.file, + size: result.compressedSize, + name: result.file.name, + type: 'video/mp4', + meta: { + ...uppyFile.meta, + name: result.file.name, + type: 'video/mp4', + }, + }); + } else { + console.warn(`file ${id} was removed during compression, skipping state update`); + } + } + } catch (error) { + console.error(`compression failed for ${uppyFile.name}, uploading original:`, error); + this.ngZone.run(() => { + this.compressingUppy = null; + this.compressionProgress$.next({ uppy, progress: null }); }); } - } catch (error) { - console.error(`compression failed for ${uppyFile.name}, uploading original:`, error); - this.isCompressing = false; - this.compressionProgress$.next(null); } - } + }); } /** @@ -276,6 +295,8 @@ export class UppyUploaderService { source }, cssClass: 'uppy-uploader-modal', + backdropDismiss: false, + canDismiss: () => Promise.resolve(this.compressingUppy === null), }); await modal.present(); diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts index cc069ab1c..8ace74ba9 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts @@ -15,6 +15,7 @@ import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'; import { IonContent, ModalController, PopoverController } from '@ionic/angular'; import { TestUtils } from '@testingv3/utils'; import { mockMembers } from '@testingv3/fixtures'; +import { UppyUploaderService } from '../../../components/uppy-uploader/uppy-uploader.service'; export class MockElementRef extends ElementRef { constructor() { super(null); } @@ -31,6 +32,7 @@ describe('ChatRoomComponent', () => { let routerSpy: jasmine.SpyObj; let routeStub: Partial; let MockIoncontent: IonContent; + let uppyUploaderServiceSpy: jasmine.SpyObj; const modalSpy = jasmine.createSpyObj('Modal', ['present', 'onDidDismiss']); modalSpy.onDidDismiss.and.returnValue(new Promise(() => { })); let modalCtrlSpy: any; @@ -110,8 +112,8 @@ describe('ChatRoomComponent', () => { useValue: modalCtrlSpy }, { - provide: NotificationsService, - useValue: jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast']), + provide: UppyUploaderService, + useValue: jasmine.createSpyObj('UppyUploaderService', ['open']) }, ] }) @@ -130,9 +132,9 @@ describe('ChatRoomComponent', () => { storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj; pusherSpy = TestBed.inject(PusherService) as jasmine.SpyObj; MockIoncontent = TestBed.inject(IonContent) as jasmine.SpyObj; + uppyUploaderServiceSpy = TestBed.inject(UppyUploaderService) as jasmine.SpyObj; modalCtrlSpy = TestBed.inject(ModalController); modalCtrlSpy.create.and.returnValue(Promise.resolve(modalSpy)); - // assign content for tests that need it component.content = MockIoncontent; fixture.detectChanges(); }); @@ -712,4 +714,57 @@ describe('ChatRoomComponent', () => { }); }); + describe('when testing attachmentSelectPopover()', () => { + it('should call uppyUploaderService.open with chat source', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['onDidDismiss']); + mockModal.onDidDismiss.and.returnValue(Promise.resolve({ data: null })); + uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve(mockModal)); + + await component.attachmentSelectPopover({}); + + expect(uppyUploaderServiceSpy.open).toHaveBeenCalledWith('chat'); + }); + + it('should add attachment when modal returns valid data', async () => { + const mockFileData = { + source: 'chat', + id: 'file-1', + name: 'video.mp4', + extension: 'mp4', + meta: { relativePath: null, name: 'video.mp4', type: 'video/mp4' }, + type: 'video/mp4', + data: {}, + progress: { uploadStarted: 0, uploadComplete: true, percentage: 100, bytesUploaded: 1000, bytesTotal: 1000 }, + size: 1000, + isGhost: false, + isRemote: false, + preview: '', + tus: { uploadUrl: 'https://upload.example.com/file-1' }, + bucket: 'test-bucket', + path: 'uploads/video.mp4', + url: 'https://cdn.example.com/video.mp4', + }; + + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['onDidDismiss']); + mockModal.onDidDismiss.and.returnValue(Promise.resolve({ data: mockFileData })); + uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve(mockModal)); + + spyOn(component, 'addAttachment'); + await component.attachmentSelectPopover({}); + + expect(component.addAttachment).toHaveBeenCalledWith(mockFileData); + }); + + it('should not add attachment when modal returns no data', async () => { + const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['onDidDismiss']); + mockModal.onDidDismiss.and.returnValue(Promise.resolve({ data: null })); + uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve(mockModal)); + + spyOn(component, 'addAttachment'); + await component.attachmentSelectPopover({}); + + expect(component.addAttachment).not.toHaveBeenCalled(); + }); + }); + }); diff --git a/projects/v3/src/app/services/ffmpeg.service.spec.ts b/projects/v3/src/app/services/ffmpeg.service.spec.ts index 555d1ac7f..aef516e64 100644 --- a/projects/v3/src/app/services/ffmpeg.service.spec.ts +++ b/projects/v3/src/app/services/ffmpeg.service.spec.ts @@ -1,19 +1,63 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NgZone } from '@angular/core'; import { FfmpegService, CompressionResult } from './ffmpeg.service'; describe('FfmpegService', () => { let service: FfmpegService; + let ngZone: NgZone; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(FfmpegService); + ngZone = TestBed.inject(NgZone); }); it('should be created', () => { expect(service).toBeTruthy(); }); + it('should inject NgZone', () => { + expect(ngZone).toBeTruthy(); + }); + + describe('progress$ and log$', () => { + it('should expose progress$ subject', () => { + expect(service.progress$).toBeTruthy(); + expect(typeof service.progress$.subscribe).toBe('function'); + }); + + it('should expose log$ subject', () => { + expect(service.log$).toBeTruthy(); + expect(typeof service.log$.subscribe).toBe('function'); + }); + + it('should emit progress values to subscribers', () => { + const emitted: any[] = []; + const sub = service.progress$.subscribe(p => emitted.push(p)); + + service.progress$.next({ progress: 0.5, timeUs: 1000 }); + service.progress$.next({ progress: 1.0, timeUs: 2000 }); + + expect(emitted.length).toBe(2); + expect(emitted[0].progress).toBe(0.5); + expect(emitted[1].progress).toBe(1.0); + sub.unsubscribe(); + }); + + it('should emit log messages to subscribers', () => { + const logs: string[] = []; + const sub = service.log$.subscribe(msg => logs.push(msg)); + + service.log$.next('encoding started'); + service.log$.next('frame=100'); + + expect(logs.length).toBe(2); + expect(logs[0]).toBe('encoding started'); + sub.unsubscribe(); + }); + }); + describe('isSupported', () => { it('should return true when WebAssembly and BigInt64Array exist', () => { expect(service.isSupported()).toBeTrue(); @@ -113,4 +157,33 @@ describe('FfmpegService', () => { expect(service.isFfmpegLoaded()).toBeFalse(); }); }); + + describe('transcodeToMp4', () => { + it('should attempt to load ffmpeg if not loaded', async () => { + const loadSpy = spyOn(service, 'loadFFmpeg').and.rejectWith(new Error('test: skip actual load')); + const file = new File(['data'], 'video.avi', { type: 'video/x-msvideo' }); + + try { + await service.transcodeToMp4(file); + } catch { + // expected — we prevented actual load + } + + expect(loadSpy).toHaveBeenCalled(); + }); + }); + + describe('loadFFmpeg', () => { + it('should skip loading if already loaded', async () => { + // set isLoaded to true via reflection + (service as any).isLoaded = true; + const ffmpegSpy = spyOn((service as any).ffmpeg, 'load'); + + await service.loadFFmpeg(); + + expect(ffmpegSpy).not.toHaveBeenCalled(); + // reset + (service as any).isLoaded = false; + }); + }); }); diff --git a/projects/v3/src/app/services/ffmpeg.service.ts b/projects/v3/src/app/services/ffmpeg.service.ts index 778d550cc..fee16834b 100644 --- a/projects/v3/src/app/services/ffmpeg.service.ts +++ b/projects/v3/src/app/services/ffmpeg.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; import { Subject } from 'rxjs'; @@ -47,7 +47,7 @@ export class FfmpegService { readonly progress$ = new Subject(); readonly log$ = new Subject(); - constructor() { + constructor(private ngZone: NgZone) { this.ffmpeg = new FFmpeg(); } @@ -101,7 +101,7 @@ export class FfmpegService { }); this.ffmpeg.on('progress', ({ progress, time }) => { - this.progress$.next({ progress, timeUs: time }); + this.ngZone.run(() => this.progress$.next({ progress, timeUs: time })); }); await this.ffmpeg.load({ @@ -162,8 +162,8 @@ export class FfmpegService { const compressedFile = new File([blob], outputName, { type: 'video/mp4' }); // clean up virtual fs to free memory - await this.ffmpeg.deleteFile(inputName); - await this.ffmpeg.deleteFile(outputName); + try { await this.ffmpeg.deleteFile(inputName); } catch { /* already removed */ } + try { await this.ffmpeg.deleteFile(outputName); } catch { /* already removed */ } const reductionPercent = Math.round((1 - compressedFile.size / file.size) * 100); @@ -206,8 +206,8 @@ export class FfmpegService { { type: 'video/mp4' } ); - await this.ffmpeg.deleteFile(inputName); - await this.ffmpeg.deleteFile(outputName); + try { await this.ffmpeg.deleteFile(inputName); } catch { /* already removed */ } + try { await this.ffmpeg.deleteFile(outputName); } catch { /* already removed */ } return outputFile; } @@ -223,8 +223,8 @@ export class FfmpegService { const fileData = await this.ffmpeg.readFile('output.mp4'); const blob = new Blob([fileData as ArrayBuffer], { type: 'video/mp4' }); - await this.ffmpeg.deleteFile('input.avi'); - await this.ffmpeg.deleteFile('output.mp4'); + try { await this.ffmpeg.deleteFile('input.avi'); } catch { /* already removed */ } + try { await this.ffmpeg.deleteFile('output.mp4'); } catch { /* already removed */ } return blob; }