diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/community-checking.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/community-checking.ts index b9d0955abef..a9123c1e62b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/community-checking.ts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/community-checking.ts @@ -180,6 +180,7 @@ async function joinAsChecker( // Give time for the last answer to be saved await page.waitForTimeout(500); } catch (e) { + if (preset.pauseOnFailure) await page.pause(); console.error('Error running tests for checker ' + userNumber); console.error(e); await screenshot( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts index 49c2741a2d5..54ad965968c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts @@ -29,6 +29,7 @@ import { FileService } from 'xforge-common/file.service'; import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { LocationService } from 'xforge-common/location.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -132,9 +133,9 @@ describe('AppComponent', () => { expect(1).toEqual(1); }); - it('navigate to last project', fakeAsync(() => { + it('navigate to last project', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); expect(env.isDrawerVisible).toEqual(true); @@ -157,9 +158,9 @@ describe('AppComponent', () => { tick(); })); - it('navigate to different project', fakeAsync(() => { + it('navigate to different project', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project02']); + await env.navigate(['/projects', 'project02']); env.init(); expect(env.isDrawerVisible).toEqual(true); @@ -177,20 +178,20 @@ describe('AppComponent', () => { discardPeriodicTasks(); })); - it('close menu when navigating to a non-project route', fakeAsync(() => { + it('close menu when navigating to a non-project route', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/my-account']); + await env.navigate(['/my-account']); env.init(); expect(env.isDrawerVisible).toEqual(false); expect(env.component.selectedProjectId).toBeUndefined(); })); - it('drawer disappears as appropriate in small viewport', fakeAsync(() => { + it('drawer disappears as appropriate in small viewport', fakeAsync(async () => { // The user goes to a project. They are using an sm size viewport. const env = new TestEnvironment(); env.breakpointObserver.emitObserveValue(false); - env.navigateFully(['/projects', 'project01']); + await env.navigateFully(['/projects', 'project01']); // (And we are not here calling env.init(), which would open the drawer.) // With a smaller viewport, at a project page, the drawer should not be visible. Although it is in the dom. @@ -234,7 +235,7 @@ describe('AppComponent', () => { expect(env.hamburgerMenuButton).toBeNull(); expect(env.component['isExpanded']).toBe(false); // The user clicks in the My projects component to open another project. - env.navigateFully(['projects', 'project02']); + await env.navigateFully(['projects', 'project02']); // The drawer should not be showing, but is in the dom. expect(env.isDrawerVisible).toBe(false); expect(env.component['isExpanded']).toBe(false); @@ -245,9 +246,9 @@ describe('AppComponent', () => { flush(); })); - it('does not set user locale when stored locale matches the browsing session', fakeAsync(() => { + it('does not set user locale when stored locale matches the browsing session', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); tick(); @@ -255,11 +256,11 @@ describe('AppComponent', () => { verify(mockedAuthService.updateInterfaceLanguage(anything())).never(); })); - it('sets user locale when stored locale does not match the browsing session', fakeAsync(() => { + it('sets user locale when stored locale does not match the browsing session', fakeAsync(async () => { const env = new TestEnvironment(); when(mockedAuthService.isNewlyLoggedIn).thenResolve(true); when(mockedI18nService.localeCode).thenReturn('es'); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); tick(); @@ -272,11 +273,11 @@ describe('AppComponent', () => { verify(mockedI18nService.setLocale('pt-BR')).once(); })); - it('should not set user locale when not newly logged in and stored locale does not match the browsing session', fakeAsync(() => { + it('should not set user locale when not newly logged in and stored locale does not match the browsing session', fakeAsync(async () => { const env = new TestEnvironment(); when(mockedAuthService.isNewlyLoggedIn).thenResolve(false); when(mockedI18nService.localeCode).thenReturn('es'); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); tick(); @@ -284,9 +285,9 @@ describe('AppComponent', () => { verify(mockedI18nService.setLocale('en')).never(); })); - it('set interface language when specifically setting locale', fakeAsync(() => { + it('set interface language when specifically setting locale', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); tick(); @@ -297,15 +298,15 @@ describe('AppComponent', () => { verify(mockedAuthService.updateInterfaceLanguage('pt-BR')).once(); })); - it('response to remote project deletion', fakeAsync(() => { + it('response to remote project deletion', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); expect(env.isDrawerVisible).toEqual(true); expect(env.selectedProjectId).toEqual('project01'); // SUT - env.deleteProject('project01', false); + await env.deleteProject('project01', false); verify(mockedDialogService.message(anything())).once(); verify(mockedUserService.setCurrentProjectId(anything(), undefined)).once(); // Get past setTimeout to navigation @@ -316,88 +317,88 @@ describe('AppComponent', () => { expect(env.location.path()).toEqual('/projects'); })); - it('response to remote project deletion when no project selected', fakeAsync(() => { + it('response to remote project deletion when no project selected', fakeAsync(async () => { // If we are at the My Projects list at /projects, and a project is deleted, we should still be at the /projects // page. Note that one difference between some other project being deleted, vs the _current_ project being deleted, // is that AppComponent listens to the current project for its deletion. const env = new TestEnvironment(); - env.navigate(['/projects']); + await env.navigate(['/projects']); env.init(); - env.deleteProject('project01', false); + await env.deleteProject('project01', false); // The drawer is not visible because we will be showing the project list. expect(env.isDrawerVisible).toEqual(false); expect(env.location.path()).toEqual('/projects'); })); - it('response to removed from project', fakeAsync(() => { + it('response to removed from project', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); expect(env.selectedProjectId).toEqual('project01'); - env.removeUserFromProject('project01'); + await env.removeUserFromProject('project01'); verify(mockedDialogService.message(anything())).once(); // Get past setTimeout to navigation tick(); expect(env.location.path()).toEqual('/projects'); })); - it('response to remote project change for serval admin', fakeAsync(() => { + it('response to remote project change for serval admin', fakeAsync(async () => { const env = new TestEnvironment(); env.setCurrentUser('user05'); - env.navigate(['/serval-administration', 'project01']); + await env.navigate(['/serval-administration', 'project01']); when(mockedLocationService.pathname).thenReturn('/serval-administration/project01'); env.init(); expect(env.selectedProjectId).toEqual('project01'); - env.updatePreTranslate('project01'); + await env.updatePreTranslate('project01'); verify(mockedDialogService.message(anything())).never(); })); - it('response to remote project change for serval admin viewing event log', fakeAsync(() => { + it('response to remote project change for serval admin viewing event log', fakeAsync(async () => { const env = new TestEnvironment(); env.setCurrentUser('user05'); - env.navigate(['/serval-administration', 'project01']); + await env.navigate(['/serval-administration', 'project01']); when(mockedLocationService.pathname).thenReturn('/projects/project01/event-log'); env.init(); expect(env.selectedProjectId).toEqual('project01'); // Simulate an op coming from another source - env.updatePreTranslate('project01'); + await env.updatePreTranslate('project01'); verify(mockedDialogService.message(anything())).never(); })); - it('response to Commenter project role changed', fakeAsync(() => { + it('response to Commenter project role changed', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.setCurrentUser('user04'); env.init(); expect(env.selectedProjectId).toEqual('project01'); when(mockedLocationService.pathname).thenReturn('/projects/project01/translate'); - env.changeUserRole('project01', 'user04', SFProjectRole.CommunityChecker); + await env.changeUserRole('project01', 'user04', SFProjectRole.CommunityChecker); expect(env.location.path()).toEqual('/projects/project01'); env.wait(); discardPeriodicTasks(); })); - it('response to Community Checker project role changed', fakeAsync(() => { + it('response to Community Checker project role changed', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.setCurrentUser('user02'); env.init(); expect(env.selectedProjectId).toEqual('project01'); when(mockedLocationService.pathname).thenReturn('/projects/project01/checking'); - env.changeUserRole('project01', 'user02', SFProjectRole.Viewer); + await env.changeUserRole('project01', 'user02', SFProjectRole.Viewer); expect(env.location.path()).toEqual('/projects/project01'); discardPeriodicTasks(); })); - it('shows banner when update is available', fakeAsync(() => { + it('shows banner when update is available', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); expect(env.refreshButton).toBeNull(); @@ -410,9 +411,9 @@ describe('AppComponent', () => { tick(); })); - it('shows install badge and option when installing is available', fakeAsync(() => { + it('shows install badge and option when installing is available', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); expect(env.installBadge).toBeNull(); @@ -424,9 +425,9 @@ describe('AppComponent', () => { env.showHideUserMenu(); })); - it('hide install badge after avatar menu click', fakeAsync(() => { + it('hide install badge after avatar menu click', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); env.canInstall$.next(true); @@ -494,14 +495,13 @@ describe('AppComponent', () => { })); describe('Community Checking', () => { - it('ensure local storage is cleared when removed from project', fakeAsync(() => { + it('ensure local storage is cleared when removed from project', fakeAsync(async () => { const env = new TestEnvironment(); - env.navigate(['/projects', 'project01']); + await env.navigate(['/projects', 'project01']); env.init(); - const projectId = 'project01'; expect(env.selectedProjectId).toEqual(projectId); - env.removeUserFromProject(projectId); + await env.removeUserFromProject(projectId); verify(mockedSFProjectService.localDelete(projectId)).once(); })); @@ -761,19 +761,21 @@ class TestEnvironment { this.addProjectUserConfig('project01', 'user03'); this.addProjectUserConfig('project01', 'user04'); - when(mockedSFProjectService.getProfile(anything())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId) + when(mockedSFProjectService.getProfile(anything(), anything())).thenCall( + async (projectId, subscription) => + await this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId, subscription) ); - when(mockedSFProjectService.getUserConfig(anything(), anything())).thenCall((projectId, userId) => - this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, `${projectId}:${userId}`) + when(mockedSFProjectService.getUserConfig(anything(), anything(), anything())).thenCall( + async (projectId, userId, subscriber) => + await this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, `${projectId}:${userId}`, subscriber) ); when(mockedLocationService.pathname).thenReturn('/projects/project01/checking'); when(mockedAuthService.currentUserRoles).thenReturn([]); when(mockedAuthService.getAccessToken()).thenResolve('access_token'); when(mockedAuthService.isLoggedIn).thenCall(() => Promise.resolve(this.loggedInState$.getValue().loggedIn)); - when(mockedAuthService.loggedIn).thenCall(() => - firstValueFrom(this.loggedInState$.pipe(filter((state: any) => state.loggedIn))) + when(mockedAuthService.loggedIn).thenCall( + async () => await firstValueFrom(this.loggedInState$.pipe(filter((state: any) => state.loggedIn))) ); when(mockedAuthService.loggedInState$).thenReturn(this.loggedInState$); if (isLoggedIn) { @@ -876,13 +878,15 @@ class TestEnvironment { return this.getElement('#sf-logo-button'); } - get currentUserDoc(): UserDoc { - return this.realtimeService.get(UserDoc.COLLECTION, 'user01'); + async getCurrentUserDoc(): Promise { + return await this.realtimeService.get(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')); } setCurrentUser(userId: string): void { when(mockedUserService.currentUserId).thenReturn(userId); - when(mockedUserService.getCurrentUser()).thenCall(() => this.realtimeService.subscribe(UserDoc.COLLECTION, userId)); + when(mockedUserService.getCurrentUser()).thenCall( + async () => await this.realtimeService.subscribe(UserDoc.COLLECTION, userId, new DocSubscription('spec')) + ); } triggerLogin(): void { @@ -914,12 +918,13 @@ class TestEnvironment { this.wait(); } - navigate(commands: any[]): void { - this.ngZone.run(() => this.router.navigate(commands)).then(); + async navigate(commands: any[]): Promise { + await this.ngZone.run(async () => await this.router.navigate(commands)); + flush(); } - navigateFully(commands: any[]): void { - this.ngZone.run(() => this.router.navigate(commands)).then(); + async navigateFully(commands: any[]): Promise { + await this.ngZone.run(async () => await this.router.navigate(commands)); flush(); this.fixture.detectChanges(); flush(); @@ -945,39 +950,64 @@ class TestEnvironment { flush(70); } - deleteProject(projectId: string, isLocal: boolean): void { + async deleteProject(projectId: string, isLocal: boolean): Promise { if (isLocal) { when(mockedUserService.currentProjectId(anything())).thenReturn(undefined); } - this.ngZone.run(() => { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); - projectDoc.delete(); + await this.ngZone.run(async () => { + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); + await projectDoc.delete(); }); this.wait(); } - removeUserFromProject(projectId: string): void { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); - projectDoc.submitJson0Op(op => op.unset(p => p.userRoles['user01']), false); + async removeUserFromProject(projectId: string): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op(op => op.unset(p => p.userRoles['user01']), false); this.wait(); } - updatePreTranslate(projectId: string): void { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); - projectDoc.submitJson0Op(op => op.set(p => p.translateConfig.preTranslate, true), false); + async updatePreTranslate(projectId: string): Promise { + const projectDoc: SFProjectProfileDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op(op => op.set(p => p.translateConfig.preTranslate, true), false); this.wait(); } - addUserToProject(projectId: string): void { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); - projectDoc.submitJson0Op(op => op.set(p => p.userRoles['user01'], SFProjectRole.CommunityChecker), false); - this.currentUserDoc.submitJson0Op(op => op.add(u => u.sites['sf'].projects, 'project04'), false); + async addUserToProject(projectId: string): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op( + op => op.set(p => p.userRoles['user01'], SFProjectRole.CommunityChecker), + false + ); + await ( + await this.getCurrentUserDoc() + ).submitJson0Op(op => op.add(u => u.sites['sf'].projects, 'project04'), false); this.wait(); } - changeUserRole(projectId: string, userId: string, role: SFProjectRole): void { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); - projectDoc.submitJson0Op(op => op.set(p => p.userRoles[userId], role), false); + async changeUserRole(projectId: string, userId: string, role: SFProjectRole): Promise { + const projectDoc: SFProjectProfileDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op(op => op.set(p => p.userRoles[userId], role), false); this.wait(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.routes.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.routes.ts index c90e77f7c44..254fe5a3d97 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.routes.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { AuthGuard } from 'xforge-common/auth.guard'; import { SystemAdminAuthGuard } from 'xforge-common/system-admin-auth.guard'; import { SystemAdministrationComponent } from 'xforge-common/system-administration/system-administration.component'; +import { BlankPageComponent } from './shared/blank-page/blank-page.component'; import { CheckingOverviewComponent } from './checking/checking-overview/checking-overview.component'; import { CheckingComponent } from './checking/checking/checking.component'; import { ConnectProjectComponent } from './connect-project/connect-project.component'; @@ -36,6 +37,7 @@ import { TranslateOverviewComponent } from './translate/translate-overview/trans import { UsersComponent } from './users/users.component'; export const APP_ROUTES: Routes = [ + { path: 'blank-page', component: BlankPageComponent }, { path: 'callback/auth0', component: MyProjectsComponent, canActivate: [AuthGuard] }, { path: 'connect-project', component: ConnectProjectComponent, canActivate: [AuthGuard] }, { path: 'login', redirectTo: 'projects', pathMatch: 'full' }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/chapter-audio-dialog/chapter-audio-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/chapter-audio-dialog/chapter-audio-dialog.component.spec.ts index e750f3614c6..14b450561d8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/chapter-audio-dialog/chapter-audio-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/chapter-audio-dialog/chapter-audio-dialog.component.spec.ts @@ -369,18 +369,18 @@ describe('ChapterAudioDialogComponent', () => { expect(result.timingData[1].to).toEqual(1.296); })); - it('will not save or upload if there is no audio', fakeAsync(() => { + it('will not save or upload if there is no audio', fakeAsync(async () => { env.component.prepareTimingFileUpload(env.timingFile); - env.component.save(); + await env.component.save(); env.fixture.detectChanges(); expect(env.numberOfTimesDialogClosed).toEqual(0); expect(env.wrapperAudio.classList.contains('invalid')).toBe(true); })); - it('will not save or upload if there is no timing data', fakeAsync(() => { + it('will not save or upload if there is no timing data', fakeAsync(async () => { env.component.audioUpdate(env.audioFile); - env.component.save(); + await env.component.save(); env.fixture.detectChanges(); expect(env.numberOfTimesDialogClosed).toEqual(0); @@ -479,7 +479,7 @@ describe('ChapterAudioDialogComponent', () => { expect(env.wrapperTiming.classList.contains('valid')).toBe(true); })); - it('will not try to save dialog if offline', fakeAsync(() => { + it('will not try to save dialog if offline', fakeAsync(async () => { env.onlineStatus = false; env.component.audioUpdate(env.audioFile); tick(); @@ -487,7 +487,7 @@ describe('ChapterAudioDialogComponent', () => { tick(); // SUT - env.component.save(); + await env.component.save(); tick(); env.fixture.detectChanges(); flush(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts index 5054ea539ec..f8789092202 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts @@ -30,6 +30,7 @@ import { VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/vers import { of } from 'rxjs'; import { anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { noopDestroyRef } from 'xforge-common/realtime.service'; @@ -80,8 +81,8 @@ describe('CheckingOverviewComponent', () => { })); describe('Add Question', () => { - it('should display "No question" message', fakeAsync(() => { - const env = new TestEnvironment(false); + it('should display "No question" message', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: false }); env.fixture.detectChanges(); expect(env.loadingQuestionsLabel).not.toBeNull(); expect(env.noQuestionsLabel).toBeNull(); @@ -90,8 +91,8 @@ describe('CheckingOverviewComponent', () => { expect(env.noQuestionsLabel).not.toBeNull(); })); - it('should not display loading if user is offline', fakeAsync(() => { - const env = new TestEnvironment(false); + it('should not display loading if user is offline', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: false }); env.onlineStatus = false; expect(env.component.showQuestionsLoadingMessage).toBe(false); expect(env.component.showNoQuestionsMessage).toBe(true); @@ -100,45 +101,45 @@ describe('CheckingOverviewComponent', () => { expect(env.noQuestionsLabel).not.toBeNull(); })); - it('should not display "Add question" button for community checker', fakeAsync(() => { - const env = new TestEnvironment(); + it('should not display "Add question" button for community checker', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.checkerUser); env.waitForQuestions(); expect(env.addQuestionButton).toBeNull(); })); - it('should display "Add question" button for project admin', fakeAsync(() => { - const env = new TestEnvironment(); + it('should display "Add question" button for project admin', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.adminUser); env.waitForQuestions(); expect(env.addQuestionButton).not.toBeNull(); })); - it('should display "Add question" button for translator with questions permission', fakeAsync(() => { - const env = new TestEnvironment(); + it('should display "Add question" button for translator with questions permission', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.translatorUser); env.waitForQuestions(); expect(env.addQuestionButton).not.toBeNull(); })); - it('should not display "Add question" button when loading', fakeAsync(() => { - const env = new TestEnvironment(); + it('should not display "Add question" button when loading', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); expect(env.addQuestionButton).toBeNull(); env.waitForQuestions(); expect(env.addQuestionButton).not.toBeNull(); })); - it('should open dialog when "Add question" button is clicked', fakeAsync(() => { - const env = new TestEnvironment(); + it('should open dialog when "Add question" button is clicked', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); env.clickElement(env.addQuestionButton); verify(mockedQuestionDialogService.questionDialog(anything())).once(); expect().nothing(); })); - it('should show new question after adding', fakeAsync(() => { - const env = new TestEnvironment(); + it('should show new question after adding', fakeAsync(async () => { + const env = await TestEnvironment.create(); const dateNow = new Date(); const newQuestion: Question = { dataId: 'newQId1', @@ -170,7 +171,7 @@ describe('CheckingOverviewComponent', () => { })); it('should show new question after local change', fakeAsync(async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); env.waitForQuestions(); const dateNow = new Date(); @@ -197,7 +198,7 @@ describe('CheckingOverviewComponent', () => { })); it('should show new answer count after remote change', fakeAsync(async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); env.waitForQuestions(); const dateNow = new Date(); @@ -214,9 +215,10 @@ describe('CheckingOverviewComponent', () => { expect(env.answerTotal).toContain('3'); - const questionDoc: QuestionDoc = env.realtimeService.get( + const questionDoc: QuestionDoc = await env.realtimeService.get( QUESTIONS_COLLECTION, - getQuestionDocId('project01', 'q4Id') + getQuestionDocId('project01', 'q4Id'), + new DocSubscription('spec') ); await questionDoc.submitJson0Op(op => { op.insert(d => d.answers, 0, newAnswer); @@ -228,8 +230,8 @@ describe('CheckingOverviewComponent', () => { expect(env.answerTotal).toContain('4'); })); - it('should show question in canonical order', fakeAsync(() => { - const env = new TestEnvironment(); + it('should show question in canonical order', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); expect(env.textRows.length).toEqual(2); // Click on Matthew and then Matthew 1 @@ -239,8 +241,8 @@ describe('CheckingOverviewComponent', () => { expect(env.textRows[3].nativeElement.textContent).toContain('v4'); })); - it('should show new question after adding to a project with no questions', fakeAsync(() => { - const env = new TestEnvironment(false); + it('should show new question after adding to a project with no questions', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: false }); const dateNow = new Date(); const newQuestion: Question = { dataId: 'newQId1', @@ -268,8 +270,8 @@ describe('CheckingOverviewComponent', () => { }); describe('Edit Question', () => { - it('should expand/collapse questions in book text', fakeAsync(() => { - const env = new TestEnvironment(); + it('should expand/collapse questions in book text', fakeAsync(async () => { + const env = await TestEnvironment.create(); const id = new TextDocId('project01', 40, 1); env.waitForQuestions(); expect(env.textRows.length).toEqual(2); @@ -289,8 +291,8 @@ describe('CheckingOverviewComponent', () => { expect(env.textRows.length).toEqual(2); })); - it('should open a dialog to edit a question', fakeAsync(() => { - const env = new TestEnvironment(); + it('should open a dialog to edit a question', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); env.clickExpanderAtRow(0); env.clickExpanderAtRow(1); @@ -302,8 +304,8 @@ describe('CheckingOverviewComponent', () => { verify(mockedQuestionDialogService.questionDialog(anything())).once(); })); - it('should bring up question dialog only if user confirms question answered dialog', fakeAsync(() => { - const env = new TestEnvironment(); + it('should bring up question dialog only if user confirms question answered dialog', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); env.clickExpanderAtRow(0); env.clickExpanderAtRow(1); @@ -328,19 +330,21 @@ describe('CheckingOverviewComponent', () => { }); describe('Import Questions', () => { - it('should open a dialog to import questions', fakeAsync(() => { - const env = new TestEnvironment(); + it('should open a dialog to import questions', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); env.clickElement(env.importButton); verify(mockedDialogService.openMatDialog(ImportQuestionsDialogComponent, anything())).once(); expect().nothing(); })); - it('should not show import questions button until list of texts have loaded', fakeAsync(() => { - const env = new TestEnvironment(); + it('should not show import questions button until list of texts have loaded', fakeAsync(async () => { + const env = await TestEnvironment.create(); const delayPromise = new Promise(resolve => setTimeout(resolve, 10 * 1000)); when(mockedQuestionsService.queryQuestions(anything(), anything(), anything())).thenReturn( - delayPromise.then(() => env.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, {}, noopDestroyRef)) + delayPromise.then( + async () => await env.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, 'spec', {}, noopDestroyRef) + ) ); env.waitForQuestions(); @@ -353,9 +357,9 @@ describe('CheckingOverviewComponent', () => { }); describe('Export Questions', () => { - it('should export questions to CSV', fakeAsync(() => { + it('should export questions to CSV', fakeAsync(async () => { spyOn(saveAs, 'saveAs').and.stub(); - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); env.waitForQuestions(); env.clickElement(env.exportButton); expect(saveAs).toHaveBeenCalled(); @@ -363,8 +367,8 @@ describe('CheckingOverviewComponent', () => { }); describe('for Reviewer', () => { - it('should display "No question" message', fakeAsync(() => { - const env = new TestEnvironment(false); + it('should display "No question" message', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: false }); env.setCurrentUser(env.checkerUser); env.fixture.detectChanges(); expect(env.loadingQuestionsLabel).not.toBeNull(); @@ -374,24 +378,24 @@ describe('CheckingOverviewComponent', () => { expect(env.noQuestionsLabel).not.toBeNull(); })); - it('should not display progress for project admin', fakeAsync(() => { - const env = new TestEnvironment(); + it('should not display progress for project admin', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.adminUser); env.waitForQuestions(); expect(env.overallProgressChart).toBeNull(); expect(env.reviewerQuestionPanel).toBeNull(); })); - it('should display progress', fakeAsync(() => { - const env = new TestEnvironment(); + it('should display progress', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.checkerUser); env.waitForQuestions(); expect(env.overallProgressChart).not.toBeNull(); expect(env.reviewerQuestionPanel).not.toBeNull(); })); - it('should calculate the right progress proportions and stats', fakeAsync(() => { - const env = new TestEnvironment(); + it('should calculate the right progress proportions and stats', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.checkerUser); env.waitForQuestions(); const [unread, read, answered] = env.component.bookProgress({ @@ -410,8 +414,8 @@ describe('CheckingOverviewComponent', () => { expect(env.component.myLikeCount).toBe(3); })); - it('should calculate the right stats for project admin', fakeAsync(() => { - const env = new TestEnvironment(); + it('should calculate the right stats for project admin', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.adminUser); env.waitForQuestions(); // 1 of 7 questions of MAT is archived + 1 in LUK @@ -421,21 +425,21 @@ describe('CheckingOverviewComponent', () => { expect(env.component.myLikeCount).toBe(4); })); - it('should hide like card if see other user responses is disabled', fakeAsync(() => { - const env = new TestEnvironment(); + it('should hide like card if see other user responses is disabled', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCurrentUser(env.checkerUser); env.waitForQuestions(); expect(env.likePanel).not.toBeNull(); - env.setSeeOtherUserResponses(false); + await env.setSeeOtherUserResponses(false); expect(env.likePanel).toBeNull(); - env.setSeeOtherUserResponses(true); + await env.setSeeOtherUserResponses(true); expect(env.likePanel).not.toBeNull(); })); }); describe('Archive Question', () => { - it('should display "No archived question" message', fakeAsync(() => { - const env = new TestEnvironment(); + it('should display "No archived question" message', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); expect(env.loadingArchivedQuestionsLabel).not.toBeNull(); env.waitForQuestions(); @@ -452,10 +456,11 @@ describe('CheckingOverviewComponent', () => { })); it('should not display loading if user is offline', fakeAsync(async () => { - const env = new TestEnvironment(); - const questionDoc: QuestionDoc = env.realtimeService.get( + const env = await TestEnvironment.create(); + const questionDoc: QuestionDoc = await env.realtimeService.get( QuestionDoc.COLLECTION, - getQuestionDocId('project01', 'q7Id') + getQuestionDocId('project01', 'q7Id'), + new DocSubscription('spec') ); await questionDoc.submitJson0Op(op => { op.set(d => d.isArchived, false); @@ -467,8 +472,8 @@ describe('CheckingOverviewComponent', () => { env.waitForQuestions(); })); - it('archives and republishes a question', fakeAsync(() => { - const env = new TestEnvironment(); + it('archives and republishes a question', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); expect(env.textRows.length).toEqual(2); expect(env.textArchivedRows.length).toEqual(1); @@ -495,8 +500,8 @@ describe('CheckingOverviewComponent', () => { discardPeriodicTasks(); })); - it('archives and republishes questions for an entire chapter or book', fakeAsync(() => { - const env = new TestEnvironment(); + it('archives and republishes questions for an entire chapter or book', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.waitForQuestions(); // VERIFY CORRECT SETUP @@ -572,16 +577,16 @@ describe('CheckingOverviewComponent', () => { }); describe('Chapter Audio', () => { - it('show audio icon on chapter heading', fakeAsync(() => { - const env = new TestEnvironment(true, true); + it('show audio icon on chapter heading', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: true, withChapterAudioData: true }); env.waitForQuestions(); env.clickExpanderAtRow(2); expect(env.checkChapterHasAudio(3)).toBeTrue(); })); - it('chapter with audio has heading visible when no questions ', fakeAsync(() => { - const env = new TestEnvironment(true, true); + it('chapter with audio has heading visible when no questions ', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: true, withChapterAudioData: true }); env.waitForQuestions(); const johnIndex = 2; const johnChapter1Index = 3; @@ -600,8 +605,8 @@ describe('CheckingOverviewComponent', () => { discardPeriodicTasks(); })); - it('click chapter with audio and no questions should not open panel ', fakeAsync(() => { - const env = new TestEnvironment(true, true); + it('click chapter with audio and no questions should not open panel ', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: true, withChapterAudioData: true }); env.waitForQuestions(); const johnIndex = 2; const johnChapter2Index = 4; @@ -613,8 +618,8 @@ describe('CheckingOverviewComponent', () => { expect(env.checkRowIsExpanded(johnChapter2Index)).toBeFalse(); })); - it('hide archive questions on book when only audio is available ', fakeAsync(() => { - const env = new TestEnvironment(true, true); + it('hide archive questions on book when only audio is available ', fakeAsync(async () => { + const env = await TestEnvironment.create({ withQuestionData: true, withChapterAudioData: true }); env.waitForQuestions(); const johnIndex = 2; @@ -630,8 +635,9 @@ describe('CheckingOverviewComponent', () => { })); }); - it('should handle question in a book that does not exist', fakeAsync(() => { - const env = new TestEnvironment(); + it('should handle question in a book that does not exist', fakeAsync(async () => { + const env = await TestEnvironment.create(); + flush(); env.addQuestion({ dataId: 'qMissingBook', projectRef: 'project01', @@ -651,8 +657,9 @@ describe('CheckingOverviewComponent', () => { expect(env.component.questionCount(41, 1)).toEqual(0); })); - it('should display question reference range if present', fakeAsync(() => { - const env = new TestEnvironment(); + it('should display question reference range if present', fakeAsync(async () => { + const env = await TestEnvironment.create(); + flush(); env.setCurrentUser(env.adminUser); const verseRefWithRange: VerseRefData = { @@ -704,10 +711,9 @@ interface UserInfo { } class TestEnvironment { - component: CheckingOverviewComponent; - fixture: ComponentFixture; - location: Location; - + private _component: CheckingOverviewComponent | undefined; + private _fixture: ComponentFixture | undefined; + readonly location: Location = TestBed.inject(Location); readonly ngZone: NgZone = TestBed.inject(NgZone); readonly realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); readonly testOnlineStatusService: TestOnlineStatusService = TestBed.inject( @@ -768,7 +774,21 @@ class TestEnvironment { private readonly anotherUserId = 'anotherUserId'; - constructor(withQuestionData: boolean = true, withChapterAudioData: boolean = false) { + private constructor() {} + + static async create({ + withQuestionData = true, + withChapterAudioData = false + }: { withQuestionData?: boolean; withChapterAudioData?: boolean } = {}): Promise { + const env = new TestEnvironment(); + await env.init({ withQuestionData, withChapterAudioData }); + return env; + } + + private async init({ + withQuestionData = true, + withChapterAudioData = false + }: { withQuestionData?: boolean; withChapterAudioData?: boolean } = {}): Promise { if (withQuestionData) { // Question 2 deliberately before question 1 to test sorting this.realtimeService.addSnapshots(QuestionDoc.COLLECTION, [ @@ -994,27 +1014,38 @@ class TestEnvironment { } ]); if (withChapterAudioData) { - this.addChapterAudio(); + await this.addChapterAudio(); } when(mockedActivatedRoute.params).thenReturn(of({ projectId: 'project01' })); when(mockedQuestionDialogService.questionDialog(anything())).thenResolve(); when(mockedDialogService.confirm(anything(), anything())).thenResolve(true); - when(mockedProjectService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) + when(mockedProjectService.getProfile(anything(), anything())).thenCall((id, subscription) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, subscription) ); - when(mockedProjectService.getUserConfig(anything(), anything())).thenCall((id, userId) => - this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, getSFProjectUserConfigDocId(id, userId)) + when(mockedProjectService.getUserConfig(anything(), anything(), anything())).thenCall((id, userId, subscriber) => + this.realtimeService.subscribe( + SFProjectUserConfigDoc.COLLECTION, + getSFProjectUserConfigDocId(id, userId), + subscriber + ) ); - when(mockedQuestionsService.queryQuestions('project01', anything(), anything())).thenCall(() => - this.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, {}, noopDestroyRef) + when(mockedQuestionsService.queryQuestions('project01', anything(), anything())).thenCall( + async () => await this.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, 'spec', {}, noopDestroyRef) ); when(mockedProjectService.onlineDeleteAudioTimingData(anything(), anything(), anything())).thenCall( - (projectId, book, chapter) => { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); + async (projectId, book, chapter) => { + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); const textIndex: number = projectDoc.data!.texts.findIndex(t => t.bookNum === book); const chapterIndex: number = projectDoc.data!.texts[textIndex].chapters.findIndex(c => c.number === chapter); - projectDoc.submitJson0Op(op => op.set(p => p.texts[textIndex].chapters[chapterIndex].hasAudio, false), false); + await projectDoc.submitJson0Op( + op => op.set(p => p.texts[textIndex].chapters[chapterIndex].hasAudio, false), + false + ); } ); when(mockedImportQuestionsDialogRef.afterClosed()).thenReturn(of(undefined)); @@ -1024,9 +1055,18 @@ class TestEnvironment { this.setCurrentUser(this.adminUser); this.testOnlineStatusService.setIsOnline(true); - this.fixture = TestBed.createComponent(CheckingOverviewComponent); - this.component = this.fixture.componentInstance; - this.location = TestBed.inject(Location); + this._fixture = TestBed.createComponent(CheckingOverviewComponent); + this._component = this.fixture.componentInstance; + } + + get component(): CheckingOverviewComponent { + if (this._component == null) throw new Error('Uninitialized'); + return this._component; + } + + get fixture(): ComponentFixture { + if (this._fixture == null) throw new Error('Uninitialized'); + return this._fixture; } get addQuestionButton(): DebugElement { @@ -1190,23 +1230,19 @@ class TestEnvironment { this.waitForProjectDocChanges(); } - setSeeOtherUserResponses(isEnabled: boolean): void { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); - projectDoc.submitJson0Op( + async setSeeOtherUserResponses(isEnabled: boolean): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op( op => op.set(p => p.checkingConfig.usersSeeEachOthersResponses, isEnabled), false ); this.waitForProjectDocChanges(); } - setCheckingEnabled(isEnabled: boolean): void { - this.ngZone.run(() => { - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); - projectDoc.submitJson0Op(op => op.set(p => p.checkingConfig.checkingEnabled, isEnabled), false); - }); - this.waitForProjectDocChanges(); - } - waitForProjectDocChanges(): void { // Project doc changes are throttled by 1000 ms, so we have to wait for them. // After 1000 ms of waiting, the project changes will be emitted, and then the async scheduler will set a 1000 ms @@ -1251,7 +1287,7 @@ class TestEnvironment { }); } - private addChapterAudio(): void { + private async addChapterAudio(): Promise { const text: TextInfo = { bookNum: 43, hasSource: false, @@ -1261,9 +1297,13 @@ class TestEnvironment { ], permissions: {} }; - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); const index: number = projectDoc.data!.texts.length - 1; - projectDoc.submitJson0Op(op => op.insert(p => p.texts, index, text), false); + await projectDoc.submitJson0Op(op => op.insert(p => p.texts, index, text), false); this.addQuestion({ dataId: 'q9Id', projectRef: 'project01', diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts index 3070be0055f..436efb215c8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts @@ -1,5 +1,5 @@ import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButton, MatIconButton, MatMiniFabButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -23,11 +23,13 @@ import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models import { toVerseRef, VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import { asyncScheduler, combineLatest, merge, Subscription } from 'rxjs'; import { map, startWith, tap, throttleTime } from 'rxjs/operators'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { DonutChartComponent } from 'xforge-common/donut-chart/donut-chart.component'; import { I18nService } from 'xforge-common/i18n.service'; import { L10nNumberPipe } from 'xforge-common/l10n-number.pipe'; +import { DocSubscription, QuerySubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -77,7 +79,7 @@ import { QuestionDialogService } from '../question-dialog/question-dialog.servic ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class CheckingOverviewComponent extends DataLoadingComponent implements OnInit, OnDestroy { +export class CheckingOverviewComponent extends DataLoadingComponent implements OnInit { texts: TextInfo[] = []; projectId?: string; questionsLoaded: boolean = false; @@ -99,6 +101,7 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O readonly i18n: I18nService, private readonly projectService: SFProjectService, private readonly checkingQuestionsService: CheckingQuestionsService, + private readonly activatedProjectService: ActivatedProjectService, private readonly userService: UserService, private readonly questionDialogService: QuestionDialogService, private readonly permissions: PermissionsService, @@ -217,7 +220,10 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O const projectId$ = this.activatedRoute.params.pipe( tap(params => { this.loadingStarted(); - projectDocPromise = this.projectService.getProfile(params['projectId']); + projectDocPromise = this.projectService.getProfile( + params['projectId'], + new DocSubscription('CheckingOverviewComponent', this.destroyRef) + ); }), map(params => params['projectId'] as string) ); @@ -227,12 +233,15 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O this.projectId = projectId; try { this.projectDoc = await projectDocPromise; - this.projectUserConfigDoc = await this.projectService.getUserConfig(projectId, this.userService.currentUserId); - this.questionsQuery?.dispose(); + this.projectUserConfigDoc = await this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('CheckingOverviewComponent', this.destroyRef) + ); this.questionsQuery = await this.checkingQuestionsService.queryQuestions( projectId, { sort: true }, - this.destroyRef + new QuerySubscription('checking-overview/main-questions', this.activatedProjectService.switched$) ); this.initTexts(); } finally { @@ -279,10 +288,6 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O }); } - ngOnDestroy(): void { - this.questionsQuery?.dispose(); - } - getRouterLink(bookId: string): string[] { if (this.projectId == null) { return []; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-comments/checking-comments.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-comments/checking-comments.stories.ts index 0de90fe63df..c599d56813d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-comments/checking-comments.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-comments/checking-comments.stories.ts @@ -3,7 +3,7 @@ import { createTestUserProfile } from 'realtime-server/lib/esm/common/models/use import { Comment } from 'realtime-server/lib/esm/scriptureforge/models/comment'; import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { expect, within } from 'storybook/test'; -import { instance, mock, when } from 'ts-mockito'; +import { anything, instance, mock, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; import { UserProfileDoc } from 'xforge-common/models/user-profile-doc'; import { OwnerComponent } from 'xforge-common/owner/owner.component'; @@ -14,11 +14,11 @@ import { CheckingCommentsComponent } from './checking-comments.component'; const mockedDialogService = mock(DialogService); const mockedUserService = mock(UserService); when(mockedUserService.currentUserId).thenReturn('user01'); -when(mockedUserService.getProfile('user01')).thenResolve({ +when(mockedUserService.getProfile('user01', anything())).thenResolve({ id: 'user01', data: createTestUserProfile({}, 1) } as UserProfileDoc); -when(mockedUserService.getProfile('user02')).thenResolve({ +when(mockedUserService.getProfile('user02', anything())).thenResolve({ id: 'user02', data: createTestUserProfile({}, 2) } as UserProfileDoc); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-questions.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-questions.service.ts index 18b946ffa0b..389e7338386 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-questions.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-questions.service.ts @@ -6,6 +6,7 @@ import { VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/vers import { Subject } from 'rxjs'; import { FileService } from 'xforge-common/file.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription, QuerySubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { ComparisonOperator, QueryParameters, Sort } from 'xforge-common/query-parameters'; import { RealtimeService } from 'xforge-common/realtime.service'; @@ -52,7 +53,7 @@ export class CheckingQuestionsService { queryQuestions( projectId: string, options: { bookNum?: number; chapterNum?: number; activeOnly?: boolean; sort?: boolean } = {}, - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { const queryParams: QueryParameters = { [obj().pathStr(q => q.projectRef)]: projectId @@ -74,7 +75,12 @@ export class CheckingQuestionsService { queryParams.$sort = this.getQuestionSortParams('ascending'); } - return this.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery( + QuestionDoc.COLLECTION, + 'query_questions ' + JSON.stringify(options), + queryParams, + lifetimeIndicator + ); } /** @@ -82,13 +88,13 @@ export class CheckingQuestionsService { * @param projectId The ID of the project to query * @param relativeTo The question or verse to use as a reference point * @param prevOrNext Whether to query the question before or after the reference point - * @param destroyRef The reference to destroy the query when the component gets destroyed. + * @param lifetimeIndicator The an indication regarding lifecycle for the query subscription. */ queryAdjacentQuestions( projectId: string, relativeTo: Question | VerseRefData, prevOrNext: 'prev' | 'next', - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { const verseRef: VerseRefData = this.isVerseRefData(relativeTo) ? relativeTo : relativeTo.verseRef; const currentQuestion: Question | undefined = this.isVerseRefData(relativeTo) ? undefined : relativeTo; @@ -144,13 +150,18 @@ export class CheckingQuestionsService { $sort: this.getQuestionSortParams(prevOrNext === 'next' ? 'ascending' : 'descending') }; - return this.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery( + QuestionDoc.COLLECTION, + 'query_adjacent_questions', + queryParams, + lifetimeIndicator + ); } async queryFirstUnansweredQuestion( projectId: string, userId: string, - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { const queryParams: QueryParameters = { [obj().pathStr(q => q.projectRef)]: projectId, @@ -166,12 +177,18 @@ export class CheckingQuestionsService { $sort: this.getQuestionSortParams('ascending'), $limit: 1 }; - return this.realtimeService.subscribeQuery(QuestionDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery( + QuestionDoc.COLLECTION, + 'query_first_unanswered_question', + queryParams, + lifetimeIndicator + ); } async createQuestion( id: string, question: Question, + subscriber: DocSubscription, audioFileName?: string, audioBlob?: Blob ): Promise { @@ -202,7 +219,7 @@ export class CheckingQuestionsService { }); return this.realtimeService - .create(QuestionDoc.COLLECTION, docId, question) + .create(QuestionDoc.COLLECTION, docId, question, subscriber) .then((questionDoc: QuestionDoc) => { this.afterQuestionCreated$.next(questionDoc); return questionDoc; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-text/checking-text.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-text/checking-text.component.spec.ts index 74475a78a57..a2e5c064a09 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-text/checking-text.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-text/checking-text.component.spec.ts @@ -7,8 +7,9 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import * as RichText from 'rich-text'; import { firstValueFrom } from 'rxjs'; -import { anything, mock, when } from 'ts-mockito'; +import { anyString, anything, mock, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -208,14 +209,14 @@ class TestEnvironment { } }) }); - when(mockedSFProjectService.getProfile('project01')).thenCall(() => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 'project01') + when(mockedSFProjectService.getProfile(anyString(), anything())).thenCall((id, subscription) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, subscription) ); - when(mockedSFProjectService.getText(anything())).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockedSFProjectService.getText(anything(), anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), subscriber) ); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01') + this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')) ); this.fixture = TestBed.createComponent(CheckingTextComponent); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts index 63cede9b2be..01f9f53dbeb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts @@ -42,6 +42,7 @@ import { anyString, anything, instance, mock, reset, resetCalls, spy, verify, wh import { DialogService } from 'xforge-common/dialog.service'; import { FileService } from 'xforge-common/file.service'; import { createStorageFileData, FileOfflineData, FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { Snapshot } from 'xforge-common/models/snapshot'; import { UserDoc } from 'xforge-common/models/user-doc'; @@ -198,7 +199,7 @@ describe('CheckingComponent', () => { env.component.checkSliderPosition({ sizes: ['*', 20] }); env.waitForSliderUpdate(); expect(env.component.splitComponent?.getVisibleAreaSizes()[1]).toBeGreaterThan(1); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -232,6 +233,7 @@ describe('CheckingComponent', () => { tick(env.questionReadTimer); const nextQuestion = env.currentQuestion; expect(nextQuestion).toEqual(2); + flush(TestEnvironment.adequateWaitMs); })); it('can navigate using previous button', fakeAsync(() => { @@ -241,6 +243,7 @@ describe('CheckingComponent', () => { tick(env.questionReadTimer); const nextQuestion = env.currentQuestion; expect(nextQuestion).toEqual(1); + flush(TestEnvironment.adequateWaitMs); })); it('prev/next disabled state based on existence of prev/next question', fakeAsync(() => { @@ -317,7 +320,7 @@ describe('CheckingComponent', () => { env.waitForAudioPlayer(); discardPeriodicTasks(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); }); @@ -334,7 +337,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); tick(); expect(getAdjacentQuestionSpy).toHaveBeenCalledWith(undefined, 'next'); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -343,21 +346,21 @@ describe('CheckingComponent', () => { env.clickButton(env.addQuestionButton); verify(mockedQuestionDialogService.questionDialog(anything())).once(); expect().nothing(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); it('hides add question button for community checker', fakeAsync(() => { const env = new TestEnvironment({ user: CHECKER_USER }); expect(env.addQuestionButton).toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); it('hides add audio button for community checker', fakeAsync(() => { const env = new TestEnvironment({ user: CHECKER_USER }); expect(env.addAudioButton).toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -365,7 +368,7 @@ describe('CheckingComponent', () => { const env = new TestEnvironment({ user: ADMIN_USER }); expect(env.addAudioButton).not.toBeNull(); expect(env.addQuestionButton).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -374,7 +377,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.addAudioButton).not.toBeNull(); expect(env.addQuestionButton).toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -383,11 +386,11 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.addAudioButton).toBeNull(); expect(env.addQuestionButton).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); - it('responds to remote removed from project', fakeAsync(() => { + it('responds to remote removed from project', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER, projectBookRoute: 'JHN', @@ -396,32 +399,35 @@ describe('CheckingComponent', () => { }); env.selectQuestion(1); expect(env.component.questionDocs.length).toEqual(14); - env.component.projectDoc!.submitJson0Op(op => op.unset(p => p.userRoles[CHECKER_USER.id]), false); + await env.component.projectDoc!.submitJson0Op(op => op.unset(p => p.userRoles[CHECKER_USER.id]), false); env.waitForSliderUpdate(); expect(env.component.projectDoc).toBeUndefined(); expect(env.component.questionDocs.length).toEqual(0); env.waitForSliderUpdate(); + flush(TestEnvironment.adequateWaitMs); })); - it('responds to remote community checking disabled when checker', fakeAsync(() => { + it('responds to remote community checking disabled when checker', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); const projectUserConfig = env.component.projectUserConfigDoc!.data!; expect(projectUserConfig.selectedTask).toEqual('checking'); expect(projectUserConfig.selectedQuestionRef).not.toBeNull(); - env.setCheckingEnabled(false); + await env.setCheckingEnabled(false); expect(env.component.projectDoc).toBeUndefined(); env.waitForSliderUpdate(); + flush(TestEnvironment.adequateWaitMs); })); - it('responds to remote community checking disabled when observer', fakeAsync(() => { + it('responds to remote community checking disabled when observer', fakeAsync(async () => { // User with access to translate app should get redirected there const env = new TestEnvironment({ user: OBSERVER_USER, projectBookRoute: 'MAT', projectChapterRoute: 1 }); env.selectQuestion(1); - env.setCheckingEnabled(false); + await env.setCheckingEnabled(false); expect(env.component.projectDoc).toBeUndefined(); expect(env.component.questionDocs.length).toEqual(0); env.waitForSliderUpdate(); + flush(TestEnvironment.adequateWaitMs); })); }); @@ -479,7 +485,7 @@ describe('CheckingComponent', () => { // Question 5 has been stored as the question to start at, but route book/chapter is forced to MAT 1, // so active question must be from MAT 1 expect(env.component.questionsList!.activeQuestionDoc!.data!.dataId).toBe('q16Id'); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -515,7 +521,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.component.questionsList!.activeQuestionDoc).toBe(undefined); expect(env.component.chapter).toEqual(3); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -533,15 +539,15 @@ describe('CheckingComponent', () => { expect(question.classes['question-read']).toBe(true); })); - it('question status change to answered', fakeAsync(() => { + it('question status change to answered', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); const question = env.selectQuestion(2); - env.answerQuestion('Answer question 2'); + await env.answerQuestion('Answer question 2'); tick(100); env.fixture.detectChanges(); expect(question.classes['question-answered']).toBe(true); tick(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('question shows answers icon and total', fakeAsync(() => { @@ -552,6 +558,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.getUnread(question)).toEqual(0); env.waitForAudioPlayer(); + flush(TestEnvironment.adequateWaitMs); })); it('allows admin to archive a question', fakeAsync(async () => { @@ -574,6 +581,7 @@ describe('CheckingComponent', () => { expect(env.component.questionDocs.length).toEqual(13); expect(env.component.questionVerseRefs.length).toEqual(13); expect(env.component.questionsList!.activeQuestionDoc!.id).toBe('project01:q3Id'); + flush(TestEnvironment.adequateWaitMs); })); it('opens a dialog when edit question is clicked', fakeAsync(() => { @@ -599,9 +607,10 @@ describe('CheckingComponent', () => { mockedFileService.findOrUpdateCache(FileType.Audio, QuestionDoc.COLLECTION, questionId, 'audioFile.mp3') ).times(3); expect().nothing(); + flush(TestEnvironment.adequateWaitMs); })); - it('removes audio player when question audio deleted', fakeAsync(() => { + it('removes audio player when question audio deleted', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER, projectBookRoute: 'JHN', @@ -609,8 +618,8 @@ describe('CheckingComponent', () => { questionScope: 'chapter' }); const questionId = 'q15Id'; - const questionDoc = cloneDeep(env.getQuestionDoc(questionId)); - questionDoc.submitJson0Op(op => { + const questionDoc = cloneDeep(await env.getQuestionDoc(questionId)); + await questionDoc.submitJson0Op(op => { op.unset(qd => qd.audioUrl!); }); when(mockedQuestionDialogService.questionDialog(anything())).thenResolve(questionDoc); @@ -625,10 +634,10 @@ describe('CheckingComponent', () => { verify(mockedFileService.findOrUpdateCache(FileType.Audio, QuestionDoc.COLLECTION, questionId, undefined)).times( 3 ); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('uploads audio then updates audio url', fakeAsync(() => { + it('uploads audio then updates audio url', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER, projectBookRoute: 'JHN', @@ -638,9 +647,9 @@ describe('CheckingComponent', () => { }); env.selectQuestion(14); const questionId = 'q14Id'; - const questionDoc = cloneDeep(env.getQuestionDoc(questionId)); + const questionDoc = cloneDeep(await env.getQuestionDoc(questionId)); expect(env.component.answersPanel?.getFileSource(questionDoc.data?.audioUrl)).toBeUndefined(); - questionDoc.submitJson0Op(op => { + await questionDoc.submitJson0Op(op => { op.set(qd => qd.audioUrl!, 'anAudioFile.mp3'); }); when(mockedQuestionDialogService.questionDialog(anything())).thenResolve(questionDoc); @@ -648,7 +657,7 @@ describe('CheckingComponent', () => { // Simulate going online after the answer is edited resetCalls(mockedFileService); env.onlineStatus = true; - env.fileSyncComplete.next(); + env.fileSyncComplete$.next(); tick(); env.fixture.detectChanges(); expect(env.component.answersPanel?.getFileSource(questionDoc.data?.audioUrl)).toBeDefined(); @@ -670,6 +679,7 @@ describe('CheckingComponent', () => { env.clickButton(env.editQuestionButton); verify(mockedDialogService.confirm(anything(), anything())).twice(); verify(mockedQuestionDialogService.questionDialog(anything())).once(); + flush(TestEnvironment.adequateWaitMs); expect().nothing(); })); @@ -681,8 +691,8 @@ describe('CheckingComponent', () => { expect(env.isSegmentHighlighted(1, 1)).toBe(true); expect(env.segmentHasQuestion(1, 5)).toBe(false); expect(env.isSegmentHighlighted(1, 5)).toBe(false); - when(mockedQuestionDialogService.questionDialog(anything())).thenCall((config: QuestionDialogData) => { - config.questionDoc!.submitJson0Op(op => + when(mockedQuestionDialogService.questionDialog(anything())).thenCall(async (config: QuestionDialogData) => { + await config.questionDoc!.submitJson0Op(op => op.set(q => q.verseRef, { bookNum: 43, chapterNum: 1, verseNum: 5, verse: '5' }) ); return config.questionDoc; @@ -699,6 +709,7 @@ describe('CheckingComponent', () => { expect(env.segmentHasQuestion(1, 5)).toBe(true); expect(env.component.questionVerseRefs.some(verseRef => verseRef.equals(new VerseRef('JHN 1:5')))).toBe(true); env.waitForQuestionTimersToComplete(); + flush(TestEnvironment.adequateWaitMs); })); it('records audio for question when button clicked', fakeAsync(() => { @@ -723,7 +734,7 @@ describe('CheckingComponent', () => { anything() ) ).once(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -731,6 +742,7 @@ describe('CheckingComponent', () => { const env = new TestEnvironment({ user: ADMIN_USER }); env.selectQuestion(15); expect(env.recordQuestionButton).toBeNull(); + flush(TestEnvironment.adequateWaitMs); })); it('unread answers badge is only visible to question managers', fakeAsync(() => { @@ -802,29 +814,29 @@ describe('CheckingComponent', () => { env.waitForAudioPlayer(); })); - it('respond to remote question audio added or removed', fakeAsync(() => { + it('respond to remote question audio added or removed', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); expect(env.audioPlayerOnQuestion).toBeNull(); - env.simulateRemoteEditQuestionAudio('filename.mp3'); + await env.simulateRemoteEditQuestionAudio('filename.mp3'); expect(env.audioPlayerOnQuestion).not.toBeNull(); verify(mockedFileService.findOrUpdateCache(FileType.Audio, QuestionDoc.COLLECTION, 'q1Id', 'filename.mp3')).times( 5 ); resetCalls(mockedFileService); - env.simulateRemoteEditQuestionAudio(undefined); + await env.simulateRemoteEditQuestionAudio(undefined); expect(env.audioPlayerOnQuestion).toBeNull(); verify(mockedFileService.findOrUpdateCache(FileType.Audio, QuestionDoc.COLLECTION, 'q1Id', undefined)).times(5); env.selectQuestion(2); - env.simulateRemoteEditQuestionAudio('filename2.mp3'); + await env.simulateRemoteEditQuestionAudio('filename2.mp3'); env.waitForSliderUpdate(); verify( mockedFileService.findOrUpdateCache(FileType.Audio, QuestionDoc.COLLECTION, 'q2Id', 'filename2.mp3') ).times(5); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('question added to another book changes the route to that book and activates the question', fakeAsync(() => { + it('question added to another book changes the route to that book and activates the question', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER }); const dateNow = new Date(); const newQuestion: Question = { @@ -839,10 +851,11 @@ describe('CheckingComponent', () => { dateModified: dateNow.toJSON() }; env.insertQuestion(newQuestion); - env.activateQuestion(newQuestion.dataId); + await env.activateQuestion(newQuestion.dataId); expect(env.location.path()).toEqual('/projects/project01/checking/MAT/1?scope=book'); - env.activateQuestion('q1Id'); + await env.activateQuestion('q1Id'); expect(env.location.path()).toEqual('/projects/project01/checking/JHN/1?scope=book'); + flush(TestEnvironment.adequateWaitMs); })); it('admin can see appropriate filter options', fakeAsync(() => { @@ -861,7 +874,7 @@ describe('CheckingComponent', () => { expect(env.component.questionFilters.has(QuestionFilter.CurrentUserHasNotAnswered)) .withContext('CurrentUserHasNotAnswered') .toEqual(false); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -881,7 +894,7 @@ describe('CheckingComponent', () => { expect(env.component.questionFilters.has(QuestionFilter.CurrentUserHasNotAnswered)) .withContext('CurrentUserHasNotAnswered') .toEqual(true); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -914,6 +927,7 @@ describe('CheckingComponent', () => { .withContext(env.component.appliedQuestionFilterKey ?? '') .toEqual(`(${expectedVisibleQuestionTotal})`); }); + flush(TestEnvironment.adequateWaitMs); })); it('show no questions message for filter', fakeAsync(() => { @@ -927,6 +941,7 @@ describe('CheckingComponent', () => { env.setQuestionFilter(QuestionFilter.StatusExport); expect(env.questions.length).toEqual(0); expect(env.noQuestionsFound).not.toBeNull(); + flush(TestEnvironment.adequateWaitMs); })); it('should update question summary when filtered', fakeAsync(() => { @@ -945,10 +960,10 @@ describe('CheckingComponent', () => { expect(env.questions.length).toEqual(10); // The first question after filter has now been read expect(env.component.summary.unread).toEqual(9); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('should reset filtering after a new question is added', fakeAsync(() => { + it('should reset filtering after a new question is added', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER, projectBookRoute: 'JHN', @@ -960,13 +975,14 @@ describe('CheckingComponent', () => { expect(env.questions.length).toEqual(1); // Technically this is an existing question returned but the test is to confirm the filter reset - const questionDoc = env.getQuestionDoc('q5Id'); + const questionDoc = await env.getQuestionDoc('q5Id'); when(mockedQuestionDialogService.questionDialog(anything())).thenResolve(questionDoc); env.clickButton(env.addQuestionButton); verify(mockedQuestionDialogService.questionDialog(anything())).once(); expect(env.component.activeQuestionFilter).toEqual(QuestionFilter.None); expect(env.questions.length).toEqual(14); env.waitForQuestionTimersToComplete(); + flush(TestEnvironment.adequateWaitMs); })); it('should not reset scope after a user changes chapter', fakeAsync(() => { @@ -983,6 +999,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.component.activeQuestionScope).toEqual('chapter'); env.waitForQuestionTimersToComplete(); + flush(TestEnvironment.adequateWaitMs); })); it('should not reset scope after a user changes book', fakeAsync(() => { @@ -999,6 +1016,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.component.activeQuestionScope).toEqual('book'); env.waitForQuestionTimersToComplete(); + flush(TestEnvironment.adequateWaitMs); })); it('can narrow questions scope', fakeAsync(() => { @@ -1018,7 +1036,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.questions.length).toEqual(14); tick(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1039,7 +1057,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.questions.length).toEqual(16); tick(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1050,7 +1068,7 @@ describe('CheckingComponent', () => { }); expect(env.questions.length).toBeGreaterThan(0); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1073,16 +1091,17 @@ describe('CheckingComponent', () => { expect(spyUpdateQuestionRefs).toHaveBeenCalledTimes(1); // Called at least once or more depending on if another question replaces the archived question expect(spyRefreshSummary).toHaveBeenCalled(); + flush(TestEnvironment.adequateWaitMs); })); describe('Question Filter', () => { - it('should filter out answered questions - "NoAnswers"', fakeAsync(() => { + it('should filter out answered questions - "NoAnswers"', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER }); env.component.activeQuestionFilter = QuestionFilter.NoAnswers; - const questionRemaining = env.getQuestionDoc('q1Id'); + const questionRemaining = await env.getQuestionDoc('q1Id'); const questionExcluded = { data: {}, getAnswers: (_?: string) => { @@ -1093,7 +1112,7 @@ describe('CheckingComponent', () => { const filtered = env.component['filterQuestions']([questionRemaining, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemaining); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1119,7 +1138,7 @@ describe('CheckingComponent', () => { const filtered = env.component['filterQuestions']([questionRemaining, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemaining); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1145,7 +1164,7 @@ describe('CheckingComponent', () => { const filtered = env.component['filterQuestions']([questionRemaining, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemaining); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1171,7 +1190,7 @@ describe('CheckingComponent', () => { const filtered = env.component['filterQuestions']([questionRemaining, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemaining); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1207,7 +1226,7 @@ describe('CheckingComponent', () => { filtered = env.component['filterQuestions']([questionRemainingNoneStatus, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemainingNoneStatus); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1233,7 +1252,7 @@ describe('CheckingComponent', () => { const filtered = env.component['filterQuestions']([questionRemaining, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemaining); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1259,7 +1278,7 @@ describe('CheckingComponent', () => { const filtered = env.component['filterQuestions']([questionRemaining, questionExcluded]); expect(filtered.length).toBe(1); expect(filtered[0]).toBe(questionRemaining); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); }); @@ -1269,33 +1288,33 @@ describe('CheckingComponent', () => { it('answer panel is initiated and shows the first question', fakeAsync(() => { const env = new TestEnvironment({ user: CHECKER_USER }); expect(env.answerPanel).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); - it('can answer a question', fakeAsync(() => { + it('can answer a question', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(2); // Checker user already has an answer on question 6 and 9 expect(env.component.summary.answered).toEqual(2); - env.answerQuestion('Answer question 2'); + await env.answerQuestion('Answer question 2'); expect(env.answers.length).toEqual(1); expect(env.getAnswerText(0)).toBe('Answer question 2'); expect(env.component.summary.answered).toEqual(3); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('opens edit display name dialog if answering a question for the first time', fakeAsync(() => { + it('opens edit display name dialog if answering a question for the first time', fakeAsync(async () => { const env = new TestEnvironment({ user: CLEAN_CHECKER_USER }); env.selectQuestion(2); - env.answerQuestion('Answering question 2 should pop up a dialog'); + await env.answerQuestion('Answering question 2 should pop up a dialog'); verify(mockedUserService.editDisplayName(true)).once(); expect(env.answers.length).toEqual(1); expect(env.getAnswerText(0)).toBe('Answering question 2 should pop up a dialog'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('does not open edit display name dialog if offline', fakeAsync(() => { + it('does not open edit display name dialog if offline', fakeAsync(async () => { const env = new TestEnvironment({ user: CLEAN_CHECKER_USER, projectBookRoute: 'JHN', @@ -1304,29 +1323,29 @@ describe('CheckingComponent', () => { hasConnection: false }); env.selectQuestion(2); - env.answerQuestion('Answering question 2 offline'); + await env.answerQuestion('Answering question 2 offline'); verify(mockedUserService.editDisplayName(anything())).never(); expect(env.answers.length).toEqual(1); expect(env.getAnswerText(0)).toBe('Answering question 2 offline'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('inserts newer answer above older answers', fakeAsync(() => { + it('inserts newer answer above older answers', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); - env.answerQuestion('Just added answer'); + await env.answerQuestion('Just added answer'); expect(env.answers.length).toEqual(2); expect(env.getAnswerText(0)).toBe('Just added answer'); expect(env.getAnswerText(1)).toBe('Answer 7 on question'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('saves the last visited question', fakeAsync(() => { + it('saves the last visited question', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); const projectUserConfigDoc = env.component.projectUserConfigDoc!.data!; verify(mockedTranslationEngineService.trainSelectedSegment(anything(), anything())).once(); expect(projectUserConfigDoc.selectedQuestionRef).toBe('project01:q5Id'); - env.component.projectDoc!.submitJson0Op(op => { + await env.component.projectDoc!.submitJson0Op(op => { op.set(p => p.translateConfig.translationSuggestionsEnabled, false); }); env.waitForSliderUpdate(); @@ -1360,10 +1379,10 @@ describe('CheckingComponent', () => { env.waitForSliderUpdate(); expect(env.yourAnswerField).toBeNull(); expect(env.addAnswerButton).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('does not save the answer when storage quota exceeded', fakeAsync(() => { + it('does not save the answer when storage quota exceeded', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); when( mockedFileService.uploadFile( @@ -1392,12 +1411,12 @@ describe('CheckingComponent', () => { blob: getAudioBlob() } }; - env.component.answerAction(answerAction); + await env.component.answerAction(answerAction); env.waitForSliderUpdate(); const questionDoc = env.component.questionsList!.activeQuestionDoc!; expect(questionDoc.data!.answers.length).toEqual(0); expect(env.saveAnswerButton).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('check answering validation', fakeAsync(() => { @@ -1407,13 +1426,13 @@ describe('CheckingComponent', () => { env.clickButton(env.saveAnswerButton); env.waitForSliderUpdate(); expect(env.answerFormErrors[0].classes['visible']).toBe(true); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('can edit a new answer', fakeAsync(() => { + it('can edit a new answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); - env.answerQuestion('Answer question 7'); + await env.answerQuestion('Answer question 7'); const myAnswerIndex = 0; const otherAnswerIndex = 1; expect(env.getAnswer(myAnswerIndex).classes['attention']).toBe(true); @@ -1425,7 +1444,7 @@ describe('CheckingComponent', () => { expect(env.getAnswer(myAnswerIndex).classes['attention']).toBe(true); expect(env.getAnswer(otherAnswerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswerText(0)).toBe('Edited question 7 answer'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('can edit an existing answer', fakeAsync(() => { @@ -1463,39 +1482,39 @@ describe('CheckingComponent', () => { .toBeUndefined(); expect(env.getAnswer(otherAnswerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswerText(myAnswerIndex)).withContext('should not have been changed').toEqual('Edited answer'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('highlights remotely edited answer', fakeAsync(() => { + it('highlights remotely edited answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(9); const otherAnswerIndex = 1; expect(env.getAnswer(otherAnswerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswerText(otherAnswerIndex)).toBe('Answer 1 on question'); - env.simulateRemoteEditAnswer(otherAnswerIndex, 'Question 9 edited answer'); + await env.simulateRemoteEditAnswer(otherAnswerIndex, 'Question 9 edited answer'); expect(env.getAnswer(otherAnswerIndex).classes['attention']).toBe(true); expect(env.getAnswerText(otherAnswerIndex)).toBe('Question 9 edited answer'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('does not highlight upon sync', fakeAsync(() => { + it('does not highlight upon sync', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(9); const answerIndex = 1; expect(env.getAnswer(answerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswerText(answerIndex)).toBe('Answer 1 on question'); - env.simulateSync(answerIndex); + await env.simulateSync(answerIndex); expect(env.getAnswer(answerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswerText(answerIndex)).toBe('Answer 1 on question'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('still shows answers as read after canceling an edit', fakeAsync(() => { + it('still shows answers as read after canceling an edit', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); - env.answerQuestion('Answer question 7'); + await env.answerQuestion('Answer question 7'); const myAnswerIndex = 0; const otherAnswerIndex = 1; expect(env.getAnswer(myAnswerIndex).classes['attention']).toBe(true); @@ -1507,38 +1526,38 @@ describe('CheckingComponent', () => { expect(env.getAnswer(myAnswerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswer(otherAnswerIndex).classes['attention']).toBeUndefined(); expect(env.getAnswerText(0)).toEqual('Answer question 7'); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('only my answer is highlighted after I add an answer', fakeAsync(() => { + it('only my answer is highlighted after I add an answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); - env.answerQuestion('My answer'); + await env.answerQuestion('My answer'); expect(env.answers.length).withContext('setup problem').toBeGreaterThan(1); const myAnswerIndex = 0; const otherAnswerIndex = 1; expect(env.getAnswer(myAnswerIndex).classes['attention']).toBe(true); expect(env.getAnswer(otherAnswerIndex).classes['attention']).toBeUndefined(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('can remove audio from answer', fakeAsync(() => { + it('can remove audio from answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); const data: FileOfflineData = { id: 'a6Id', dataCollection: 'questions', blob: getAudioBlob() }; when(mockedFileService.findOrUpdateCache(FileType.Audio, 'questions', 'a6Id', '/audio.mp3')).thenResolve(data); env.selectQuestion(6); env.clickButton(env.getAnswerEditButton(0)); env.waitForSliderUpdate(); - env.component.answersPanel!.submit({ text: 'Answer 6 on question', audio: { status: 'reset' } }); + await env.component.answersPanel!.submit({ text: 'Answer 6 on question', audio: { status: 'reset' } }); env.waitForSliderUpdate(); verify( mockedFileService.deleteFile(FileType.Audio, 'project01', QuestionDoc.COLLECTION, 'a6Id', CHECKER_USER.id) ).once(); expect().nothing(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('saves audio answer offline and plays from cache', fakeAsync(() => { + it('saves audio answer offline and plays from cache', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER, projectBookRoute: 'JHN', @@ -1547,7 +1566,7 @@ describe('CheckingComponent', () => { hasConnection: false }); const resolveUpload$: Subject = env.resolveFileUploadSubject('blob://audio'); - env.answerQuestion('An offline answer', 'audioFile.mp3'); + await env.answerQuestion('An offline answer', 'audioFile.mp3'); resolveUpload$.next(); env.waitForSliderUpdate(); verify( @@ -1567,23 +1586,23 @@ describe('CheckingComponent', () => { expect(newAnswer.audioUrl).toEqual('blob://audio'); expect(env.component.answersPanel?.getFileSource(newAnswer.audioUrl)).toBeDefined(); verify(mockedFileService.findOrUpdateCache(FileType.Audio, 'questions', anything(), 'blob://audio')).once(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); - it('saves the answer to the correct question when active question changed', fakeAsync(() => { + it('saves the answer to the correct question when active question changed', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); const resolveUpload$: Subject = env.resolveFileUploadSubject('uploadedFile.mp3'); env.selectQuestion(1); - env.answerQuestion('Answer with audio', 'audioFile.mp3'); + await env.answerQuestion('Answer with audio', 'audioFile.mp3'); expect(env.answers.length).toEqual(0); - const question = env.getQuestionDoc('q1Id'); + const question = await env.getQuestionDoc('q1Id'); expect(env.saveAnswerButton).not.toBeNull(); env.selectQuestion(2); resolveUpload$.next(); env.waitForSliderUpdate(); expect(question.data!.answers.length).toEqual(1); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('can delete an answer', fakeAsync(() => { @@ -1596,33 +1615,34 @@ describe('CheckingComponent', () => { verify( mockedFileService.deleteFile(FileType.Audio, 'project01', QuestionDoc.COLLECTION, 'a6Id', CHECKER_USER.id) ).once(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('can delete correct answer after changing chapters', fakeAsync(() => { + it('can delete correct answer after changing chapters', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(2); - env.answerQuestion('Answer question 2'); + await env.answerQuestion('Answer question 2'); env.component.chapter!++; env.clickButton(env.answerDeleteButton(0)); env.waitForSliderUpdate(); expect(env.answers.length).toEqual(0); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('answers reset when changing questions', fakeAsync(() => { + it('answers reset when changing questions', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(2); - env.answerQuestion('Answer question 2'); + await env.answerQuestion('Answer question 2'); expect(env.answers.length).toEqual(1); env.selectQuestion(1); expect(env.answers.length).toEqual(0); + flush(TestEnvironment.adequateWaitMs); })); - it("checker user can like and unlike another's answer", fakeAsync(() => { + it("checker user can like and unlike another's answer", fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); - env.answerQuestion('Answer question 7'); + await env.answerQuestion('Answer question 7'); expect(env.getAnswerText(1)).toBe('Answer 7 on question'); expect(env.getLikeTotal(1)).toBe(0); env.clickButton(env.likeButtons[1]); @@ -1633,19 +1653,19 @@ describe('CheckingComponent', () => { env.waitForSliderUpdate(); expect(env.getLikeTotal(1)).toBe(0); expect(env.likeButtons[1].classes.like).toBeUndefined(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('cannot like your own answer', fakeAsync(() => { + it('cannot like your own answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Answer question to be liked'); + await env.answerQuestion('Answer question to be liked'); expect(env.getLikeTotal(0)).toBe(0); env.clickButton(env.likeButtons[0]); env.waitForSliderUpdate(); expect(env.getLikeTotal(0)).toBe(0); verify(mockedNoticeService.show('You cannot like your own answer.')).once(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('observer cannot like an answer', fakeAsync(() => { @@ -1678,40 +1698,40 @@ describe('CheckingComponent', () => { expect(env.getLikeTotal(1)).toBe(0); expect(env.likeButtons[0].classes.like).toBeUndefined(); expect(env.likeButtons[1].classes.like).toBeUndefined(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('hides the like icon if see other users responses is disabled', fakeAsync(() => { + it('hides the like icon if see other users responses is disabled', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(6); expect(env.answers.length).toEqual(1); expect(env.likeButtons.length).toEqual(1); - env.setSeeOtherUserResponses(false); + await env.setSeeOtherUserResponses(false); expect(env.likeButtons.length).toEqual(0); - env.setSeeOtherUserResponses(true); + await env.setSeeOtherUserResponses(true); expect(env.likeButtons.length).toEqual(1); })); - it('do not show answers until current user has submitted an answer', fakeAsync(() => { + it('do not show answers until current user has submitted an answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); expect(env.getUnread(env.questions[6])).toEqual(0); env.selectQuestion(7); expect(env.answers.length).toBe(0); - env.answerQuestion('Answer from checker'); + await env.answerQuestion('Answer from checker'); expect(env.answers.length).toBe(2); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('checker can only see their answers when the setting is OFF to see other answers', fakeAsync(() => { + it('checker can only see their answers when the setting is OFF to see other answers', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); - env.setSeeOtherUserResponses(false); + await env.setSeeOtherUserResponses(false); env.selectQuestion(6); expect(env.answers.length).toBe(1); env.selectQuestion(7); expect(env.answers.length).toBe(0); - env.answerQuestion('Answer from checker'); + await env.answerQuestion('Answer from checker'); expect(env.answers.length).toBe(1); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('can add scripture to an answer', fakeAsync(() => { @@ -1745,7 +1765,7 @@ describe('CheckingComponent', () => { env.waitForSliderUpdate(); env.clickButton(env.clearScriptureButton); expect(env.selectVersesButton).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -1762,14 +1782,15 @@ describe('CheckingComponent', () => { expect(env.getAnswerEditButton(0)).toBeNull(); env.selectQuestion(7); expect(env.getAnswerEditButton(0)).not.toBeNull(); + flush(TestEnvironment.adequateWaitMs); })); - it('new remote answers from other users are not displayed until requested', fakeAsync(() => { + it('new remote answers from other users are not displayed until requested', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); expect(env.totalAnswersMessageCount).withContext('setup').toBeNull(); - env.answerQuestion('New answer from current user'); + await env.answerQuestion('New answer from current user'); // Answers count as displayed in HTML. expect(env.totalAnswersMessageCount).toEqual(2); @@ -1780,7 +1801,7 @@ describe('CheckingComponent', () => { expect(env.showUnreadAnswersButton).toBeNull(); - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); // The new answer does not show up yet. expect(env.answers.length).toEqual(2); @@ -1802,7 +1823,7 @@ describe('CheckingComponent', () => { expect(env.totalAnswersMessageCount).toEqual(3); })); - it('new remote answers from other users are not displayed to proj admin until requested', fakeAsync(() => { + it('new remote answers from other users are not displayed to proj admin until requested', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER }); // Select a question with at least one answer, but with no answers // authored by the project admin since that was hindering this test. @@ -1813,7 +1834,7 @@ describe('CheckingComponent', () => { expect(env.showUnreadAnswersButton).toBeNull(); expect(env.totalAnswersMessageCount).toEqual(1); - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); // New remote answer is buffered rather than shown immediately. expect(env.answers.length).toEqual(1); @@ -1837,7 +1858,7 @@ describe('CheckingComponent', () => { expect(env.totalAnswersMessageCount).toEqual(2); })); - it('proj admin sees total answer count if >0 answers', fakeAsync(() => { + it('proj admin sees total answer count if >0 answers', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER }); // Select a question with at least one answer, but with no answers // authored by the project admin, in case that hinders this test. @@ -1848,19 +1869,19 @@ describe('CheckingComponent', () => { expect(env.showUnreadAnswersButton).toBeNull(); expect(env.totalAnswersMessageCount).toEqual(1); // Delete only answer on question. - env.deleteAnswer('a6Id'); + await env.deleteAnswer('a6Id'); // Total answers header goes away. expect(env.totalAnswersMessageCount).toBeNull(); // A remote answer is added - env.simulateNewRemoteAnswer('remoteAnswerId123'); + await env.simulateNewRemoteAnswer('remoteAnswerId123'); // The total answers header comes back. expect(env.totalAnswersMessageCount).toEqual(1); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it("new remote answers and banner don't show, if user has not yet answered the question", fakeAsync(() => { + it("new remote answers and banner don't show, if user has not yet answered the question", fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); expect(env.answers.length).withContext('setup (no answers in DOM yet)').toEqual(0); @@ -1868,7 +1889,7 @@ describe('CheckingComponent', () => { expect(env.totalAnswersMessageCount).toBeNull(); // Another user adds an answer, but with no impact on the current user's screen yet. - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); expect(env.showUnreadAnswersButton).toBeNull(); expect(env.answers.length).withContext('broken unrelated functionality').toEqual(0); // Incoming remote answer should have been absorbed into the set of @@ -1878,24 +1899,24 @@ describe('CheckingComponent', () => { expect(env.totalAnswersMessageCount).toBeNull(); // Current user adds her answer, and all answers show. - env.answerQuestion('New answer from current user'); + await env.answerQuestion('New answer from current user'); expect(env.showUnreadAnswersButton).toBeNull(); expect(env.answers.length).toEqual(3); expect(env.component.answersPanel!.answers.length).toEqual(3); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('show-remote-answer banner disappears if user deletes their answer', fakeAsync(() => { + it('show-remote-answer banner disappears if user deletes their answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); // User answers a question - env.answerQuestion('New answer from current user'); + await env.answerQuestion('New answer from current user'); expect(env.answers.length).withContext('setup').toEqual(2); expect(env.component.answersPanel!.answers.length).withContext('setup').toEqual(2); expect(env.showUnreadAnswersButton).toBeNull(); // A remote answer is added, but the current user does not click the banner to show the remote answer. - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); expect(env.answers.length).toEqual(2); expect(env.component.answersPanel!.answers.length).toEqual(2); expect(env.showUnreadAnswersButton).not.toBeNull(); @@ -1917,54 +1938,54 @@ describe('CheckingComponent', () => { expect(env.totalAnswersMessageCount).toBeNull(); // Adding an answer should result in seeing all answers, and no banner. - env.answerQuestion('New/replaced answer from current user'); + await env.answerQuestion('New/replaced answer from current user'); expect(env.answers.length).toEqual(3); expect(env.component.answersPanel!.answers.length).toEqual(3); expect(env.showUnreadAnswersButton).toBeNull(); expect(env.totalAnswersMessageCount).toEqual(3); // A remote answer at this point makes the banner show, tho. - env.simulateNewRemoteAnswer('answerId12345', 'another remote answer'); + await env.simulateNewRemoteAnswer('answerId12345', 'another remote answer'); expect(env.answers.length).toEqual(3); expect(env.component.answersPanel!.answers.length).toEqual(3); expect(env.showUnreadAnswersButton).not.toBeNull(); expect(env.unreadAnswersBannerCount).toEqual(1); expect(env.totalAnswersMessageCount).toEqual(4); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('show-remote-answer banner disappears if the un-shown remote answer is deleted', fakeAsync(() => { + it('show-remote-answer banner disappears if the un-shown remote answer is deleted', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); // User answers a question - env.answerQuestion('New answer from current user'); + await env.answerQuestion('New answer from current user'); expect(env.answers.length).withContext('setup').toEqual(2); expect(env.component.answersPanel!.answers.length).withContext('setup').toEqual(2); expect(env.showUnreadAnswersButton).toBeNull(); expect(env.totalAnswersMessageCount).toEqual(2); // A remote answer is added and then deleted, before the current user clicks the banner to show the remote answer. - env.simulateNewRemoteAnswer('remoteAnswerId123'); + await env.simulateNewRemoteAnswer('remoteAnswerId123'); expect(env.showUnreadAnswersButton).not.toBeNull(); expect(env.totalAnswersMessageCount).toEqual(3); - env.deleteAnswer('remoteAnswerId123'); + await env.deleteAnswer('remoteAnswerId123'); expect(env.showUnreadAnswersButton).toBeNull(); expect(env.answers.length).toEqual(2); expect(env.component.answersPanel!.answers.length).toEqual(2); expect(env.totalAnswersMessageCount).toEqual(2); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); - it('show-remote-answer banner not shown if user is editing their answer', fakeAsync(() => { + it('show-remote-answer banner not shown if user is editing their answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(7); // User answers a question - env.answerQuestion('New answer from current user'); + await env.answerQuestion('New answer from current user'); expect(env.showUnreadAnswersButton).withContext('setup').toBeNull(); expect(env.answers.length).withContext('setup').toEqual(2); // A remote answer is added, but the current user does not click the banner to show the remote answer. - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); expect(env.showUnreadAnswersButton).withContext('setup').not.toBeNull(); // The current user edits their own answer. env.clickButton(env.getAnswerEditButton(0)); @@ -1980,31 +2001,31 @@ describe('CheckingComponent', () => { expect(env.answers.length).toEqual(2); expect(env.totalAnswersMessageCount).toEqual(3); expect(env.unreadAnswersBannerCount).toEqual(1); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('show-remote-answer banner not shown to user if see-others-answers is disabled', fakeAsync(() => { + it('show-remote-answer banner not shown to user if see-others-answers is disabled', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); - env.setSeeOtherUserResponses(false); + await env.setSeeOtherUserResponses(false); expect(env.component.projectDoc!.data!.checkingConfig.usersSeeEachOthersResponses) .withContext('setup') .toBe(false); env.selectQuestion(7); // User answers a question - env.answerQuestion('New answer from current user'); + await env.answerQuestion('New answer from current user'); expect(env.totalAnswersMessageText).withContext('setup').toEqual('Your answer'); // A remote answer is added. - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); expect(env.totalAnswersMessageText).toEqual('Your answer'); // Banner is not shown expect(env.showUnreadAnswersButton).toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('show-remote-answer banner still shown to proj admin if see-others-answers is disabled', fakeAsync(() => { + it('show-remote-answer banner still shown to proj admin if see-others-answers is disabled', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER }); - env.setSeeOtherUserResponses(false); + await env.setSeeOtherUserResponses(false); expect(env.component.projectDoc!.data!.checkingConfig.usersSeeEachOthersResponses) .withContext('setup') .toBe(false); @@ -2013,32 +2034,32 @@ describe('CheckingComponent', () => { expect(env.totalAnswersMessageCount).withContext('setup').toEqual(1); // A remote answer is added. - env.simulateNewRemoteAnswer(); + await env.simulateNewRemoteAnswer(); expect(env.totalAnswersMessageCount).toEqual(2); // Banner is shown expect(env.showUnreadAnswersButton).not.toBeNull(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); describe('Comments', () => { - it('can comment on an answer', fakeAsync(() => { + it('can comment on an answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Answer question to be commented on'); - env.commentOnAnswer(0, 'Response to answer'); + await env.answerQuestion('Answer question to be commented on'); + await env.commentOnAnswer(0, 'Response to answer'); env.waitForSliderUpdate(); expect(env.getAnswerComments(0).length).toBe(1); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('can edit comment on an answer', fakeAsync(() => { + it('can edit comment on an answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); // Answer a question in a chapter where chapters previous also have comments env.selectQuestion(14); - env.answerQuestion('Answer question to be commented on'); - env.commentOnAnswer(0, 'Response to answer'); + await env.answerQuestion('Answer question to be commented on'); + await env.commentOnAnswer(0, 'Response to answer'); env.waitForSliderUpdate(); - env.commentOnAnswer(0, 'Second comment to answer'); + await env.commentOnAnswer(0, 'Second comment to answer'); env.waitForSliderUpdate(); env.clickButton(env.getEditCommentButton(0, 0)); expect(env.getYourCommentField(0)).not.toBeNull(); @@ -2048,42 +2069,43 @@ describe('CheckingComponent', () => { expect(env.getAnswerCommentText(0, 0)).toBe('Edited comment'); expect(env.getAnswerCommentText(0, 1)).toBe('Second comment to answer'); expect(env.getAnswerComments(0).length).toBe(2); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('can delete comment on an answer', fakeAsync(() => { + it('can delete comment on an answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Answer question to be commented on'); - env.commentOnAnswer(0, 'Response to answer'); + await env.answerQuestion('Answer question to be commented on'); + await env.commentOnAnswer(0, 'Response to answer'); env.waitForSliderUpdate(); expect(env.getAnswerComments(0).length).toBe(1); env.clickButton(env.getDeleteCommentButton(0, 0)); env.waitForSliderUpdate(); expect(env.getAnswerComments(0).length).toBe(0); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('can record audio for a comment', fakeAsync(() => { + it('can record audio for a comment', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Answer question to be commented on'); + await env.answerQuestion('Answer question to be commented on'); const resolveUpload$: Subject = env.resolveFileUploadSubject('blob://audio'); - env.commentOnAnswer(0, '', 'audioFile.mp3'); + await env.commentOnAnswer(0, '', 'audioFile.mp3'); resolveUpload$.next(); env.waitForSliderUpdate(); expect(env.component.answersPanel!.answers[0].comments[0].audioUrl).toEqual('blob://audio'); env.waitForSliderUpdate(); expect(env.getAnswerCommentAudio(0, 0)).not.toBeNull(); expect(env.getAnswerCommentText(0, 0)).toBe(''); + flush(TestEnvironment.adequateWaitMs); })); - it('can remove audio from a comment', fakeAsync(() => { + it('can remove audio from a comment', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Answer question to be commented on'); + await env.answerQuestion('Answer question to be commented on'); const resolveUpload$: Subject = env.resolveFileUploadSubject('blob://audio'); - env.commentOnAnswer(0, 'comment with audio', 'audioFile.mp3'); + await env.commentOnAnswer(0, 'comment with audio', 'audioFile.mp3'); resolveUpload$.next(); env.waitForSliderUpdate(); expect(env.component.answersPanel!.answers[0].comments[0].audioUrl).toEqual('blob://audio'); @@ -2096,14 +2118,15 @@ describe('CheckingComponent', () => { verify( mockedFileService.deleteFile(FileType.Audio, 'project01', QuestionDoc.COLLECTION, anything(), anything()) ).once(); + flush(TestEnvironment.adequateWaitMs); })); - it('will delete comment audio when comment is deleted', fakeAsync(() => { + it('will delete comment audio when comment is deleted', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); const resolveUpload$: Subject = env.resolveFileUploadSubject('blob://audio'); - env.answerQuestion('Answer question to be commented on'); - env.commentOnAnswer(0, 'comment with audio', 'audioFile.mp3'); + await env.answerQuestion('Answer question to be commented on'); + await env.commentOnAnswer(0, 'comment with audio', 'audioFile.mp3'); resolveUpload$.next(); env.waitForSliderUpdate(); expect(env.component.answersPanel!.answers[0].comments[0].audioUrl).toEqual('blob://audio'); @@ -2116,25 +2139,26 @@ describe('CheckingComponent', () => { verify( mockedFileService.deleteFile(FileType.Audio, 'project01', QuestionDoc.COLLECTION, anything(), anything()) ).once(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('comments only appear on the relevant answer', fakeAsync(() => { + it('comments only appear on the relevant answer', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Answer question to be commented on'); - env.commentOnAnswer(0, 'First comment'); - env.commentOnAnswer(0, 'Second comment'); + await env.answerQuestion('Answer question to be commented on'); + await env.commentOnAnswer(0, 'First comment'); + await env.commentOnAnswer(0, 'Second comment'); env.waitForSliderUpdate(); expect(env.getAnswerComments(0).length).toBe(2); env.selectQuestion(2); - env.answerQuestion('Second answer question to be commented on'); - env.commentOnAnswer(0, 'Third comment'); + await env.answerQuestion('Second answer question to be commented on'); + await env.commentOnAnswer(0, 'Third comment'); env.waitForSliderUpdate(); expect(env.getAnswerComments(0).length).toBe(1); expect(env.getAnswerCommentText(0, 0)).toBe('Third comment'); env.selectQuestion(1); expect(env.getAnswerCommentText(0, 0)).toBe('First comment'); + flush(TestEnvironment.adequateWaitMs); })); it('comments display show more button', fakeAsync(() => { @@ -2155,7 +2179,7 @@ describe('CheckingComponent', () => { expect(env.getAnswerComments(0).length).toBe(4); expect(env.getShowAllCommentsButton(0)).toBeFalsy(); expect(env.getAddCommentButton(0)).toBeTruthy(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('comments unread only mark as read when the show more button is clicked', fakeAsync(() => { @@ -2168,15 +2192,15 @@ describe('CheckingComponent', () => { env.clickButton(env.getShowAllCommentsButton(0)); env.waitForSliderUpdate(); expect(env.getUnread(question)).toEqual(0); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('displays comments in real-time', fakeAsync(() => { + it('displays comments in real-time', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Admin will add a comment to this'); + await env.answerQuestion('Admin will add a comment to this'); expect(env.getAnswerComments(0).length).toEqual(0); - const commentId: string = env.commentOnAnswerRemotely( + const commentId: string = await await env.commentOnAnswerRemotely( 'Comment left by admin', env.component.questionsList!.activeQuestionDoc! ); @@ -2185,19 +2209,19 @@ describe('CheckingComponent', () => { tick(); expect(env.getAnswerComments(0).length).toEqual(1); expect(env.component.projectUserConfigDoc!.data!.commentRefsRead.includes(commentId)).toBe(true); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('does not mark third comment read if fourth comment also added', fakeAsync(() => { + it('does not mark third comment read if fourth comment also added', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(1); - env.answerQuestion('Admin will add four comments'); - env.commentOnAnswer(0, 'First comment'); + await env.answerQuestion('Admin will add four comments'); + await env.commentOnAnswer(0, 'First comment'); const questionDoc: QuestionDoc = clone(env.component.questionsList!.activeQuestionDoc!); env.selectQuestion(2); - env.commentOnAnswerRemotely('Comment #2', questionDoc); - env.commentOnAnswerRemotely('Comment #3', questionDoc); - env.commentOnAnswerRemotely('Comment #4', questionDoc); + await await env.commentOnAnswerRemotely('Comment #2', questionDoc); + await await env.commentOnAnswerRemotely('Comment #3', questionDoc); + await await env.commentOnAnswerRemotely('Comment #4', questionDoc); env.selectQuestion(1); expect(env.component.answersPanel!.answers.length).toEqual(1); expect(env.component.answersPanel!.answers[0].comments.length).toEqual(4); @@ -2207,7 +2231,7 @@ describe('CheckingComponent', () => { env.clickButton(env.getShowAllCommentsButton(0)); expect(env.component.projectUserConfigDoc!.data!.commentRefsRead.length).toEqual(3); expect(env.getAnswerComments(0).length).toEqual(4); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('observer cannot comment on an answer', fakeAsync(() => { @@ -2224,21 +2248,22 @@ describe('CheckingComponent', () => { env.selectQuestion(8); expect(env.getAnswerComments(0).length).toEqual(2); expect(env.getEditCommentButton(0, 0)).toBeNull(); + flush(TestEnvironment.adequateWaitMs); })); }); - it('update answer audio cache when activating a question', fakeAsync(() => { + it('update answer audio cache when activating a question', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); - const questionDoc = spy(env.getQuestionDoc('q5Id')); + const questionDoc = spy(await env.getQuestionDoc('q5Id')); verify(questionDoc!.updateAnswerFileCache()).never(); env.selectQuestion(5); verify(questionDoc!.updateAnswerFileCache()).once(); expect().nothing(); })); - it('update answer audio cache after save', fakeAsync(() => { + it('update answer audio cache after save', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); - const questionDoc = spy(env.getQuestionDoc('q6Id')); + const questionDoc = spy(await env.getQuestionDoc('q6Id')); verify(questionDoc!.updateAnswerFileCache()).never(); env.selectQuestion(6); env.clickButton(env.getAnswerEditButton(0)); @@ -2248,31 +2273,31 @@ describe('CheckingComponent', () => { verify(questionDoc!.updateAnswerFileCache()).once(); expect().nothing(); tick(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('update answer audio cache on remote update to question', fakeAsync(() => { + it('update answer audio cache on remote update to question', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); - const questionDoc = spy(env.getQuestionDoc('q6Id')); + const questionDoc = spy(await env.getQuestionDoc('q6Id')); env.selectQuestion(6); verify(questionDoc!.updateAnswerFileCache()).times(1); - env.simulateRemoteEditAnswer(0, 'Question 6 edited answer'); + await env.simulateRemoteEditAnswer(0, 'Question 6 edited answer'); verify(questionDoc!.updateAnswerFileCache()).times(2); expect().nothing(); tick(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); - it('update answer audio cache on remote removal of an answer', fakeAsync(() => { + it('update answer audio cache on remote removal of an answer', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER }); - const questionDoc = spy(env.getQuestionDoc('q6Id')); + const questionDoc = await env.getQuestionDoc('q6Id'); + spyOn(questionDoc, 'updateAnswerFileCache').and.callThrough(); env.selectQuestion(6); - verify(questionDoc!.updateAnswerFileCache()).times(1); - env.simulateRemoteDeleteAnswer('q6Id', 0); - verify(questionDoc!.updateAnswerFileCache()).times(2); - expect().nothing(); - tick(); - flush(); + expect(questionDoc.updateAnswerFileCache).toHaveBeenCalledTimes(1); + // SUT + await env.simulateRemoteDeleteAnswer('q6Id', 0); + expect(questionDoc.updateAnswerFileCache).toHaveBeenCalledTimes(2); + flush(TestEnvironment.adequateWaitMs); })); it('only admins can change answer export status', fakeAsync(() => { @@ -2289,6 +2314,7 @@ describe('CheckingComponent', () => { } env.waitForAudioPlayer(); }); + flush(TestEnvironment.adequateWaitMs); })); it('can mark answer ready for export', fakeAsync(() => { @@ -2372,6 +2398,7 @@ describe('CheckingComponent', () => { tick(env.questionReadTimer); env.fixture.detectChanges(); expect(env.currentQuestion).toBe(4); + flush(TestEnvironment.adequateWaitMs); })); it('quill editor element lang attribute is set from project language', fakeAsync(() => { @@ -2379,7 +2406,7 @@ describe('CheckingComponent', () => { env.waitForAudioPlayer(); const quillElementLang = env.quillEditorElement.getAttribute('lang'); expect(quillElementLang).toEqual(TestEnvironment.project01WritingSystemTag); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -2388,11 +2415,11 @@ describe('CheckingComponent', () => { const segment = env.quillEditor.querySelector('usx-segment[data-segment=verse_1_1]')!; expect(segment.hasAttribute('data-question-count')).toBe(true); expect(segment.getAttribute('data-question-count')).toBe('13'); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); - it('updates question highlight when verse ref changes', fakeAsync(() => { + it('updates question highlight when verse ref changes', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); env.selectQuestion(4); expect(env.getVerse(1, 3)).not.toBeNull(); @@ -2400,7 +2427,7 @@ describe('CheckingComponent', () => { expect(segment.classList.contains('question-segment')).toBe(true); expect(segment.classList.contains('highlight-segment')).toBe(true); expect(fromVerseRef(env.component.activeQuestionVerseRef!).verseNum).toEqual(3); - env.component.questionsList!.activeQuestionDoc!.submitJson0Op(op => { + await env.component.questionsList!.activeQuestionDoc!.submitJson0Op(op => { op.set(qd => qd.verseRef, fromVerseRef(new VerseRef('JHN 1:5'))); }, false); env.waitForSliderUpdate(); @@ -2415,7 +2442,7 @@ describe('CheckingComponent', () => { expect(segment.classList.contains('question-segment')).toBe(true); expect(segment.classList.contains('highlight-segment')).toBe(true); tick(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('is not hidden when project setting did not specify to hide it', fakeAsync(() => { @@ -2432,7 +2459,7 @@ describe('CheckingComponent', () => { .withContext('Scripture text should be shown since the project is set not to hide it') .toBe(false); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -2452,18 +2479,18 @@ describe('CheckingComponent', () => { .withContext('Scripture text should be hidden when project setting') .toBe(true); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); - it('dynamically hides when project setting changes to specify hide text', fakeAsync(() => { + it('dynamically hides when project setting changes to specify hide text', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER }); // Starts off not hiding text. expect(env.component.projectDoc!.data!.checkingConfig.hideCommunityCheckingText).withContext('setup').toBe(false); // After the page was originally set up, now set project setting to hide community checking text const changeOriginatesLocally: boolean = false; - env.component.projectDoc?.submitJson0Op(op => { + await env.component.projectDoc?.submitJson0Op(op => { op.set(proj => proj.checkingConfig.hideCommunityCheckingText, true); }, changeOriginatesLocally); env.fixture.detectChanges(); @@ -2481,7 +2508,7 @@ describe('CheckingComponent', () => { .toBe(true); // And now set project setting NOT to hide community checking text - env.component.projectDoc?.submitJson0Op(op => { + await env.component.projectDoc?.submitJson0Op(op => { op.set(proj => proj.checkingConfig.hideCommunityCheckingText, false); }, changeOriginatesLocally); env.fixture.detectChanges(); @@ -2498,7 +2525,7 @@ describe('CheckingComponent', () => { .withContext('Scripture text should not be hidden') .toBe(false); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); }); @@ -2514,7 +2541,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.component.showScriptureAudioPlayer).toBe(true); - flush(); + flush(TestEnvironment.adequateWaitMs); expect(env.audioCheckingWarning).toBeNull(); expect(env.questionNoAudioWarning).toBeNull(); discardPeriodicTasks(); @@ -2531,7 +2558,7 @@ describe('CheckingComponent', () => { env.fixture.detectChanges(); expect(env.component.showScriptureAudioPlayer).toBe(false); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -2543,7 +2570,7 @@ describe('CheckingComponent', () => { verify(audio.stop()).once(); expect(env.component).toBeDefined(); - flush(); + flush(TestEnvironment.adequateWaitMs); discardPeriodicTasks(); })); @@ -2555,7 +2582,7 @@ describe('CheckingComponent', () => { verify(audio.stop()).once(); expect(env.component).toBeDefined(); - flush(); + flush(TestEnvironment.adequateWaitMs); })); it('pauses chapter audio when adding a question', fakeAsync(() => { @@ -2583,6 +2610,7 @@ describe('CheckingComponent', () => { verify(audio.pause()).once(); expect(env.component).toBeDefined(); + flush(TestEnvironment.adequateWaitMs); })); it('hides chapter audio if chapter audio is absent', fakeAsync(() => { @@ -2594,7 +2622,7 @@ describe('CheckingComponent', () => { env.component.chapter = 99; env.fixture.detectChanges(); - flush(); + flush(TestEnvironment.adequateWaitMs); expect(env.component.showScriptureAudioPlayer).toBe(false); discardPeriodicTasks(); @@ -2609,7 +2637,7 @@ describe('CheckingComponent', () => { env.component.chapter = 2; env.fixture.detectChanges(); - flush(); + flush(TestEnvironment.adequateWaitMs); expect(env.component.showScriptureAudioPlayer).toBe(true); discardPeriodicTasks(); @@ -2621,7 +2649,7 @@ describe('CheckingComponent', () => { projectBookRoute: 'MAT', questionScope: 'book' }); - env.setHideScriptureText(true); + await env.setHideScriptureText(true); expect(env.component.hideChapterText).toBe(true); env.waitForQuestionTimersToComplete(); env.fixture.detectChanges(); @@ -2629,8 +2657,8 @@ describe('CheckingComponent', () => { expect(env.audioCheckingWarning).not.toBeNull(); expect(env.questionNoAudioWarning).not.toBeNull(); - when(mockedChapterAudioDialogService.openDialog(anything())).thenCall(() => { - env.component.projectDoc!.submitJson0Op(op => { + when(mockedChapterAudioDialogService.openDialog(anything())).thenCall(async () => { + await env.component.projectDoc!.submitJson0Op(op => { const matTextIndex: number = env.component.projectDoc!.data!.texts.findIndex(t => t.bookNum === 40); op.set(p => p.texts[matTextIndex].chapters[0].hasAudio, true); }); @@ -2645,13 +2673,13 @@ describe('CheckingComponent', () => { expect(env.questionNoAudioWarning).toBeNull(); })); - it('notifies community checker if chapter audio is absent and hide scripture text is enabled', fakeAsync(() => { + it('notifies community checker if chapter audio is absent and hide scripture text is enabled', fakeAsync(async () => { const env = new TestEnvironment({ user: CHECKER_USER, projectBookRoute: 'MAT', questionScope: 'book' }); - env.setHideScriptureText(true); + await env.setHideScriptureText(true); expect(env.component.hideChapterText).toBe(true); env.waitForQuestionTimersToComplete(); env.fixture.detectChanges(); @@ -2741,6 +2769,7 @@ interface UserInfo { } class TestEnvironment { + static readonly adequateWaitMs: number = 1000; static project01WritingSystemTag = 'en'; readonly component: CheckingComponent; @@ -2756,7 +2785,7 @@ class TestEnvironment { ) as TestOnlineStatusService; questionReadTimer: number = 2000; - fileSyncComplete: Subject = new Subject(); + fileSyncComplete$: Subject = new Subject(); private readonly params$: BehaviorSubject; private readonly queryParams$: BehaviorSubject; @@ -2835,7 +2864,7 @@ class TestEnvironment { when( mockedFileService.findOrUpdateCache(FileType.Audio, QuestionDoc.COLLECTION, anything(), undefined) ).thenResolve(undefined); - when(mockedFileService.fileSyncComplete$).thenReturn(this.fileSyncComplete); + when(mockedFileService.fileSyncComplete$).thenReturn(this.fileSyncComplete$); const query = mock(RealtimeQuery) as RealtimeQuery; when(query.remoteChanges$).thenReturn(new BehaviorSubject(undefined)); @@ -3158,8 +3187,8 @@ class TestEnvironment { }); } - activateQuestion(dataId: string): void { - const questionDoc = this.getQuestionDoc(dataId); + async activateQuestion(dataId: string): Promise { + const questionDoc = await this.getQuestionDoc(dataId); this.ngZone.run(() => this.component.questionsList!.activateQuestion(questionDoc)); tick(); this.waitForQuestionTimersToComplete(); @@ -3199,14 +3228,14 @@ class TestEnvironment { ); } - answerQuestion(answer: string, audioFilename?: string): void { + async answerQuestion(answer: string, audioFilename?: string): Promise { this.clickButton(this.addAnswerButton); const response: CheckingInput = { text: answer }; const audio: AudioAttachment = { status: 'processed', blob: getAudioBlob(), fileName: audioFilename }; if (audioFilename != null) { response.audio = audio; } - this.component.answersPanel?.submit(response); + await this.component.answersPanel?.submit(response); tick(); this.fixture.detectChanges(); this.waitForSliderUpdate(); @@ -3232,7 +3261,7 @@ class TestEnvironment { tick(); } - commentOnAnswer(answerIndex: number, comment: string, audioFilename?: string): void { + async commentOnAnswer(answerIndex: number, comment: string, audioFilename?: string): Promise { this.clickButton(this.getAddCommentButton(answerIndex)); if (this.getYourCommentField(answerIndex) == null) return; this.setTextFieldValue(this.getYourCommentField(answerIndex), comment); @@ -3242,11 +3271,11 @@ class TestEnvironment { } const commentsComponent = this.fixture.debugElement.query(By.css('#answer-comments'))! .componentInstance as CheckingCommentsComponent; - commentsComponent.submit({ text: comment, audio: commentAudio }); + await commentsComponent.submit({ text: comment, audio: commentAudio }); this.waitForSliderUpdate(); } - commentOnAnswerRemotely(text: string, questionDoc: QuestionDoc): string { + async commentOnAnswerRemotely(text: string, questionDoc: QuestionDoc): Promise { const commentId: string = objectId(); const date = new Date().toJSON(); const comment: Comment = { @@ -3257,7 +3286,7 @@ class TestEnvironment { dateModified: date, deleted: false }; - questionDoc.submitJson0Op(op => op.insert(q => q.answers[0].comments, 0, comment), false); + await questionDoc.submitJson0Op(op => op.insert(q => q.answers[0].comments, 0, comment), false); return commentId; } @@ -3316,8 +3345,12 @@ class TestEnvironment { return this.getAnswerComments(answerIndex)[commentIndex].query(By.css('.comment-edit')); } - getQuestionDoc(dataId: string): QuestionDoc { - return this.realtimeService.get(QuestionDoc.COLLECTION, getQuestionDocId('project01', dataId)); + async getQuestionDoc(dataId: string): Promise { + return await this.realtimeService.get( + QuestionDoc.COLLECTION, + getQuestionDocId('project01', dataId), + new DocSubscription('spec') + ); } getQuestionText(question: DebugElement): string { @@ -3351,8 +3384,8 @@ class TestEnvironment { return question; } - setSeeOtherUserResponses(isEnabled: boolean): void { - this.component.projectDoc!.submitJson0Op( + async setSeeOtherUserResponses(isEnabled: boolean): Promise { + await this.component.projectDoc!.submitJson0Op( op => op.set(p => p.checkingConfig.usersSeeEachOthersResponses, isEnabled), false ); @@ -3386,9 +3419,9 @@ class TestEnvironment { return segment != null && segment.classList.contains('question-segment'); } - setCheckingEnabled(isEnabled: boolean = true): void { - this.ngZone.run(() => { - this.component.projectDoc!.submitJson0Op( + async setCheckingEnabled(isEnabled: boolean = true): Promise { + await this.ngZone.run(async () => { + await this.component.projectDoc!.submitJson0Op( op => op.set(p => p.checkingConfig.checkingEnabled, isEnabled), false ); @@ -3454,12 +3487,12 @@ class TestEnvironment { } /** Delete answer by id behind the scenes */ - deleteAnswer(answerIdToDelete: string): void { + async deleteAnswer(answerIdToDelete: string): Promise { const questionDoc = this.component.answersPanel!.questionDoc!; const answers = questionDoc.data!.answers; const answerIndex = answers.findIndex(existingAnswer => existingAnswer.dataId === answerIdToDelete); - questionDoc.submitJson0Op(op => op.set(q => q.answers[answerIndex].deleted, true)); + await questionDoc.submitJson0Op(op => op.set(q => q.answers[answerIndex].deleted, true)); this.fixture.detectChanges(); tick(); @@ -3482,12 +3515,15 @@ class TestEnvironment { return audio; } - simulateNewRemoteAnswer(dataId: string = 'newAnswer1', text: string = 'new answer from another user'): void { + async simulateNewRemoteAnswer( + dataId: string = 'newAnswer1', + text: string = 'new answer from another user' + ): Promise { // Another user on another computer adds a new answer. const date = new Date(); date.setDate(date.getDate() - 1); const dateCreated = date.toJSON(); - this.component.answersPanel!.questionDoc!.submitJson0Op( + await this.component.answersPanel!.questionDoc!.submitJson0Op( op => op.insert(q => q.answers, 0, { dataId: dataId, @@ -3511,10 +3547,10 @@ class TestEnvironment { tick(); } - simulateRemoteEditQuestionAudio(filename?: string, questionId?: string): void { + async simulateRemoteEditQuestionAudio(filename?: string, questionId?: string): Promise { const questionDoc = - questionId != null ? this.getQuestionDoc(questionId) : this.component.questionsList!.activeQuestionDoc!; - questionDoc.submitJson0Op(op => { + questionId != null ? await this.getQuestionDoc(questionId) : this.component.questionsList!.activeQuestionDoc!; + await questionDoc.submitJson0Op(op => { if (filename != null) { op.set(q => q.audioUrl!, filename); } else { @@ -3525,17 +3561,17 @@ class TestEnvironment { this.fixture.detectChanges(); } - simulateRemoteDeleteAnswer(questionId: string, answerIndex: number): void { - const questionDoc = this.getQuestionDoc(questionId); - questionDoc.submitJson0Op(op => op.set(q => q.answers[answerIndex].deleted, true), false); + async simulateRemoteDeleteAnswer(questionId: string, answerIndex: number): Promise { + const questionDoc = await this.getQuestionDoc(questionId); + await questionDoc.submitJson0Op(op => op.set(q => q.answers[answerIndex].deleted, true), false); tick(this.questionReadTimer); this.fixture.detectChanges(); tick(); } - simulateRemoteEditAnswer(index: number, text: string): void { + async simulateRemoteEditAnswer(index: number, text: string): Promise { const questionDoc = this.component.questionsList!.activeQuestionDoc!; - questionDoc.submitJson0Op(op => { + await questionDoc.submitJson0Op(op => { op.set(q => q.answers[index].text!, text); op.set(q => q.answers[index].dateModified, new Date().toJSON()); }, false); @@ -3544,9 +3580,9 @@ class TestEnvironment { tick(); } - simulateSync(index: number): void { + async simulateSync(index: number): Promise { const questionDoc = this.component.questionsList!.activeQuestionDoc!; - questionDoc.submitJson0Op(op => { + await questionDoc.submitJson0Op(op => { op.set(q => (q.answers[index] as any).syncUserRef, objectId()); }, false); tick(this.questionReadTimer); @@ -3554,9 +3590,9 @@ class TestEnvironment { tick(); } - setHideScriptureText(hideScriptureText: boolean): void { + async setHideScriptureText(hideScriptureText: boolean): Promise { const projectDoc: SFProjectProfileDoc = this.component.projectDoc!; - projectDoc.submitJson0Op(op => op.set(p => p.checkingConfig.hideCommunityCheckingText, hideScriptureText)); + await projectDoc.submitJson0Op(op => op.set(p => p.checkingConfig.hideCommunityCheckingText, hideScriptureText)); tick(); this.fixture.detectChanges(); } @@ -3577,8 +3613,8 @@ class TestEnvironment { } ]); - when(mockedProjectService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, id) + when(mockedProjectService.getProfile(anything(), anything())).thenCall( + async (id, subscriber) => await this.realtimeService.subscribe(SFProjectDoc.COLLECTION, id, subscriber) ); when(mockedProjectService.isProjectAdmin(anything(), anything())).thenResolve( user.role === SFProjectRole.ParatextAdministrator @@ -3610,8 +3646,13 @@ class TestEnvironment { data: this.consultantProjectUserConfig } ]); - when(mockedProjectService.getUserConfig(anything(), anything())).thenCall((id, userId) => - this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, getSFProjectUserConfigDocId(id, userId)) + when(mockedProjectService.getUserConfig(anything(), anything(), anything())).thenCall( + async (id, userId, subscriber) => + await this.realtimeService.subscribe( + SFProjectUserConfigDoc.COLLECTION, + getSFProjectUserConfigDocId(id, userId), + subscriber + ) ); this.realtimeService.addSnapshots(TextDoc.COLLECTION, [ @@ -3641,8 +3682,8 @@ class TestEnvironment { type: RichText.type.name } ]); - when(mockedProjectService.getText(anything())).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockedProjectService.getText(anything(), anything())).thenCall( + async (id, subscriber) => await this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), subscriber) ); when(mockedActivatedRoute.params).thenReturn(this.params$); when(mockedActivatedRoute.queryParams).thenReturn(this.queryParams$); @@ -3654,8 +3695,8 @@ class TestEnvironment { data: user.user } ]); - when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, user.id) + when(mockedUserService.getCurrentUser()).thenCall( + async () => await this.realtimeService.subscribe(UserDoc.COLLECTION, user.id, new DocSubscription('spec')) ); this.realtimeService.addSnapshots(UserProfileDoc.COLLECTION, [ @@ -3676,9 +3717,6 @@ class TestEnvironment { data: CONSULTANT_USER.user } ]); - when(mockedUserService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(UserProfileDoc.COLLECTION, id) - ); when(mockedDialogService.openMatDialog(TextChooserDialogComponent, anything())).thenReturn( instance(this.mockedTextChooserDialogComponent) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts index d155ccada3f..a4d658be3c4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts @@ -34,11 +34,13 @@ import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-inf import { toVerseRef, VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import { asyncScheduler, BehaviorSubject, combineLatest, merge, Observable, of, Subscription } from 'rxjs'; import { distinctUntilChanged, filter, map, startWith, take, throttleTime } from 'rxjs/operators'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DonutChartComponent } from 'xforge-common/donut-chart/donut-chart.component'; import { I18nService } from 'xforge-common/i18n.service'; import { Breakpoint, MediaBreakpointService } from 'xforge-common/media-breakpoints/media-breakpoint.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription, QuerySubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -208,6 +210,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A private readonly activatedRoute: ActivatedRoute, private readonly projectService: SFProjectService, private readonly checkingQuestionsService: CheckingQuestionsService, + private readonly activatedProjectService: ActivatedProjectService, private readonly userService: UserService, readonly breakpointObserver: BreakpointObserver, private readonly mediaBreakpointService: MediaBreakpointService, @@ -552,7 +555,10 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A // Do once unless project changes if (routeProjectId !== prevProjectId) { - this.projectDoc = await this.projectService.getProfile(routeProjectId); + this.projectDoc = await this.projectService.getProfile( + routeProjectId, + new DocSubscription('CheckingComponent', this.destroyRef) + ); if (!this.projectDoc?.isLoaded) { return; @@ -571,7 +577,8 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A this.projectUserConfigDoc = await this.projectService.getUserConfig( routeProjectId, - this.userService.currentUserId + this.userService.currentUserId, + new DocSubscription('CheckingComponent', this.destroyRef) ); // Subscribe to the projectDoc now that it is defined @@ -628,7 +635,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A verseNum: 0 }, 'next', - this.destroyRef + new QuerySubscription('checking/default-question', this.activatedProjectService.switched$) ); this.defaultQuestionsQuerySub = this.defaultQuestionsQuery.ready$ .pipe(ready => ready, quietTakeUntilDestroyed(this.destroyRef)) @@ -669,7 +676,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A sort: true, activeOnly: true }, - this.destroyRef + new QuerySubscription('checking/main-questions', this.activatedProjectService.switched$) ); if (this.projectDoc != null) { this.textAudioSub = merge(this.questionsQuery.ready$, this.projectDoc.remoteChanges$) @@ -1777,11 +1784,9 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A this.questionsSub?.unsubscribe(); this.audioChangedSub?.unsubscribe(); this.questionsRemoteChangesSub?.unsubscribe(); - this.questionsQuery?.dispose(); this.textAudioQuery?.dispose(); this.hideTextSub?.unsubscribe(); this.textAudioSub?.unsubscribe(); - this.defaultQuestionsQuery?.dispose(); this.defaultQuestionsQuerySub?.unsubscribe(); this.prevQuestionOutOfScope?.dispose(); this.nextQuestionOutOfScope?.dispose(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-base.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-base.service.ts index 96b358ebf67..7ee966eddd3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-base.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-base.service.ts @@ -4,6 +4,7 @@ import { NavigationEnd, Params, Router } from '@angular/router'; import { Canon } from '@sillsdev/scripture'; import { BehaviorSubject, distinctUntilChanged, filter, map, merge, Observable, of, shareReplay } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -85,7 +86,11 @@ export abstract class ResumeBaseService { private async updateProjectUserConfig(projectId: string | undefined): Promise { this.projectUserConfigDoc = undefined; if (projectId != null) { - this.projectUserConfigDoc = await this.projectService.getUserConfig(projectId, this.userService.currentUserId); + this.projectUserConfigDoc = await this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('ResumeBaseService', this.destroyRef) + ); this.projectUserConfigDoc$.next(this.projectUserConfigDoc); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-checking.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-checking.service.spec.ts index 07029ddc564..f4c7d9e1150 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-checking.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-checking.service.spec.ts @@ -67,7 +67,7 @@ describe('ResumeCheckingService', () => { when(mockRouter.events).thenReturn(routerEvents$); when(mockUserService.currentUserId).thenReturn('user01'); - when(mockProjectService.getUserConfig(anything(), anything())).thenResolve({ + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenResolve({ changes$: of([]) as Observable, data: {} as SFProjectUserConfig } as SFProjectUserConfigDoc); @@ -96,7 +96,7 @@ describe('ResumeCheckingService', () => { }); it('should create link using last location if it is present', fakeAsync(async () => { - when(mockProjectService.getUserConfig(anything(), anything())).thenResolve({ + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenResolve({ changes$: of([]) as Observable, data: { selectedBookNum: 40, selectedChapterNum: 2 } as SFProjectUserConfig } as SFProjectUserConfigDoc); @@ -115,7 +115,7 @@ describe('ResumeCheckingService', () => { })); it('should create link using first unanswered question if last location is invalid', fakeAsync(async () => { - when(mockProjectService.getUserConfig(anything(), anything())).thenResolve({ + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenResolve({ changes$: of([]) as Observable, data: { selectedBookNum: 5, selectedChapterNum: 2 } as SFProjectUserConfig } as SFProjectUserConfigDoc); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-translate.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-translate.service.spec.ts index 9fec4101af5..67661d82f11 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-translate.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/resume-translate.service.spec.ts @@ -71,7 +71,7 @@ describe('ResumeTranslateService', () => { } as SFProject; when(mockedProjectDoc.data).thenReturn(testSFProject); - when(mockProjectService.getUserConfig(anything(), anything())).thenResolve({ + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenResolve({ changes$: of([]) as Observable, data: { selectedTask: 'checking', selectedBookNum: 40, selectedChapterNum: 2 } as SFProjectUserConfig } as SFProjectUserConfigDoc); @@ -93,7 +93,7 @@ describe('ResumeTranslateService', () => { })); it('should create link using first book if last location is invalid', fakeAsync(async () => { - when(mockProjectService.getUserConfig(anything(), anything())).thenResolve({ + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenResolve({ changes$: of([]) as Observable, data: { selectedTask: 'checking', selectedBookNum: 6, selectedChapterNum: 2 } as SFProjectUserConfig } as SFProjectUserConfigDoc); @@ -113,7 +113,7 @@ describe('ResumeTranslateService', () => { })); it('should create link using first book if no user config exists', fakeAsync(async () => { - when(mockProjectService.getUserConfig(anything(), anything())).thenResolve({ + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenResolve({ changes$: of([]) as Observable } as SFProjectUserConfigDoc); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.spec.ts index 4237bc1e81c..4b8454c369a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.spec.ts @@ -238,7 +238,7 @@ describe('ImportQuestionsDialogComponent', () => { env.click(env.importFromTransceleratorButton); env.selectQuestion(env.tableRows[0]); env.click(env.importSelectedQuestionsButton); - verify(mockedQuestionsService.createQuestion('project01', anything(), undefined, undefined)).once(); + verify(mockedQuestionsService.createQuestion('project01', anything(), anything(), undefined, undefined)).once(); const question = capture(mockedQuestionsService.createQuestion).last()[1]; expect(question.projectRef).toBe('project01'); expect(question.text).toBe('Transcelerator question 1:1'); @@ -256,7 +256,7 @@ describe('ImportQuestionsDialogComponent', () => { env.click(env.importFromTransceleratorButton); env.selectQuestion(env.tableRows[1]); env.click(env.importSelectedQuestionsButton); - verify(mockedQuestionsService.createQuestion('project01', anything(), undefined, undefined)).once(); + verify(mockedQuestionsService.createQuestion('project01', anything(), anything(), undefined, undefined)).once(); const question = capture(mockedQuestionsService.createQuestion).last()[1]; expect(question.verseRef).toEqual({ bookNum: 40, @@ -296,13 +296,13 @@ describe('ImportQuestionsDialogComponent', () => { expect(env.importSelectedQuestionsButton.textContent).toContain('1'); env.click(env.importSelectedQuestionsButton); - verify(mockedQuestionsService.createQuestion('project01', anything(), undefined, undefined)).once(); + verify(mockedQuestionsService.createQuestion('project01', anything(), anything(), undefined, undefined)).once(); })); it('allows canceling the import of questions', fakeAsync(() => { const env = new TestEnvironment(); env.click(env.importFromTransceleratorButton); - when(mockedQuestionsService.createQuestion('project01', anything(), undefined, undefined)).thenCall( + when(mockedQuestionsService.createQuestion('project01', anything(), anything(), undefined, undefined)).thenCall( () => new Promise(resolve => setTimeout(resolve, 5000)) ); expect(env.tableRows.length).toBe(2); @@ -314,12 +314,12 @@ describe('ImportQuestionsDialogComponent', () => { env.importSelectedQuestionsButton.click(); tick(4000); - verify(mockedQuestionsService.createQuestion('project01', anything(), undefined, undefined)).once(); + verify(mockedQuestionsService.createQuestion('project01', anything(), anything(), undefined, undefined)).once(); // cancel while the first question is still being imported env.cancelButton.click(); tick(12000); - verify(mockedQuestionsService.createQuestion('project01', anything(), undefined, undefined)).once(); + verify(mockedQuestionsService.createQuestion('project01', anything(), anything(), undefined, undefined)).once(); })); it('can import from a CSV file', fakeAsync(() => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts index 456a316e8b3..79fc764dbb2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts @@ -42,6 +42,7 @@ import { CsvService } from 'xforge-common/csv-service.service'; import { DialogService } from 'xforge-common/dialog.service'; import { ExternalUrlService } from 'xforge-common/external-url.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { RetryingRequest } from 'xforge-common/retrying-request.service'; @@ -461,7 +462,13 @@ export class ImportQuestionsDialogComponent implements OnDestroy { } await this.zone.runOutsideAngular(() => - this.checkingQuestionsService.createQuestion(this.data.projectId, newQuestion, undefined, undefined) + this.checkingQuestionsService.createQuestion( + this.data.projectId, + newQuestion, + new DocSubscription('ImportQuestionsDialogComponent', this.destroyRef), + undefined, + undefined + ) ); } else if (this.questionsDiffer(listItem)) { await listItem.sfVersionOfQuestion.submitJson0Op(op => diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts index a5d89f47716..248e9052d84 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts @@ -21,6 +21,7 @@ import { BugsnagService } from 'xforge-common/bugsnag.service'; import { DialogService } from 'xforge-common/dialog.service'; import { FileService } from 'xforge-common/file.service'; import { createStorageFileData, FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -93,22 +94,22 @@ describe('QuestionDialogComponent', () => { flush(); })); - it('should allow user to cancel', fakeAsync(() => { - env = new TestEnvironment(); + it('should allow user to cancel', fakeAsync(async () => { + env = await TestEnvironment.create(); env.clickElement(env.cancelButton); flush(); expect(env.afterCloseCallback).toHaveBeenCalledWith('close'); })); - it('should not allow Save without required fields', fakeAsync(() => { - env = new TestEnvironment(); + it('should not allow Save without required fields', fakeAsync(async () => { + env = await TestEnvironment.create(); env.clickElement(env.saveButton); flush(); expect(env.afterCloseCallback).not.toHaveBeenCalled(); })); - it('should show error text for required fields', fakeAsync(() => { - env = new TestEnvironment(); + it('should show error text for required fields', fakeAsync(async () => { + env = await TestEnvironment.create(); expect(env.errorText[0].classes['visible']).not.toBeDefined(); env.component.scriptureStart.markAsTouched(); @@ -121,9 +122,8 @@ describe('QuestionDialogComponent', () => { expect(env.errorText[0].classes['visible']).toBe(true); })); - it('does not accept just whitespace for a question', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('does not accept just whitespace for a question', fakeAsync(async () => { + env = await TestEnvironment.create(); env.inputValue(env.questionInput, ''); env.clickElement(env.saveButton); @@ -141,9 +141,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.textAndAudio?.text.errors!.invalid).toBeDefined(); })); - it('should validate verse fields', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should validate verse fields', fakeAsync(async () => { + env = await TestEnvironment.create(); expect(env.component.versesForm.valid).toBe(false); expect(env.component.scriptureStart.valid).toBe(false); // scriptureEnd starts disabled, and therefore invalid @@ -181,9 +180,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.scriptureEnd.errors!.verseRange).toBe(true); })); - it('should produce error', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should produce error', fakeAsync(async () => { + env = await TestEnvironment.create(); const invalidVerses = [ 'MAT 1', 'MAT a1', @@ -208,17 +206,15 @@ describe('QuestionDialogComponent', () => { } })); - it('should set default verse and text direction when provided', fakeAsync(() => { + it('should set default verse and text direction when provided', fakeAsync(async () => { const verseRef: VerseRef = new VerseRef('LUK 1:1'); - env = new TestEnvironment(undefined, verseRef, true); - flush(); + env = await TestEnvironment.create({ defaultVerseRef: verseRef, isRtl: true }); expect(env.component.scriptureStart.value).toBe('LUK 1:1'); expect(env.component.isTextRightToLeft).toBe(true); })); - it('should validate matching book and chapter', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should validate matching book and chapter', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('MAT 1:2'); expect(env.component.scriptureStart.valid).toBe(true); expect(env.component.scriptureStart.errors).toBeNull(); @@ -236,9 +232,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.versesForm.errors).toBeNull(); })); - it('should validate start verse is before or same as end verse', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should validate start verse is before or same as end verse', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('MAT 1:2'); expect(env.component.scriptureStart.valid).toBe(true); expect(env.component.scriptureStart.errors).toBeNull(); @@ -256,9 +251,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.versesForm.errors).toBeNull(); })); - it('opens reference chooser, uses result', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('opens reference chooser, uses result', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('MAT 3:4'); expect(env.component.scriptureStart.value).not.toEqual('LUK 1:2'); @@ -272,9 +266,8 @@ describe('QuestionDialogComponent', () => { })); // Needed for validation error messages to appear - it('control marked as touched+dirty after reference chooser', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('control marked as touched+dirty after reference chooser', fakeAsync(async () => { + env = await TestEnvironment.create(); // scriptureStart control starts off untouched+undirty and changes env.component.scriptureStart.setValue('MAT 3:4'); expect(env.component.scriptureStart.touched).toBe(false); @@ -296,8 +289,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.scriptureEnd.dirty).toBe(true); })); - it('control marked as touched after reference chooser closed', fakeAsync(() => { - env = new TestEnvironment(); + it('control marked as touched after reference chooser closed', fakeAsync(async () => { + env = await TestEnvironment.create(); when(env.mockedScriptureChooserMatDialogRef.afterOpened()).thenReturn(of()); when(env.mockedScriptureChooserMatDialogRef.afterClosed()).thenReturn(of('close')); flush(); @@ -312,9 +305,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.scriptureStart.dirty).toBe(false); })); - it('passes start reference to end-reference chooser', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('passes start reference to end-reference chooser', fakeAsync(async () => { + env = await TestEnvironment.create(); expect(env.component.scriptureEnd.enabled).toBe(false); env.component.scriptureStart.setValue('LUK 1:1'); env.component.scriptureEnd.setValue('GEN 5:6'); @@ -335,9 +327,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.scriptureEnd.value).toEqual('LUK 1:2'); })); - it('does not pass start reference as range start when opening start-reference chooser', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('does not pass start reference as range start when opening start-reference chooser', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('LUK 1:1'); env.clickElement(env.scriptureStartInputIcon); @@ -353,9 +344,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.scriptureStart.value).toEqual('LUK 1:2'); })); - it('disables end-reference if start-reference is invalid', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('disables end-reference if start-reference is invalid', fakeAsync(async () => { + env = await TestEnvironment.create(); tick(EDITOR_READY_TIMEOUT); env.inputValue(env.scriptureStartInput, 'LUK 1:1'); expect(env.component.scriptureEnd.disabled).toBe(false); @@ -366,17 +356,15 @@ describe('QuestionDialogComponent', () => { expect(env.component.scriptureEnd.disabled).toBe(false); })); - it('does not enable end-reference until start-reference is changed', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('does not enable end-reference until start-reference is changed', fakeAsync(async () => { + env = await TestEnvironment.create(); expect(env.component.scriptureEnd.disabled).toBe(true); env.inputValue(env.scriptureStartInput, 'LUK 1:1'); expect(env.component.scriptureEnd.disabled).toBe(false); })); - it('allows a question without text if audio is provided', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('allows a question without text if audio is provided', fakeAsync(async () => { + env = await TestEnvironment.create(); env.inputValue(env.questionInput, ''); env.clickElement(env.saveButton); expect(env.component.textAndAudio?.text.valid).toBe(false); @@ -392,9 +380,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.textAndAudio?.text.errors!.invalid).not.toBeNull(); })); - it('should not save with no text and audio permission is denied', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should not save with no text and audio permission is denied', fakeAsync(async () => { + env = await TestEnvironment.create(); env.inputValue(env.questionInput, ''); env.clickElement(env.saveButton); expect(env.component.textAndAudio?.text.valid).toBe(false); @@ -405,9 +392,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.textAndAudio?.text.errors!.invalid).not.toBeNull(); })); - it('display quill editor', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('display quill editor', fakeAsync(async () => { + env = await TestEnvironment.create(); expect(env.quillEditor).not.toBeNull(); expect(env.component.textDocId).toBeUndefined(); env.component.scriptureStart.setValue('LUK 1:1'); @@ -416,22 +402,24 @@ describe('QuestionDialogComponent', () => { tick(500); const textDocId = new TextDocId('project01', 42, 1, 'target'); expect(env.component.textDocId!.toString()).toBe(textDocId.toString()); - verify(mockedProjectService.getText(deepEqual(textDocId))).once(); + verify(mockedProjectService.getText(deepEqual(textDocId), anything())).once(); expect(env.isSegmentHighlighted('1')).toBe(true); expect(env.isSegmentHighlighted('2')).toBe(false); })); - it('retrieves scripture text on editing a question', fakeAsync(() => { - env = new TestEnvironment({ - dataId: 'question01', - ownerRef: 'user01', - projectRef: 'project01', - verseRef: fromVerseRef(new VerseRef('LUK 1:3')), - answers: [], - isArchived: false, - dateCreated: '', - dateModified: '', - audioUrl: 'test-audio-short.mp3' + it('retrieves scripture text on editing a question', fakeAsync(async () => { + env = await TestEnvironment.create({ + question: { + dataId: 'question01', + ownerRef: 'user01', + projectRef: 'project01', + verseRef: fromVerseRef(new VerseRef('LUK 1:3')), + answers: [], + isArchived: false, + dateCreated: '', + dateModified: '', + audioUrl: 'test-audio-short.mp3' + } }); flush(); const textDocId = new TextDocId('project01', 42, 1, 'target'); @@ -439,21 +427,23 @@ describe('QuestionDialogComponent', () => { tick(EDITOR_READY_TIMEOUT); env.fixture.detectChanges(); expect(env.component.textDocId!.toString()).toBe(textDocId.toString()); - verify(mockedProjectService.getText(deepEqual(textDocId))).once(); + verify(mockedProjectService.getText(deepEqual(textDocId), anything())).once(); expect(env.component.selection!.toString()).toEqual('LUK 1:3'); expect(env.component.textAndAudio?.input?.audioUrl).toBeDefined(); })); - it('displays error editing end reference to different book', fakeAsync(() => { - env = new TestEnvironment({ - dataId: 'question01', - ownerRef: 'user01', - projectRef: 'project01', - verseRef: fromVerseRef(new VerseRef('LUK 1:3')), - answers: [], - isArchived: false, - dateCreated: '', - dateModified: '' + it('displays error editing end reference to different book', fakeAsync(async () => { + env = await TestEnvironment.create({ + question: { + dataId: 'question01', + ownerRef: 'user01', + projectRef: 'project01', + verseRef: fromVerseRef(new VerseRef('LUK 1:3')), + answers: [], + isArchived: false, + dateCreated: '', + dateModified: '' + } }); flush(); tick(EDITOR_READY_TIMEOUT); @@ -468,16 +458,18 @@ describe('QuestionDialogComponent', () => { expect(env.scriptureEndValidationMsg.textContent).toContain('Must be the same book and chapter'); })); - it('displays error editing start reference to a different book', fakeAsync(() => { - env = new TestEnvironment({ - dataId: 'question01', - ownerRef: 'user01', - projectRef: 'project01', - verseRef: fromVerseRef(new VerseRef('LUK 1:3-4')), - answers: [], - isArchived: false, - dateCreated: '', - dateModified: '' + it('displays error editing start reference to a different book', fakeAsync(async () => { + env = await TestEnvironment.create({ + question: { + dataId: 'question01', + ownerRef: 'user01', + projectRef: 'project01', + verseRef: fromVerseRef(new VerseRef('LUK 1:3-4')), + answers: [], + isArchived: false, + dateCreated: '', + dateModified: '' + } }); flush(); tick(EDITOR_READY_TIMEOUT); @@ -489,9 +481,8 @@ describe('QuestionDialogComponent', () => { expect(env.scriptureEndValidationMsg.textContent).toContain('Must be the same book and chapter'); })); - it('generate correct verse ref when start and end mismatch only by case or insignificant zero', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('generate correct verse ref when start and end mismatch only by case or insignificant zero', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('LUK 1:1'); env.component.scriptureEnd.setValue('luk 1:1'); @@ -503,9 +494,8 @@ describe('QuestionDialogComponent', () => { expect(env.component.selection!.toString()).toEqual('LUK 1:1'); })); - it('should handle invalid start reference when end reference exists', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should handle invalid start reference when end reference exists', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('nonsense'); env.component.scriptureEnd.setValue('LUK 1:1'); expect(() => { @@ -513,9 +503,8 @@ describe('QuestionDialogComponent', () => { }).not.toThrow(); })); - it('should not highlight range if chapter or book differ', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should not highlight range if chapter or book differ', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('MAT 1:1'); env.component.scriptureEnd.setValue('LUK 1:2'); tick(500); @@ -524,9 +513,8 @@ describe('QuestionDialogComponent', () => { expect(env.isSegmentHighlighted('1')).toBe(false); })); - it('should clear highlight when starting ref is cleared', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should clear highlight when starting ref is cleared', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('LUK 1:1'); tick(500); env.fixture.detectChanges(); @@ -550,9 +538,8 @@ describe('QuestionDialogComponent', () => { expect(env.isSegmentHighlighted('1')).toBe(true); })); - it('should clear highlight when end ref is invalid', fakeAsync(() => { - env = new TestEnvironment(); - flush(); + it('should clear highlight when end ref is invalid', fakeAsync(async () => { + env = await TestEnvironment.create(); env.component.scriptureStart.setValue('LUK 1:1'); env.component.scriptureEnd.setValue('LUK 1:2'); tick(500); @@ -579,18 +566,35 @@ describe('QuestionDialogComponent', () => { }); class TestEnvironment { - readonly fixture: ComponentFixture; - readonly component: QuestionDialogComponent; - readonly dialogRef: MatDialogRef; - readonly afterCloseCallback: jasmine.Spy; - readonly dialogServiceSpy: DialogService; + fixture: ComponentFixture; + private _component: QuestionDialogComponent | undefined; + dialogRef: MatDialogRef | undefined; + afterCloseCallback: jasmine.Spy | undefined; + private _dialogServiceSpy: DialogService | undefined; readonly mockedScriptureChooserMatDialogRef = mock(MatDialogRef); private readonly realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); - constructor(question?: Question, defaultVerseRef?: VerseRef, isRtl: boolean = false) { + private constructor() { this.fixture = TestBed.createComponent(ChildViewContainerComponent); + } + + static async create({ + question, + defaultVerseRef, + isRtl = false + }: { question?: Question; defaultVerseRef?: VerseRef; isRtl?: boolean } = {}): Promise { + const env = new TestEnvironment(); + await env.init({ question, defaultVerseRef, isRtl }); + return env; + } + + private async init({ + question, + defaultVerseRef, + isRtl = false + }: { question?: Question; defaultVerseRef?: VerseRef; isRtl?: boolean } = {}): Promise { const viewContainerRef = this.fixture.componentInstance.childViewContainer; let questionDoc: QuestionDoc | undefined; if (question != null) { @@ -599,7 +603,11 @@ class TestEnvironment { id: questionId, data: question }); - questionDoc = this.realtimeService.get(QuestionDoc.COLLECTION, questionId); + questionDoc = await this.realtimeService.get( + QuestionDoc.COLLECTION, + questionId, + new DocSubscription('spec') + ); questionDoc.onlineFetch(); } const textsByBookId = { @@ -629,7 +637,11 @@ class TestEnvironment { id: 'project01', data: createTestProjectProfile({ texts: Object.values(textsByBookId) }) }); - const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); + const projectDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); const config: MatDialogConfig = { data: { questionDoc, @@ -655,10 +667,10 @@ class TestEnvironment { this.dialogRef = TestBed.inject(MatDialog).open(QuestionDialogComponent, config); this.afterCloseCallback = jasmine.createSpy('afterClose callback'); this.dialogRef.afterClosed().subscribe(this.afterCloseCallback); - this.component = this.dialogRef.componentInstance; + this._component = this.dialogRef.componentInstance; // Set up dialog mocking after it's already used above (without mocking) in creating the component. - this.dialogServiceSpy = spy(this.component.dialogService); + this._dialogServiceSpy = spy(this.component.dialogService); when(this.dialogServiceSpy.openMatDialog(anything(), anything())).thenReturn( instance(this.mockedScriptureChooserMatDialogRef) ); @@ -667,21 +679,35 @@ class TestEnvironment { this.addTextDoc(new TextDocId('project01', 40, 1)); this.addTextDoc(new TextDocId('project01', 42, 1)); this.addEmptyTextDoc(43); - when(mockedProjectService.getText(anything())).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockedProjectService.getText(anything(), anything())).thenCall(id => + this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), new DocSubscription('spec')) ); - when(mockedProjectService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id.toString()) + when(mockedProjectService.getProfile(anything(), anything())).thenCall(id => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id.toString(), new DocSubscription('spec')) ); when(mockedFileService.findOrUpdateCache(FileType.Audio, anything(), 'question01', anything())).thenResolve( createStorageFileData(QuestionDoc.COLLECTION, 'question01', 'test-audio-short.mp3', getAudioBlob()) ); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01') + this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')) ); this.fixture.detectChanges(); } + get component(): QuestionDialogComponent { + if (this._component == null) { + throw new Error('Uninitialized'); + } + return this._component; + } + + get dialogServiceSpy(): DialogService { + if (this._dialogServiceSpy == null) { + throw new Error('Uninitialized'); + } + return this._dialogServiceSpy; + } + get overlayContainerElement(): HTMLElement { return this.fixture.nativeElement.parentElement.querySelector('.cdk-overlay-container'); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.spec.ts index 5227764ee0c..192574758c1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.spec.ts @@ -16,6 +16,7 @@ import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; import { FileService } from 'xforge-common/file.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; @@ -52,7 +53,7 @@ describe('QuestionDialogService', () => { })); it('should add a question', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); const result: QuestionDialogResult = { text: 'question added', verseRef: new VerseRef('MAT 1:3'), @@ -60,20 +61,20 @@ describe('QuestionDialogService', () => { }; when(env.mockedDialogRef.afterClosed()).thenReturn(of(result)); await env.service.questionDialog(env.getQuestionDialogData()); - verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything(), undefined, undefined)).once(); + verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything(), anything(), undefined, undefined)).once(); expect().nothing(); }); it('should not add a question if cancelled', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); when(env.mockedDialogRef.afterClosed()).thenReturn(of('close')); await env.service.questionDialog(env.getQuestionDialogData()); - verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything())).never(); + verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything(), anything())).never(); expect().nothing(); }); it('should not create question if user does not have permission', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); const result: QuestionDialogResult = { text: 'This question is added just as user role is changed', verseRef: new VerseRef('MAT 1:3'), @@ -82,13 +83,13 @@ describe('QuestionDialogService', () => { when(env.mockedDialogRef.afterClosed()).thenReturn(of(result)); env.updateUserRole(SFProjectRole.CommunityChecker); await env.service.questionDialog(env.getQuestionDialogData()); - verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything())).never(); + verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything(), anything())).never(); verify(mockedNoticeService.show('question_dialog.add_question_denied')).once(); expect().nothing(); }); it('uploads audio when provided', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); const result: QuestionDialogResult = { text: 'question added', verseRef: new VerseRef('MAT 1:3'), @@ -96,26 +97,28 @@ describe('QuestionDialogService', () => { }; when(env.mockedDialogRef.afterClosed()).thenReturn(of(result)); await env.service.questionDialog(env.getQuestionDialogData()); - verify(mockedQuestionsService.createQuestion(env.PROJECT01, anything(), 'someFileName.mp3', anything())).once(); + verify( + mockedQuestionsService.createQuestion(env.PROJECT01, anything(), anything(), 'someFileName.mp3', anything()) + ).once(); expect().nothing(); }); it('edits a question', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); const result: QuestionDialogResult = { text: 'question edited', verseRef: new VerseRef('MAT 1:3'), audio: {} }; when(env.mockedDialogRef.afterClosed()).thenReturn(of(result)); - const questionDoc = env.addQuestion(env.getNewQuestion()); + const questionDoc = await env.addQuestion(env.getNewQuestion()); expect(questionDoc!.data!.text).toBe('question to be edited'); await env.service.questionDialog(env.getQuestionDialogData(questionDoc)); expect(questionDoc!.data!.text).toBe('question edited'); }); it('discards changes if failed to upload or store the audio', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); const result: QuestionDialogResult = { text: 'question added', verseRef: new VerseRef('MAT 1:3'), @@ -134,13 +137,13 @@ describe('QuestionDialogService', () => { anything() ) ).thenResolve(undefined); - const questionDoc = env.addQuestion(env.getNewQuestion()); + const questionDoc = await env.addQuestion(env.getNewQuestion()); const editedQuestion = await env.service.questionDialog(env.getQuestionDialogData(questionDoc)); expect(editedQuestion).toBeUndefined(); }); it('removes audio if audio deleted', async () => { - const env = new TestEnvironment(); + const env = await TestEnvironment.create(); const result: QuestionDialogResult = { text: 'question edited', verseRef: new VerseRef('MAT 1:3'), @@ -148,7 +151,7 @@ describe('QuestionDialogService', () => { }; when(env.mockedDialogRef.afterClosed()).thenReturn(of(result)); const audioUrl = 'anAudioFile.mp3'; - const questionDoc = env.addQuestion(env.getNewQuestion(audioUrl)); + const questionDoc = await env.addQuestion(env.getNewQuestion(audioUrl)); expect(questionDoc!.data!.audioUrl).toBe(audioUrl); await env.service.questionDialog(env.getQuestionDialogData(questionDoc)); expect(questionDoc!.data!.audioUrl).toBeUndefined(); @@ -167,7 +170,7 @@ class TestEnvironment { readonly service: QuestionDialogService; readonly mockedDialogRef = mock>(MatDialogRef); textsByBookId: TextsByBookId; - projectProfileDoc: SFProjectProfileDoc; + projectProfileDoc: SFProjectProfileDoc | undefined; matthewText: TextInfo = { bookNum: 40, hasSource: false, @@ -188,7 +191,7 @@ class TestEnvironment { }); private readonly realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); - constructor() { + private constructor() { this.service = TestBed.inject(QuestionDialogService); this.textsByBookId = { ['MAT']: this.matthewText }; @@ -196,24 +199,38 @@ class TestEnvironment { id: this.PROJECT01, data: this.testProjectProfile }); - this.projectProfileDoc = this.realtimeService.get( - SFProjectProfileDoc.COLLECTION, - this.PROJECT01 - ); when(mockedDialogService.openMatDialog(anything(), anything())).thenReturn(instance(this.mockedDialogRef)); when(mockedUserService.currentUserId).thenReturn(this.adminUser.id); - when(mockedProjectService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) + when(mockedProjectService.getProfile(anything(), anything())).thenCall(id => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, new DocSubscription('spec')) ); } - addQuestion(question: Question): QuestionDoc { + static async create(): Promise { + const env = new TestEnvironment(); + await env.init(); + return env; + } + + private async init(): Promise { + this.projectProfileDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + this.PROJECT01, + new DocSubscription('spec') + ); + } + + async addQuestion(question: Question): Promise { this.realtimeService.addSnapshot(QUESTIONS_COLLECTION, { id: getQuestionDocId(this.PROJECT01, question.dataId), data: question }); - return this.realtimeService.get(QUESTIONS_COLLECTION, getQuestionDocId(this.PROJECT01, question.dataId)); + return await this.realtimeService.get( + QUESTIONS_COLLECTION, + getQuestionDocId(this.PROJECT01, question.dataId), + new DocSubscription('spec') + ); } getNewQuestion(audioUrl?: string): Question { @@ -233,6 +250,7 @@ class TestEnvironment { } getQuestionDialogData(questionDoc?: QuestionDoc): QuestionDialogData { + if (this.projectProfileDoc == null) throw new Error('uninitialized'); return { questionDoc, projectDoc: this.projectProfileDoc, @@ -241,10 +259,11 @@ class TestEnvironment { }; } - updateUserRole(role: string): void { - const projectProfileDoc = this.realtimeService.get( + async updateUserRole(role: string): Promise { + const projectProfileDoc = await this.realtimeService.get( SFProjectProfileDoc.COLLECTION, - this.PROJECT01 + this.PROJECT01, + new DocSubscription('spec') ); const userRole = projectProfileDoc.data!.userRoles; userRole[this.adminUser.id] = role; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts index 18da420a539..72ec05a941c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { TranslocoService } from '@ngneat/transloco'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; @@ -8,6 +8,7 @@ import { fromVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/vers import { lastValueFrom } from 'rxjs'; import { DialogService } from 'xforge-common/dialog.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { UserService } from 'xforge-common/user.service'; import { objectId } from 'xforge-common/utils'; @@ -26,7 +27,8 @@ export class QuestionDialogService { private readonly checkingQuestionsService: CheckingQuestionsService, private readonly userService: UserService, private readonly noticeService: NoticeService, - private readonly transloco: TranslocoService + private readonly transloco: TranslocoService, + private readonly destroyRef: DestroyRef ) {} /** Opens a question dialog that can be used to add a new question or edit an existing question. */ @@ -98,6 +100,7 @@ export class QuestionDialogService { return await this.checkingQuestionsService.createQuestion( config.projectId, newQuestion, + new DocSubscription('QuestionDialogService.questionDialog', this.destroyRef), result.audio.fileName, result.audio.blob ); @@ -105,11 +108,14 @@ export class QuestionDialogService { private async canCreateAndEditQuestions(projectId: string): Promise { const userId = this.userService.currentUserId; - const project = (await this.projectService.getProfile(projectId)).data; - return ( + const docSubscription = new DocSubscription('QuestionDialogService.canCreateAndEditQuestions'); + const project = (await this.projectService.getProfile(projectId, docSubscription)).data; + const result = project != null && SF_PROJECT_RIGHTS.hasRight(project, userId, SFProjectDomain.Questions, Operation.Create) && - SF_PROJECT_RIGHTS.hasRight(project, userId, SFProjectDomain.Questions, Operation.Edit) - ); + SF_PROJECT_RIGHTS.hasRight(project, userId, SFProjectDomain.Questions, Operation.Edit); + + docSubscription.unsubscribe(); + return result; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.spec.ts index 2accc9c62b6..161e04f4e97 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.spec.ts @@ -9,6 +9,7 @@ import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models import { anything, deepEqual, mock, resetCalls, verify, when } from 'ts-mockito'; import { AuthService } from 'xforge-common/auth.service'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -142,7 +143,7 @@ describe('ConnectProjectComponent', () => { expect(env.component.state).toEqual('offline'); })); - it('should create when non-existent project is selected', fakeAsync(() => { + it('should create when non-existent project is selected', fakeAsync(async () => { const env = new TestEnvironment(); env.setupDefaultProjectData(); env.waitForProjectsResponse(); @@ -159,8 +160,8 @@ describe('ConnectProjectComponent', () => { expect(env.component.state).toEqual('connecting'); expect(env.submitButton).toBeNull(); expect(env.progressBar).not.toBeNull(); - env.setQueuedCount(); - env.emitSyncComplete(); + await env.setQueuedCount(); + await env.emitSyncComplete(); const settings: SFProjectCreateSettings = { paratextId: 'pt01', @@ -171,7 +172,7 @@ describe('ConnectProjectComponent', () => { verify(mockedRouter.navigate(deepEqual(['/projects', 'project01']))).once(); })); - it('should create when no setting is selected', fakeAsync(() => { + it('should create when no setting is selected', fakeAsync(async () => { const env = new TestEnvironment(); env.setupDefaultProjectData(); env.waitForProjectsResponse(); @@ -183,8 +184,8 @@ describe('ConnectProjectComponent', () => { expect(env.component.state).toEqual('connecting'); expect(env.progressBar).not.toBeNull(); - env.setQueuedCount(); - env.emitSyncComplete(); + await env.setQueuedCount(); + await env.emitSyncComplete(); const project: SFProjectCreateSettings = { paratextId: 'pt01', @@ -229,7 +230,7 @@ describe('ConnectProjectComponent', () => { verify(mockedRouter.navigate(deepEqual(['/projects', 'project01']))).never(); })); - it('shows error message when resources fail to load, but still allows selecting a based on project', fakeAsync(() => { + it('shows error message when resources fail to load, but still allows selecting a based on project', fakeAsync(async () => { const env = new TestEnvironment(); env.setupDefaultProjectData(); when(mockedParatextService.getResources()).thenReject(new Error('Failed to fetch resources')); @@ -244,8 +245,8 @@ describe('ConnectProjectComponent', () => { env.clickElement(env.submitButton); expect(env.component.state).toEqual('connecting'); - env.setQueuedCount(); - env.emitSyncComplete(); + await env.setQueuedCount(); + await env.emitSyncComplete(); const settings: SFProjectCreateSettings = { paratextId: 'pt01', @@ -350,7 +351,7 @@ class TestEnvironment { private readonly realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); constructor(params: TestEnvironmentParams = { paratextId: null }) { - when(mockedSFProjectService.onlineCreate(anything())).thenCall((settings: SFProjectCreateSettings) => { + when(mockedSFProjectService.onlineCreate(anything())).thenCall(async (settings: SFProjectCreateSettings) => { const newProject: SFProject = createTestProject({ paratextId: settings.paratextId, translateConfig: { @@ -375,11 +376,12 @@ class TestEnvironment { }, paratextUsers: [{ sfUserId: 'user01', username: 'ptuser01', opaqueUserId: 'opaqueuser01' }] }); - this.realtimeService.create(SFProjectDoc.COLLECTION, 'project01', newProject); - return Promise.resolve('project01'); + await this.realtimeService.create(SFProjectDoc.COLLECTION, 'project01', newProject, new DocSubscription('spec')); + return 'project01'; }); - when(mockedSFProjectService.get('project01')).thenCall(() => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'project01') + when(mockedSFProjectService.subscribe('project01', anything())).thenCall( + async () => + await this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'project01', new DocSubscription('spec')) ); if (params.paratextId === undefined) { when(mockedRouter.currentNavigation).thenReturn(signal({ extras: {} } as any)); @@ -485,16 +487,24 @@ class TestEnvironment { return element.nativeElement.querySelector('input') as HTMLInputElement; } - setQueuedCount(): void { - const projectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, 'project01'); - projectDoc.submitJson0Op(op => op.set(p => p.sync.queuedCount, 1), false); + async setQueuedCount(): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op(op => op.set(p => p.sync.queuedCount, 1), false); tick(); this.fixture.detectChanges(); } - emitSyncComplete(): void { - const projectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, 'project01'); - projectDoc.submitJson0Op(op => { + async emitSyncComplete(): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op(op => { op.set(p => p.sync.queuedCount, 0); op.set(p => p.sync.lastSyncSuccessful!, true); op.set(p => p.sync.dateLastSuccessfulSync!, new Date().toJSON()); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.ts index 1e1d40f45be..9df1cc35936 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/connect-project/connect-project.component.ts @@ -11,6 +11,7 @@ import { TranslocoModule, TranslocoService } from '@ngneat/transloco'; import { AuthService } from 'xforge-common/auth.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -176,7 +177,10 @@ export class ConnectProjectComponent extends DataLoadingComponent implements OnI void this.populateProjectList(); return; } - this.projectDoc = await this.projectService.get(projectId); + this.projectDoc = await this.projectService.subscribe( + projectId, + new DocSubscription('ConnectProjectComponent', this.destroyRef) + ); } updateStatus(inProgress: boolean): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/note-thread-doc.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/note-thread-doc.spec.ts index 4d8c9d08dff..9953ebc9351 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/note-thread-doc.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/note-thread-doc.spec.ts @@ -10,6 +10,7 @@ import { NoteType } from 'realtime-server/lib/esm/scriptureforge/models/note-thread'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; import { configureTestingModule } from 'xforge-common/test-utils'; @@ -264,7 +265,7 @@ class TestEnvironment { id: threadId, data: thread }); - return this.realtimeService.subscribe(NoteThreadDoc.COLLECTION, threadId); + return this.realtimeService.subscribe(NoteThreadDoc.COLLECTION, threadId, new DocSubscription('spec')); } private getNoteThread(notes: Note[]): NoteThread { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/sf-project-base-doc.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/sf-project-base-doc.ts index a601cd01c38..49c9ed90539 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/sf-project-base-doc.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/sf-project-base-doc.ts @@ -1,10 +1,8 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; -import { TEXTS_COLLECTION } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { ProjectDoc } from 'xforge-common/models/project-doc'; -import { RealtimeDoc } from 'xforge-common/models/realtime-doc'; import { QuestionDoc } from './question-doc'; import { SFProjectUserConfigDoc } from './sf-project-user-config-doc'; -import { TextDoc, TextDocId } from './text-doc'; +import { TextDoc } from './text-doc'; export abstract class SFProjectBaseDoc extends ProjectDoc { get taskNames(): string[] { @@ -18,23 +16,6 @@ export abstract class SFProjectBaseDoc extends Proje return names; } - loadTextDocs(bookNum?: number): Promise { - const texts: Promise[] = []; - for (const textDocId of this.getTextDocs(bookNum)) { - texts.push(this.realtimeService.subscribe(TEXTS_COLLECTION, textDocId.toString())); - } - return Promise.all(texts); - } - - async unLoadTextDocs(bookNum?: number): Promise { - for (const textDocId of this.getTextDocs(bookNum)) { - if (this.realtimeService.isSet(TEXTS_COLLECTION, textDocId.toString())) { - const doc = this.realtimeService.get(TEXTS_COLLECTION, textDocId.toString()); - await doc.dispose(); - } - } - } - protected async onDelete(): Promise { await super.onDelete(); await this.deleteProjectDocs(SFProjectUserConfigDoc.COLLECTION); @@ -42,20 +23,6 @@ export abstract class SFProjectBaseDoc extends Proje await this.deleteProjectDocs(QuestionDoc.COLLECTION); } - private getTextDocs(bookNum?: number): TextDocId[] { - const texts: TextDocId[] = []; - if (this.data != null) { - for (const text of this.data.texts) { - if (bookNum == null || bookNum === text.bookNum) { - for (const chapter of text.chapters) { - texts.push(new TextDocId(this.id, text.bookNum, chapter.number, 'target')); - } - } - } - } - return texts; - } - private async deleteProjectDocs(collection: string): Promise { const tasks: Promise[] = []; for (const id of await this.realtimeService.offlineStore.getAllIds(collection)) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts index 71b4d613bdf..89a1d56fd1e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts @@ -8,6 +8,7 @@ import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scripture import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; @@ -159,7 +160,7 @@ describe('PermissionsService', () => { expect(await env.service.userHasParatextRoleOnProject('project01')).toBe(true); env.setCurrentUser('other'); expect(await env.service.userHasParatextRoleOnProject('project01')).toBe(false); - verify(mockedProjectService.getProfile('project01')).twice(); + verify(mockedProjectService.getProfile('project01', anything())).twice(); })); describe('canSync', () => { @@ -250,12 +251,8 @@ class TestEnvironment { constructor(readonly checkingEnabled = true) { this.service = TestBed.inject(PermissionsService); - when(mockedProjectService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) - ); - - when(mockedProjectService.get(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) + when(mockedProjectService.getProfile(anything(), anything())).thenCall(id => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, new DocSubscription('spec')) ); this.setProjectProfile(); @@ -305,7 +302,9 @@ class TestEnvironment { setCurrentUser(userId: string = 'user01'): void { when(mockedUserService.currentUserId).thenReturn(userId); - when(mockedUserService.getCurrentUser()).thenCall(() => this.realtimeService.subscribe(UserDoc.COLLECTION, userId)); + when(mockedUserService.getCurrentUser()).thenCall(() => + this.realtimeService.subscribe(UserDoc.COLLECTION, userId, new DocSubscription('spec')) + ); } setupUserData(userId: string = 'user01', projects: string[] = ['project01']): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts index 3b832fb4eb8..6f0bb9aab1a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts @@ -5,6 +5,7 @@ import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scri import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { UserService } from 'xforge-common/user.service'; import { environment } from '../../environments/environment'; @@ -54,9 +55,14 @@ export class PermissionsService { } async userHasParatextRoleOnProject(projectId: string): Promise { + const docSubscription = new DocSubscription('PermissionsService.userHasParatextRoleOnProject'); const currentUserDoc: UserDoc = await this.userService.getCurrentUser(); - const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(projectId); - return isParatextRole(projectDoc.data?.userRoles[currentUserDoc.id] ?? SFProjectRole.None); + try { + const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(projectId, docSubscription); + return isParatextRole(projectDoc.data?.userRoles[currentUserDoc.id] ?? SFProjectRole.None); + } finally { + docSubscription.unsubscribe(); + } } /** @@ -93,15 +99,20 @@ export class PermissionsService { * @returns A promise for a boolean value. */ async canAccessTextAsync(textDocId: TextDocId): Promise { - // Get the project doc, if the user is on that project - let projectDoc: SFProjectProfileDoc | undefined; - if (textDocId.projectId != null) { - const isUserOnProject = await this.isUserOnProject(textDocId.projectId); - projectDoc = isUserOnProject ? await this.projectService.getProfile(textDocId.projectId) : undefined; - return this.canAccessText(textDocId, projectDoc?.data); + if (textDocId.projectId == null) return false; + const isUserOnProject = await this.isUserOnProject(textDocId.projectId); + if (!isUserOnProject) return false; + + const docSubscription = new DocSubscription('PermissionsService.canAccessTextAsync'); + try { + const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile( + textDocId.projectId, + docSubscription + ); + return this.canAccessText(textDocId, projectDoc.data); + } finally { + docSubscription.unsubscribe(); } - - return false; } canSync(projectDoc: SFProjectProfileDoc, userId?: string): boolean { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts index 546c68bef06..b1793082c0f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts @@ -21,6 +21,7 @@ import { import { Subject } from 'rxjs'; import { CommandService } from 'xforge-common/command.service'; import { LocationService } from 'xforge-common/location.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { ProjectService } from 'xforge-common/project.service'; import { QueryParameters, QueryResults } from 'xforge-common/query-parameters'; @@ -54,9 +55,10 @@ export class SFProjectService extends ProjectService { realtimeService: RealtimeService, commandService: CommandService, private readonly locationService: LocationService, - protected readonly retryingRequestService: RetryingRequestService + protected readonly retryingRequestService: RetryingRequestService, + destroyRef: DestroyRef ) { - super(realtimeService, commandService, retryingRequestService, SF_PROJECT_ROLES); + super(realtimeService, commandService, retryingRequestService, SF_PROJECT_ROLES, destroyRef); } /** @@ -123,29 +125,32 @@ export class SFProjectService extends ProjectService { * Returns the SF project if the user has a role that allows access (i.e. a paratext role), * otherwise returns undefined. */ - async tryGetForRole(id: string, role: string): Promise { + async tryGetForRole(id: string, role: string, subscriber: DocSubscription): Promise { if (SF_PROJECT_RIGHTS.roleHasRight(role, SFProjectDomain.Project, Operation.View)) { - return await this.get(id); + return await this.subscribe(id, subscriber); } return undefined; } /** Returns the project profile with the project data that all project members can access. */ - getProfile(id: string): Promise { - return this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id); + getProfile(id: string, docSubscription: DocSubscription): Promise { + return this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, docSubscription); } - getUserConfig(id: string, userId: string): Promise { - return this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, getSFProjectUserConfigDocId(id, userId)); + getUserConfig(id: string, userId: string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe( + SFProjectUserConfigDoc.COLLECTION, + getSFProjectUserConfigDocId(id, userId), + subscriber + ); } async isProjectAdmin(projectId: string, userId: string): Promise { - const projectDoc = await this.getProfile(projectId); - return ( - projectDoc != null && - projectDoc.data != null && - projectDoc.data.userRoles[userId] === SFProjectRole.ParatextAdministrator - ); + const docSubscription = new DocSubscription('SFProjectService.isProjectAdmin'); + const projectDoc = await this.getProfile(projectId, docSubscription); + const result = projectDoc.data?.userRoles[userId] === SFProjectRole.ParatextAdministrator; + docSubscription.unsubscribe(); + return result; } /** @@ -159,21 +164,25 @@ export class SFProjectService extends ProjectService { return this.onlineInvoke('addTranslateMetrics', { projectId: id, metrics }); } - getText(textId: TextDocId | string): Promise { - return this.realtimeService.subscribe(TextDoc.COLLECTION, textId instanceof TextDocId ? textId.toString() : textId); + getText(textId: TextDocId | string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe( + TextDoc.COLLECTION, + textId instanceof TextDocId ? textId.toString() : textId, + subscriber + ); } - getNoteThread(threadDataId: string): Promise { - return this.realtimeService.subscribe(NoteThreadDoc.COLLECTION, threadDataId); + getNoteThread(threadDataId: string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe(NoteThreadDoc.COLLECTION, threadDataId, subscriber); } - getBiblicalTerm(biblicalTermId: string): Promise { - return this.realtimeService.subscribe(BiblicalTermDoc.COLLECTION, biblicalTermId); + getBiblicalTerm(biblicalTermId: string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe(BiblicalTermDoc.COLLECTION, biblicalTermId, subscriber); } - async createNoteThread(projectId: string, noteThread: NoteThread): Promise { + async createNoteThread(projectId: string, noteThread: NoteThread, subscriber: DocSubscription): Promise { const docId: string = getNoteThreadDocId(projectId, noteThread.dataId); - await this.realtimeService.create(NoteThreadDoc.COLLECTION, docId, noteThread); + await this.realtimeService.create(NoteThreadDoc.COLLECTION, docId, noteThread, subscriber); } generateSharingUrl(shareKey: string, localeCode?: string): string { @@ -196,14 +205,14 @@ export class SFProjectService extends ProjectService { [obj().pathStr(t => t.verseRef.bookNum)]: bookNum, [obj().pathStr(t => t.verseRef.chapterNum)]: chapterNum }; - return this.realtimeService.subscribeQuery(NoteThreadDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery(NoteThreadDoc.COLLECTION, 'query_note_threads', queryParams, destroyRef); } queryAudioText(sfProjectId: string, destroyRef: DestroyRef): Promise> { const queryParams: QueryParameters = { [obj().pathStr(t => t.projectRef)]: sfProjectId }; - return this.realtimeService.subscribeQuery(TextAudioDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery(TextAudioDoc.COLLECTION, 'query_audio_text', queryParams, destroyRef); } queryBiblicalTerms( @@ -222,7 +231,12 @@ export class SFProjectService extends ProjectService { } } }; - return this.realtimeService.subscribeQuery(BiblicalTermDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery( + BiblicalTermDoc.COLLECTION, + 'query_biblical_terms', + queryParams, + destroyRef + ); } queryBiblicalTermNoteThreads(sfProjectId: string, destroyRef: DestroyRef): Promise> { @@ -230,7 +244,12 @@ export class SFProjectService extends ProjectService { [obj().pathStr(t => t.projectRef)]: sfProjectId, [obj().pathStr(t => t.biblicalTermId)]: { $ne: null } }; - return this.realtimeService.subscribeQuery(NoteThreadDoc.COLLECTION, parameters, destroyRef); + return this.realtimeService.subscribeQuery( + NoteThreadDoc.COLLECTION, + 'query_biblical_term_note_threads', + parameters, + destroyRef + ); } onlineSync(id: string): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts index dbe1e5e97e6..59df72ccfbb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts @@ -6,7 +6,8 @@ import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-dat import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import * as RichText from 'rich-text'; -import { mock, when } from 'ts-mockito'; +import { anything, mock, when } from 'ts-mockito'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; import { configureTestingModule } from 'xforge-common/test-utils'; @@ -29,19 +30,19 @@ describe('TextDocService', () => { ] })); - it('should overwrite text doc', fakeAsync(() => { + it('should overwrite text doc', fakeAsync(async () => { const env = new TestEnvironment(); const newDelta: Delta = getCombinedVerseTextDoc(env.textDocId) as Delta; env.textDocService.overwrite(env.textDocId, newDelta, 'Editor'); tick(); - expect(env.getTextDoc(env.textDocId).data?.ops).toEqual(newDelta.ops); + expect((await env.getTextDoc(env.textDocId)).data?.ops).toEqual(newDelta.ops); })); - it('should emit diff', fakeAsync(() => { + it('should emit diff', fakeAsync(async () => { const env = new TestEnvironment(); - const origDelta: Delta = env.getTextDoc(env.textDocId).data as Delta; + const origDelta: Delta = (await env.getTextDoc(env.textDocId)).data as Delta; const newDelta: Delta = getPoetryVerseTextDoc(env.textDocId) as Delta; const diff: Delta = origDelta.diff(newDelta); @@ -53,11 +54,11 @@ describe('TextDocService', () => { tick(); })); - it('should submit the source', fakeAsync(() => { + it('should submit the source', fakeAsync(async () => { const env = new TestEnvironment(); const newDelta: Delta = getPoetryVerseTextDoc(env.textDocId) as Delta; - const textDoc = env.getTextDoc(env.textDocId); + const textDoc = await env.getTextDoc(env.textDocId); textDoc.adapter.changes$.subscribe(() => { // overwrite() resets submitSource to false, so we check it when the op is submitted expect(textDoc.adapter.submitSource).toBe(true); @@ -453,13 +454,13 @@ class TestEnvironment { type: RichText.type.name }); - when(mockProjectService.getText(this.textDocId)).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockProjectService.getText(this.textDocId, anything())).thenCall(id => + this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), new DocSubscription('spec')) ); when(mockUserService.currentUserId).thenReturn('user01'); } - getTextDoc(textId: TextDocId): TextDoc { - return this.realtimeService.get(TextDoc.COLLECTION, textId.toString()); + async getTextDoc(textId: TextDocId): Promise { + return await this.realtimeService.get(TextDoc.COLLECTION, textId.toString(), new DocSubscription('spec')); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts index 93328512044..ba625648c40 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts @@ -7,6 +7,7 @@ import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-dat import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import { Observable, Subject } from 'rxjs'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserService } from 'xforge-common/user.service'; import { TextDoc, TextDocId, TextDocSource } from './models/text-doc'; import { SFProjectService } from './sf-project.service'; @@ -29,20 +30,25 @@ export class TextDocService { * @param {TextDocSource} source The source of the op. This is sent to the server. */ async overwrite(textDocId: TextDocId, newDelta: Delta, source: TextDocSource): Promise { - const textDoc: TextDoc = await this.projectService.getText(textDocId); + const docSubscription = new DocSubscription('TextDocService'); + try { + const textDoc: TextDoc = await this.projectService.getText(textDocId, docSubscription); - if (textDoc.data?.ops == null) { - throw new Error(`No TextDoc data for ${textDocId}`); - } + if (textDoc.data?.ops == null) { + throw new Error(`No TextDoc data for ${textDocId}`); + } - const origDelta: Delta = new Delta(textDoc.data.ops); - const diff: Delta = origDelta.diff(newDelta); + const origDelta: Delta = new Delta(textDoc.data.ops); + const diff: Delta = origDelta.diff(newDelta); - // Update text doc directly - await textDoc.submit(diff, source); + // Update text doc directly + await textDoc.submit(diff, source); - // Notify so that TextViewModels can update - this.getLocalSystemChangesInternal$(textDocId).next(diff); + // Notify so that TextViewModels can update + this.getLocalSystemChangesInternal$(textDocId).next(diff); + } finally { + docSubscription.unsubscribe(); + } } /** diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/translation-engine.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/translation-engine.service.ts index d44f47843e0..d7b268a2f3c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/translation-engine.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/translation-engine.service.ts @@ -9,6 +9,7 @@ import { getTextDocId } from 'realtime-server/lib/esm/scriptureforge/models/text import { Observable } from 'rxjs'; import { filter, share } from 'rxjs/operators'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OfflineData, OfflineStore } from 'xforge-common/offline-store'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -140,7 +141,10 @@ export class TranslationEngineService { segment: string, checksum?: number ): Promise { - const targetDoc = await this.projectService.getText(getTextDocId(projectRef, bookNum, chapterNum, 'target')); + const targetDoc = await this.projectService.getText( + getTextDocId(projectRef, bookNum, chapterNum, 'target'), + new DocSubscription('TranslationEngineService', this.destroyRef) + ); const targetText = targetDoc.getSegmentText(segment); if (targetText === '') { return; @@ -152,7 +156,10 @@ export class TranslationEngineService { } } - const sourceDoc = await this.projectService.getText(getTextDocId(sourceProjectRef, bookNum, chapterNum, 'source')); + const sourceDoc = await this.projectService.getText( + getTextDocId(sourceProjectRef, bookNum, chapterNum, 'source'), + new DocSubscription('TranslationEngineService', this.destroyRef) + ); const sourceText = sourceDoc.getSegmentText(segment); if (sourceText === '') { return; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.spec.ts index 868fe343f24..cf44f446b4b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.spec.ts @@ -91,10 +91,10 @@ describe('EventMetricsAuthGuard', () => { when(mockedAuthService.currentUserRoles).thenReturn([role]); when(mockedUserService.currentUserId).thenReturn(user01); - when(mockedProjectService.getProfile(project01)).thenResolve({ + when(mockedProjectService.getProfile(project01, anything())).thenResolve({ data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextAdministrator } }) } as SFProjectProfileDoc); - when(mockedProjectService.getProfile(project02)).thenResolve({ + when(mockedProjectService.getProfile(project02, anything())).thenResolve({ data: createTestProjectProfile() } as SFProjectProfileDoc); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.ts index 5d63b1977bb..2ae1b4d9f0c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-auth.guard.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { AuthGuard } from 'xforge-common/auth.guard'; @@ -16,9 +16,10 @@ export class EventMetricsAuthGuard extends RouterGuard { readonly authGuard: AuthGuard, private readonly authService: AuthService, readonly projectService: SFProjectService, - private readonly userService: UserService + private readonly userService: UserService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts index 786e34a00f8..02df62bbf60 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts @@ -98,13 +98,13 @@ describe('MyProjectsComponent', () => { expect(env.router.url).toEqual('/projects/sf-cbntt'); })); - it('user cannot join a project while offline', fakeAsync(() => { + it('user cannot join a project while offline', fakeAsync(async () => { const env = new TestEnvironment(); env.waitUntilLoaded(); env.onlineStatus = false; expect(env.messageOffline).not.toBeNull(); expect(env.buttonForUnconnectedProject('pt-connButNotThisUser').nativeElement.disabled).toBe(true); - env.component.joinProject('sf-cbntt'); + await env.component.joinProject('sf-cbntt'); tick(); env.fixture.detectChanges(); verify(mockedNoticeService.show(anything())).once(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts index 7511f455743..0d0bc6321c5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts @@ -73,8 +73,8 @@ function setUpMocks(args: StoryState): void { const realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); realtimeService.addSnapshot(SFProjectProfileDoc.COLLECTION, { id: projectId, data: project }); - when(mockedSFProjectService.getProfile(anything())).thenCall(id => - realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) + when(mockedSFProjectService.getProfile(anything(), anything())).thenCall((id, subscription) => + realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, subscription) ); testActivatedProjectService = TestActivatedProjectService.withProjectId(projectId); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts index 0476b2e99d2..70b09eea802 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts @@ -1,6 +1,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, ActivatedRouteSnapshot, Router } from '@angular/router'; import { TranslocoService } from '@ngneat/transloco'; import { User } from 'realtime-server/lib/esm/common/models/user'; @@ -16,6 +16,7 @@ import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scripturefo import { of } from 'rxjs'; import { anything, deepEqual, mock, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; @@ -175,6 +176,7 @@ describe('ProjectComponent', () => { verify(mockedRouter.navigate(anything(), anything())).never(); env.addUserToProject(1); + flushMicrotasks(); verify(mockedRouter.navigate(deepEqual(['projects', 'project1', 'translate', 'MAT']), anything())).once(); expect().nothing(); })); @@ -190,7 +192,7 @@ class TestEnvironment { when(mockedUserService.currentUserId).thenReturn('user01'); when(mockedUserService.currentProjectId(anything())).thenReturn('project1'); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01') + this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')) ); when(mockedTranslocoService.translate(anything())).thenReturn('The project link is invalid.'); const snapshot = new ActivatedRouteSnapshot(); @@ -274,9 +276,9 @@ class TestEnvironment { }); } - addUserToProject(projectIdSuffix: number): void { + async addUserToProject(projectIdSuffix: number): Promise { this.setProjectData({ memberProjectIdSuffixes: [projectIdSuffix] }); - const userDoc: UserDoc = this.realtimeService.get(UserDoc.COLLECTION, 'user01'); + const userDoc: UserDoc = await this.realtimeService.get(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')); userDoc.submitJson0Op(op => op.set(u => u.sites, { sf: { projects: [`project${projectIdSuffix}`] } }), false); tick(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts index 3347d4919fd..8aff6cca694 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts @@ -6,6 +6,7 @@ import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/mode import { lastValueFrom, Observable } from 'rxjs'; import { distinctUntilChanged, filter, first, map } from 'rxjs/operators'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { UserService } from 'xforge-common/user.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -73,8 +74,12 @@ export class ProjectComponent extends DataLoadingComponent implements OnInit { try { const [projectUserConfigDoc, projectDoc] = await Promise.all([ - this.projectService.getUserConfig(projectId, this.userService.currentUserId), - this.projectService.getProfile(projectId) + this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('ProjectComponent', this.destroyRef) + ), + this.projectService.getProfile(projectId, new DocSubscription('ProjectComponent', this.destroyRef)) ]); const projectUserConfig = projectUserConfigDoc.data; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts index 9e5194da6b5..9110880ed11 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts @@ -834,7 +834,7 @@ class TestEnvironment { // Mock project names via ServalAdministrationService for projects in JSON const projectIds = [...new Set(eventMetrics.map(e => e.projectId).filter((id): id is string => id != null))]; projectIds.forEach(projectId => { - when(mockedServalAdministrationService.get(projectId)).thenResolve({ + when(mockedServalAdministrationService.subscribe(projectId, anything())).thenResolve({ id: projectId, data: { name: `Project ${projectId.substring(0, 8)}`, shortName: projectId.substring(0, 4) } } as any); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts index 3932a32d9c1..7530a252c7a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts @@ -26,6 +26,7 @@ import { AuthService } from 'xforge-common/auth.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { OwnerComponent } from 'xforge-common/owner/owner.component'; @@ -332,7 +333,10 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { let displayName = projectFilterId ?? ''; if (projectFilterId != null) { try { - const projectDoc = await this.servalAdministrationService.get(projectFilterId); + const projectDoc = await this.servalAdministrationService.subscribe( + projectFilterId, + new DocSubscription('DraftJobsComponent', this.destroyRef) + ); displayName = projectDoc?.data != null ? projectLabel(projectDoc.data) : projectFilterId; } catch (error) { // We can filter for a now-deleted project, so an error here is not unexpected and fully supported @@ -805,7 +809,10 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { this.projectShortNames.clear(); // Fetch project data for each unique project ID for (const projectId of projectIds) { - const projectDoc = await this.servalAdministrationService.get(projectId); + const projectDoc = await this.servalAdministrationService.subscribe( + projectId, + new DocSubscription('DraftJobsComponent', this.destroyRef) + ); if (projectDoc?.data != null) { this.projectNames.set(projectId, projectLabel(projectDoc.data)); this.projectShortNames.set(projectId, projectDoc.data.shortName || undefined); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.spec.ts index c8ac15ed686..9bd1230a972 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.spec.ts @@ -290,13 +290,15 @@ describe('OnboardingRequestDetailComponent', () => { 'user02', 'user03' ]); - when(mockedServalAdministrationService.getByParatextId(anything())).thenResolve(undefined); + when(mockedServalAdministrationService.getByParatextId(anything(), anything())).thenResolve(undefined); // Specific paratextId overrides must be registered after the anything() stub so they take priority for (const [paratextId, doc] of options.projectDocsByParatextId ?? []) { - when(mockedServalAdministrationService.getByParatextId(paratextId)).thenResolve(doc); + when(mockedServalAdministrationService.getByParatextId(paratextId, anything())).thenResolve(doc); } if (options.mainProjectDoc != null) { - when(mockedServalAdministrationService.get(request.submission.projectId)).thenResolve(options.mainProjectDoc); + when(mockedServalAdministrationService.subscribe(request.submission.projectId, anything())).thenResolve( + options.mainProjectDoc + ); } if ('approveDialogResult' in options) { when(this.approveDialogRef.afterClosed()).thenReturn(of(options.approveDialogResult)); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.ts index cae93c95efb..1fb87f91e64 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-request-detail/onboarding-request-detail.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card'; @@ -22,6 +22,7 @@ import { ProjectType } from 'realtime-server/lib/esm/scriptureforge/models/trans import { catchError, lastValueFrom, of, throwError } from 'rxjs'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OwnerComponent } from 'xforge-common/owner/owner.component'; import { RouterLinkDirective } from 'xforge-common/router-link.directive'; @@ -112,7 +113,8 @@ export class OnboardingRequestDetailComponent extends DataLoadingComponent imple private readonly dialogService: DialogService, protected readonly userService: UserService, private readonly projectService: SFProjectService, - protected readonly noticeService: NoticeService + protected readonly noticeService: NoticeService, + private readonly destroyRef: DestroyRef ) { super(noticeService, 'OnboardingRequestDetailComponent'); } @@ -158,7 +160,10 @@ export class OnboardingRequestDetailComponent extends DataLoadingComponent imple } // Load the main project (submission.projectId is an SF project ID) - this.mainProjectDoc = await this.servalAdministrationService.get(this.request.submission.projectId); + this.mainProjectDoc = await this.servalAdministrationService.subscribe( + this.request.submission.projectId, + new DocSubscription('OnboardingRequestDetailComponent', this.destroyRef) + ); if (this.mainProjectDoc?.data != null) { this.projectDocs.set(this.request.submission.projectId, this.mainProjectDoc); this.projectNames.set(this.request.submission.projectId, projectLabel(this.mainProjectDoc.data)); @@ -181,7 +186,10 @@ export class OnboardingRequestDetailComponent extends DataLoadingComponent imple // Load each form data project's name and ID by querying by paratextId for (const paratextId of paratextIds) { - const projectDoc = await this.servalAdministrationService.getByParatextId(paratextId); + const projectDoc = await this.servalAdministrationService.getByParatextId( + paratextId, + new DocSubscription('OnboardingRequestDetailComponent', this.destroyRef) + ); if (projectDoc?.data != null) { this.projectDocs.set(paratextId, projectDoc); this.projectNames.set(paratextId, projectLabel(projectDoc.data)); @@ -218,8 +226,15 @@ export class OnboardingRequestDetailComponent extends DataLoadingComponent imple this.loadingStarted(); // Get the project to retrieve its shortName - const projectDoc = await this.servalAdministrationService.get(id); - if (projectDoc?.data?.shortName == null) { + const projectDocSubscription = new DocSubscription('OnboardingRequestDetailComponent.downloadProject'); + let shortName: string | undefined; + try { + const projectDoc = await this.servalAdministrationService.subscribe(id, projectDocSubscription); + shortName = projectDoc?.data?.shortName; + } finally { + projectDocSubscription.unsubscribe(); + } + if (shortName == null) { this.noticeService.showError('Unable to retrieve project information.'); this.loadingFinished(); return; @@ -247,7 +262,7 @@ export class OnboardingRequestDetailComponent extends DataLoadingComponent imple } // Use the FileSaver API to download the file with the project's shortName - saveAs(blob, projectDoc.data.shortName + '.zip'); + saveAs(blob, shortName + '.zip'); this.loadingFinished(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.spec.ts index cbdaebc09e5..fc3866d2873 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.spec.ts @@ -141,12 +141,12 @@ class TestEnvironment { when(mockedOnboardingRequestService.getResolution(anything())).thenCall((key: OnboardingRequestResolutionKey) => ONBOARDING_REQUEST_RESOLUTION_OPTIONS.find(option => option.key === key) ); - when(mockedServalAdministrationService.get(PROJECT_ID)).thenResolve({ + when(mockedServalAdministrationService.subscribe(PROJECT_ID, anything())).thenResolve({ id: PROJECT_ID, data: { shortName: PROJECT_SHORT_NAME } } as any); - when(mockedServalAdministrationService.get(UNKNOWN_PROJECT_ID)).thenResolve(undefined as any); - when(mockedUserService.get(ASSIGNEE_ID)).thenResolve({ + when(mockedServalAdministrationService.subscribe(UNKNOWN_PROJECT_ID, anything())).thenResolve(undefined as any); + when(mockedUserService.get(ASSIGNEE_ID, anything())).thenResolve({ data: { displayName: ASSIGNEE_NAME } } as unknown as UserDoc); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.ts index cab174fbcfc..3758025c522 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests-export.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import Papa from 'papaparse'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserService } from 'xforge-common/user.service'; import { isPopulatedString } from '../../../type-utils'; import { parseDate } from '../../shared/utils'; @@ -91,8 +92,14 @@ export class OnboardingRequestsExportService { assigneeNames: Map ): Promise { const sfProjectId = request.submission.projectId; - const projectDoc = await this.servalAdministrationService.get(sfProjectId); - const projectShortName = projectDoc?.data?.shortName ?? sfProjectId; + const projectDocSubscription = new DocSubscription('OnboardingRequestsExportService.createSpreadsheetRow'); + let projectShortName: string; + try { + const projectDoc = await this.servalAdministrationService.subscribe(sfProjectId, projectDocSubscription); + projectShortName = projectDoc?.data?.shortName ?? sfProjectId; + } finally { + projectDocSubscription.unsubscribe(); + } return { request_date_utc: this.formatRequestDateUtc(request.submission.timestamp) ?? '', @@ -112,8 +119,15 @@ export class OnboardingRequestsExportService { const assigneeIds = [...new Set(requests.map(request => request.assigneeId).filter(isPopulatedString))]; const entries = await Promise.all( assigneeIds.map(async assigneeId => { - const userDoc = await this.userService.get(assigneeId); - return [assigneeId, userDoc?.data?.displayName ?? ''] as const; + const docSubscription = new DocSubscription('OnboardingRequestsExportService.lookupAssigneeNames'); + let displayName: string; + try { + const userDoc = await this.userService.get(assigneeId, docSubscription); + displayName = userDoc?.data?.displayName ?? ''; + } finally { + docSubscription.unsubscribe(); + } + return [assigneeId, displayName] as const; }) ); return new Map(entries); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests.component.ts index 4b69d747175..8b672fb051e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/onboarding-requests/onboarding-requests.component.ts @@ -13,6 +13,7 @@ import { MatTableModule } from '@angular/material/table'; import { TranslocoModule } from '@ngneat/transloco'; import { saveAs } from 'file-saver'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OwnerComponent } from 'xforge-common/owner/owner.component'; import { RouterLinkDirective } from 'xforge-common/router-link.directive'; @@ -106,6 +107,7 @@ export class OnboardingRequestsComponent extends DataLoadingComponent implements displayedColumns: string[] = ['status', 'project', 'languageCode', 'user', 'email', 'assignee', 'resolution']; currentUserId?: string; assignedUserIds: Set = new Set(); + userDisplayNames: Map = new Map(); projectNames: Map = new Map(); filterOptions = filterOptions; @@ -154,11 +156,16 @@ export class OnboardingRequestsComponent extends DataLoadingComponent implements // Fetch project data for each unique project ID for (const projectId of projectIds) { - const projectDoc = await this.servalAdministrationService.get(projectId); - if (projectDoc?.data != null) { - this.projectNames.set(projectId, projectLabel(projectDoc.data)); - } else { - this.projectNames.set(projectId, projectId); + const docSubscription = new DocSubscription('OnboardingRequests.loadProjectNames'); + try { + const projectDoc = await this.servalAdministrationService.subscribe(projectId, docSubscription); + if (projectDoc?.data != null) { + this.projectNames.set(projectId, projectLabel(projectDoc.data)); + } else { + this.projectNames.set(projectId, projectId); + } + } finally { + docSubscription.unsubscribe(); } } } @@ -192,6 +199,69 @@ export class OnboardingRequestsComponent extends DataLoadingComponent implements return this.projectNames.get(projectId) ?? projectId; } + /** + * Gets the list of user IDs to show in the assignee dropdown (excluding "Unassigned"). + * Includes current user first, then all users assigned to other requests. + */ + getAssignedUserOptions(): string[] { + const options: string[] = []; + + // Add current user first if available + if (this.currentUserId != null) { + options.push(this.currentUserId); + void this.cacheUserDisplayName(this.currentUserId); + } + + // Add all other assigned users + this.assignedUserIds.forEach(userId => { + if (userId !== this.currentUserId && !options.includes(userId)) { + options.push(userId); + void this.cacheUserDisplayName(userId); + } + }); + + return options; + } + + /** + * Caches the display name for a user ID. + */ + private async cacheUserDisplayName(userId: string): Promise { + if (!this.userDisplayNames.has(userId)) { + try { + const docSubscription = new DocSubscription('OnboardingRequests.cacheUserDisplayName'); + try { + const userDoc = await this.userService.getProfile(userId, docSubscription); + if (userDoc?.data != null) { + const displayName = this.currentUserId === userId ? 'Me' : userDoc.data.displayName || 'Unknown User'; + this.userDisplayNames.set(userId, displayName); + } + } finally { + docSubscription.unsubscribe(); + } + } catch (error) { + console.error('Error loading user display name:', error); + this.userDisplayNames.set(userId, 'Unknown User'); + } + } + } + + /** Gets the display name for a user ID. */ + getUserDisplayName(userId: string): string { + return this.userDisplayNames.get(userId) || 'Loading...'; + } + + getStatus = this.onboardingRequestService.getStatus; + + getResolution = this.onboardingRequestService.getResolution; + + /** + * Comparison function for resolution values. + * Needed to properly handle null values in the select dropdown and the resolution not yet being set on a request. + */ + compareResolutions(r1: string | null, r2: string | null): boolean { + return r1 === r2 || (r1 == null && r2 == null); + } private _activeFilter: FilterName = 'newAndMyActiveRequests'; get activeFilter(): string { return this._activeFilter; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-administration.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-administration.service.ts index 7e904cdbfd8..6a08ca0ffea 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-administration.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-administration.service.ts @@ -1,8 +1,9 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { Observable } from 'rxjs'; import { CommandService } from 'xforge-common/command.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { ProjectService } from 'xforge-common/project.service'; import { RealtimeService } from 'xforge-common/realtime.service'; import { RetryingRequestService } from 'xforge-common/retrying-request.service'; @@ -20,9 +21,10 @@ export class ServalAdministrationService extends ProjectService { - const query = await this.realtimeService.onlineQuery(SFProjectProfileDoc.COLLECTION, { - paratextId - }); - return query.docs.length > 0 ? query.docs[0] : undefined; + async getByParatextId(paratextId: string, subscriber: DocSubscription): Promise { + const query = await this.realtimeService.onlineQuery( + SFProjectProfileDoc.COLLECTION, + 'ServalAdministrationService.getByParatextId', + { + paratextId + } + ); + const doc: SFProjectProfileDoc | undefined = query.docs.length > 0 ? query.docs[0] : undefined; + if (doc != null) { + doc.addSubscriber(subscriber); + } + query.dispose(); + return doc; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts index 1d09cf9cde1..0be8137f835 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts @@ -2095,7 +2095,7 @@ class TestEnvironment { when(mockExportService.exportCsv(anything(), anything(), anything(), anything(), anything())).thenReturn(undefined); when(mockExportService.exportRsv(anything(), anything(), anything(), anything(), anything())).thenReturn(undefined); when(mockExportService.exportTsv(anything(), anything(), anything(), anything(), anything())).thenReturn(undefined); - when(mockUserService.get(anything())).thenCall((requesterSFUserId: string) => { + when(mockUserService.get(anything(), anything())).thenCall((requesterSFUserId: string) => { const userData: RequesterInfo | undefined = this.requesterDataById.get(requesterSFUserId); const changes$: Subject | undefined = this.requesterChangesById.get(requesterSFUserId); const userDoc = { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts index dee7a6b159a..eda87ab081a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts @@ -45,6 +45,7 @@ import { CopyComponent } from 'xforge-common/copy/copy.component'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -521,7 +522,9 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni if (cached$ != null) return cached$; // Cache the lookups so multiple rows don't need to request the same thing. - const identity$: Observable = from(this.userService.get(requesterSFUserId)).pipe( + const identity$: Observable = from( + this.userService.get(requesterSFUserId, new DocSubscription('ServalBuildsComponent', this.destroyRef)) + ).pipe( // This switchMap to changes$ lets us cache Observables with information that stays up-to-date. switchMap((userDoc: UserDoc) => userDoc.changes$.pipe( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.spec.ts index 62d4b804121..33f5be193ee 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.spec.ts @@ -149,6 +149,7 @@ class TestEnvironment { return from( this.realtimeService.onlineQuery( TestProjectDoc.COLLECTION, + 'spec', merge(filters, queryParameters) ) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.spec.ts index 29d96f58d69..0b11772e2e7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.spec.ts @@ -769,8 +769,8 @@ class TestEnvironment { when(mockedSFProjectService.onlineDelete(anything())).thenResolve(); when(mockedSFProjectService.onlineUpdateSettings('project01', anything())).thenResolve(); when(mockedSFProjectService.onlineSetRoleProjectPermissions('project01', anything(), anything())).thenResolve(); - when(mockedSFProjectService.get('project01')).thenCall(() => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'project01') + when(mockedSFProjectService.subscribe('project01', anything())).thenCall((id, subscription) => + this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'project01', subscription) ); this.testOnlineStatusService.setIsOnline(hasConnection); @@ -815,11 +815,11 @@ class TestEnvironment { } ]); - when(mockedSFProjectService.queryAudioText(anything(), anything())).thenCall(sfProjectId => { + when(mockedSFProjectService.queryAudioText(anything(), anything())).thenCall(async sfProjectId => { const queryParams: QueryParameters = { [obj().pathStr(t => t.projectRef)]: sfProjectId }; - return this.realtimeService.subscribeQuery(TextAudioDoc.COLLECTION, queryParams, noopDestroyRef); + return await this.realtimeService.subscribeQuery(TextAudioDoc.COLLECTION, 'spec', queryParams, noopDestroyRef); }); this.fixture = TestBed.createComponent(SettingsComponent); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts index 56bc798ff29..05bcd1736bf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts @@ -26,6 +26,7 @@ import { ExternalUrlService } from 'xforge-common/external-url.service'; import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; import { I18nService, TextAroundTemplate } from 'xforge-common/i18n.service'; import { ElementState } from 'xforge-common/models/element-state'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -220,7 +221,9 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { firstValueFrom(this.paratextService.getParatextUsername()).then((username: string | undefined) => { if (username != null) this.paratextUsername = username; }), - this.projectService.get(projectId).then(projectDoc => (this.projectDoc = projectDoc)) + this.projectService + .subscribe(projectId, new DocSubscription('SettingsComponent', this.destroyRef)) + .then(projectDoc => (this.projectDoc = projectDoc)) ]).then(() => { if (this.projectDoc != null) { this.updateSettingsInfo(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.html new file mode 100644 index 00000000000..e69ed87be65 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.html @@ -0,0 +1 @@ +

This component is intentionally blank in order to test application behavior when no data should be loaded.

diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.ts new file mode 100644 index 00000000000..e233fb28443 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-blank-page', + templateUrl: './blank-page.component.html', + styleUrl: './blank-page.component.scss' +}) +export class BlankPageComponent {} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.spec.ts index a3588dd2f21..4e8ba3a3d86 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.spec.ts @@ -1,128 +1,387 @@ -import { NgZone } from '@angular/core'; -import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; -import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { filter } from 'rxjs'; +import { anything, deepEqual, mock, resetCalls, verify, when } from 'ts-mockito'; +import { ActivatedProjectService, TestActivatedProjectService } from 'xforge-common/activated-project.service'; import { configureTestingModule } from 'xforge-common/test-utils'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; -import { TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; import { CacheService } from './cache.service'; -const mockedProjectService = mock(SFProjectService); -const mockedProjectDoc = mock(SFProjectProfileDoc); -const mockedPermissionService = mock(PermissionsService); +const mockProjectService = mock(SFProjectService); +const mockPermissionsService = mock(PermissionsService); +// let testActivatedProjectService: TestActivatedProjectService; -describe('cache service', () => { +describe('CacheService', () => { configureTestingModule(() => ({ providers: [ - { provide: SFProjectService, useMock: mockedProjectService }, - { provide: PermissionsService, useMock: mockedPermissionService } + { provide: SFProjectService, useMock: mockProjectService }, + { provide: PermissionsService, useMock: mockPermissionsService }, + { provide: ActivatedProjectService, useClass: TestActivatedProjectService } ] })); - describe('load all texts', () => { - it('does not get texts from project service if no permission', fakeAsync(async () => { - const env = new TestEnvironment(); - when(mockedPermissionService.canAccessTextAsync(anything())).thenResolve(false); - await env.service.cache(env.projectDoc); - env.wait(); - verify(mockedProjectService.getText(anything())).times(0); + beforeEach(() => { + resetCalls(mockProjectService); + resetCalls(mockPermissionsService); + }); - flush(); - expect(true).toBeTruthy(); - })); + it('constructs', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + expect(env.service).toBeDefined(); + })); - it('gets all texts from project service', fakeAsync(async () => { - const env = new TestEnvironment(); - await env.service.cache(env.projectDoc); - env.wait(); + it('activating a project fetches texts', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + env.activateProject('project01'); + tick(); + const numTexts: number = env.numBooks * env.numChapters; + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + verify(mockProjectService.getText(anything(), anything())).times(numTexts); + })); - verify(mockedProjectService.getText(anything())).times(200 * 100 * 2); + it('activating a project fetches texts, including sources', fakeAsync(() => { + const env = new TestEnvironment({ projectHasSource: true, textsHaveSource: true }); + tick(); + env.activateProject('project01'); + tick(); + // Twice as many + const numTexts: number = env.numBooks * env.numChapters * 2; + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + verify(mockProjectService.getText(anything(), anything())).times(numTexts); + })); - flush(); - expect(true).toBeTruthy(); - })); + it('activating a project fetches texts, including sources, unless project has no source project ref', fakeAsync(() => { + const env = new TestEnvironment({ projectHasSource: false, textsHaveSource: true }); + tick(); + env.activateProject('project01'); + tick(); + // Not twice as many + const numTexts: number = env.numBooks * env.numChapters; + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + verify(mockProjectService.getText(anything(), anything())).times(numTexts); + })); - it('stops the current operation if cache is called again', fakeAsync(async () => { - const env = new TestEnvironment(); + it('activating a project fetches texts, including sources, unless texts do not haveSource', fakeAsync(() => { + const env = new TestEnvironment({ projectHasSource: false, textsHaveSource: true }); + tick(); + env.activateProject('project01'); + tick(); + // Not twice as many + const numTexts: number = env.numBooks * env.numChapters; + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + verify(mockProjectService.getText(anything(), anything())).times(numTexts); + })); - const mockProject = mock(SFProjectProfileDoc); - when(mockProject.id).thenReturn('new project'); - const data = createTestProjectProfile({ - texts: env.createTexts() - }); - when(mockProject.data).thenReturn(data); + it('activating an undefined project does not fetch texts', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + env.activateProject('project01'); + tick(); + resetCalls(mockPermissionsService); + resetCalls(mockProjectService); + env.activateProject(undefined); + verify(mockPermissionsService.canAccessTextAsync(anything())).never(); + verify(mockProjectService.getText(anything(), anything())).never(); + })); - env.service.cache(env.projectDoc); - await env.service.cache(instance(mockProject)); - env.wait(); + it('activating a project unsubscribes from previous project texts', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + env.activateProject('project01'); + tick(); + // CacheService should not have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(0); + env.activateProject('project02'); + tick(); + const numTexts: number = env.numBooks * env.numChapters; + // CacheService should have unsubscribed from the previous project text docs. + expect(env.numTextDocUnsubscribes).toEqual(numTexts); + })); - verify( - mockedProjectService.getText(deepEqual(new TextDocId('new project', anything(), anything(), 'target'))) - ).times(200 * 100); + it('activating a project unsubscribes from previous project texts, including sources', fakeAsync(() => { + const env = new TestEnvironment({ projectHasSource: true, textsHaveSource: true }); + tick(); + env.activateProject('project01'); + tick(); + // CacheService should not have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(0); + env.activateProject('project02'); + tick(); + // Twice as many + const numTexts: number = env.numBooks * env.numChapters * 2; + // CacheService should have unsubscribed from the previous project text docs. + expect(env.numTextDocUnsubscribes).toEqual(numTexts); + })); - //verify at least some books were not gotten - verify(mockedProjectService.getText(anything())).atMost(200 * 100 * 2 - 1); + it('activating an undefined project does not unsubscribe from project texts', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + env.activateProject('project01'); + tick(); + // CacheService should not have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(0); + env.activateProject(undefined); + tick(); + // CacheService should not have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(0); + })); - flush(); - expect(true).toBeTruthy(); - })); + it('destroying CacheService unsubscribes from project texts', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + env.activateProject('project01'); + tick(); + // CacheService should not have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(0); + TestBed.resetTestingModule(); + tick(); + // CacheService should have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(env.numBooks * env.numChapters); + })); - it('gets the source texts if they are present and the user can access', fakeAsync(async () => { - const env = new TestEnvironment(); - when( - mockedPermissionService.canAccessTextAsync(deepEqual(new TextDocId('sourceId', 0, 0, 'target'))) - ).thenResolve(false); //remove access for one source doc + it('destroying CacheService unsubscribes from project texts, including sources', fakeAsync(() => { + const env = new TestEnvironment({ projectHasSource: true, textsHaveSource: true }); + tick(); + env.activateProject('project01'); + tick(); + // CacheService should not have unsubscribed from the project01 text docs. + expect(env.numTextDocUnsubscribes).toEqual(0); + TestBed.resetTestingModule(); + tick(); + // CacheService should have unsubscribed from the project01 text docs. + // Twice as many + expect(env.numTextDocUnsubscribes).toEqual(env.numBooks * env.numChapters * 2); + })); - await env.service.cache(env.projectDoc); - env.wait(); + it('should not load texts if user lacks permission', fakeAsync(() => { + const env = new TestEnvironment({ userHasTextPermission: false }); + tick(); + env.activateProject('project01'); + tick(); + const numTexts: number = env.numBooks * env.numChapters; + // Queried permissions for each text doc. + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + // But never tried to fetch them. + verify(mockProjectService.getText(anything(), anything())).times(0); + })); - //verify all sources and targets were gotten except the inaccessible one - verify(mockedProjectService.getText(anything())).times(200 * 100 * 2 - 1); + it('should load texts but not source texts if user lacks permission', fakeAsync(() => { + const env = new TestEnvironment({ + projectHasSource: true, + textsHaveSource: true, + userHasTextPermission: true, + userHasSourceTextPermission: false + }); + tick(); + env.activateProject('project01'); + tick(); + const numProjectTexts: number = env.numBooks * env.numChapters; + const numSourceTexts: number = env.numBooks * env.numChapters; + // Twice as many since there are source texts + const numTexts: number = numProjectTexts + numSourceTexts; + // Queried permissions for each text doc, including sources. + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + // Only tried to fetch project texts, not source texts. + verify(mockProjectService.getText(anything(), anything())).times(numProjectTexts); + })); - flush(); - expect(true).toBeTruthy(); - })); - }); + it('should not load project texts if user lacks permission, but load source texts', fakeAsync(() => { + const env = new TestEnvironment({ + projectHasSource: true, + textsHaveSource: true, + userHasTextPermission: false, + userHasSourceTextPermission: true + }); + tick(); + env.activateProject('project01'); + tick(); + const numProjectTexts: number = env.numBooks * env.numChapters; + const numSourceTexts: number = env.numBooks * env.numChapters; + // Twice as many since there are source texts + const numTexts: number = numProjectTexts + numSourceTexts; + // Queried permissions for each text doc, including sources. + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + // Only tried to fetch source texts, not project texts. + verify(mockProjectService.getText(anything(), anything())).times(numSourceTexts); + })); + + it('should not load project texts or source texts if user lacks permission to both', fakeAsync(() => { + const env = new TestEnvironment({ + projectHasSource: true, + textsHaveSource: true, + userHasTextPermission: false, + userHasSourceTextPermission: false + }); + tick(); + env.activateProject('project01'); + tick(); + const numProjectTexts: number = env.numBooks * env.numChapters; + const numSourceTexts: number = env.numBooks * env.numChapters; + // Twice as many since there are source texts + const numTexts: number = numProjectTexts + numSourceTexts; + // Queried permissions for each text doc, including sources. + verify(mockPermissionsService.canAccessTextAsync(anything())).times(numTexts); + // Did not try to fetch project texts or source texts. + verify(mockProjectService.getText(anything(), anything())).times(0); + })); + + it('activating a project interrupts caching', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + // When CacheService starts to process project01's texts, activate another project in the middle of processing. + when( + mockProjectService.getText(deepEqual(new TextDocId('project01', anything(), anything(), 'target')), anything()) + ).thenCall(() => env.activateProject('project02')); + env.activateProject('project01'); + tick(); + const numTexts: number = env.numBooks * env.numChapters; + // Verify test setup. + expect(numTexts).toBeGreaterThan(1); + // CacheService will have started loading texts for project01, but the processing only loads one text before it is interrupted. + verify( + mockProjectService.getText(deepEqual(new TextDocId('project01', anything(), anything(), 'target')), anything()) + ).atMost(1); + + // But CacheService will have loaded all the texts for project02. + verify( + mockProjectService.getText(deepEqual(new TextDocId('project02', anything(), anything(), 'target')), anything()) + ).times(numTexts); + })); + + it('activating an undefined project does not interrupt caching', fakeAsync(() => { + const env = new TestEnvironment(); + tick(); + // When CacheService starts to process project01's texts, activate an undefined project in the middle of processing. + when( + mockProjectService.getText(deepEqual(new TextDocId('project01', anything(), anything(), 'target')), anything()) + ).thenCall(() => env.activateProject(undefined)); + env.activateProject('project01'); + tick(); + const numTexts: number = env.numBooks * env.numChapters; + // CacheService should have continued to load all the texts for project01. + verify( + mockProjectService.getText(deepEqual(new TextDocId('project01', anything(), anything(), 'target')), anything()) + ).times(numTexts); + })); }); class TestEnvironment { - readonly ngZone: NgZone = TestBed.inject(NgZone); readonly service: CacheService; - readonly projectDoc: SFProjectProfileDoc = instance(mockedProjectDoc); + readonly numBooks = 2; + readonly numChapters = 3; + numTextDocUnsubscribes: number = 0; + private readonly activatedProjectService: TestActivatedProjectService; + + constructor({ + projectHasSource = false, + textsHaveSource = false, + userHasTextPermission = true, + userHasSourceTextPermission = true + }: { + projectHasSource?: boolean; + textsHaveSource?: boolean; + userHasTextPermission?: boolean; + userHasSourceTextPermission?: boolean; + } = {}) { + when(mockProjectService.getText(anything(), anything())).thenCall( + async (_textId: TextDocId | string, subscriber: DocSubscription) => { + subscriber.isUnsubscribed$ + .pipe(filter(isUnsubscribed => isUnsubscribed === true)) + .subscribe(() => this.numTextDocUnsubscribes++); + return {} as TextDoc; + } + ); + + this.setupProject( + 'project01', + projectHasSource, + textsHaveSource, + userHasTextPermission, + userHasSourceTextPermission + ); + this.setupProject( + 'project02', + projectHasSource, + textsHaveSource, + userHasTextPermission, + userHasSourceTextPermission + ); - constructor() { + this.activatedProjectService = TestBed.inject(ActivatedProjectService) as TestActivatedProjectService; this.service = TestBed.inject(CacheService); + } + + async activateProject(projectId?: string): Promise { + await this.activatedProjectService.setProject(projectId); + } + private setupProject( + projectId: string, + projectHasSource: boolean, + textsHaveSource: boolean, + userHasTextPermission: boolean, + userHasSourceTextPermission: boolean + ): void { const data = createTestProjectProfile({ - texts: this.createTexts(), + texts: this.createTexts(projectId, textsHaveSource, userHasTextPermission, userHasSourceTextPermission), translateConfig: { - source: { - projectRef: 'sourceId' - } + source: projectHasSource + ? { + projectRef: 'sourceId' + } + : undefined } }); - when(mockedProjectDoc.data).thenReturn(data); - when(mockedPermissionService.canAccessTextAsync(anything())).thenResolve(true); + const projectDoc = { + id: projectId, + data: data as SFProjectProfile + } as SFProjectProfileDoc; + + when(mockProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); } - createTexts(): TextInfo[] { + private createTexts( + projectId: string, + textsHaveSource: boolean, + userHasTextPermission: boolean, + userHasSourceTextPermission: boolean + ): TextInfo[] { const texts: TextInfo[] = []; - for (let book = 0; book < 200; book++) { + // Create Matthew (40) and Mark (41) + for (let bookNum = 40; bookNum <= 40 + this.numBooks - 1; bookNum++) { const chapters: Chapter[] = []; - for (let chapter = 0; chapter < 100; chapter++) { - chapters.push({ isValid: true, lastVerse: 1, number: chapter, permissions: {}, hasAudio: false }); + for (let chapterNumber = 1; chapterNumber <= this.numChapters; chapterNumber++) { + chapters.push({ + isValid: true, + lastVerse: 10, + number: chapterNumber, + permissions: {}, + hasAudio: false + }); + const textDocId = new TextDocId(projectId, bookNum, chapterNumber); + const sourceTextDocId = new TextDocId('sourceId', bookNum, chapterNumber); + + when(mockPermissionsService.canAccessTextAsync(deepEqual(textDocId))).thenResolve(userHasTextPermission); + when(mockPermissionsService.canAccessTextAsync(deepEqual(sourceTextDocId))).thenResolve( + userHasSourceTextPermission + ); } - texts.push({ bookNum: book, chapters: chapters, hasSource: true, permissions: {} }); + texts.push({ + bookNum: bookNum, + chapters: chapters, + hasSource: textsHaveSource, + permissions: {} + }); } return texts; } - - async wait(ms: number = 200): Promise { - await new Promise(resolve => this.ngZone.runOutsideAngular(() => setTimeout(resolve, ms))); - tick(); - } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.ts index 368100ba565..e4524e674c7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/cache-service/cache.service.ts @@ -1,46 +1,62 @@ -import { EventEmitter, Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../core/models/text-doc'; import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; +/** Fetches and holds a DocSubscription to chapter texts for the last activated project. (i.e. the currently activated + * project, or the most recent one if there is no currently activated project.) */ @Injectable({ providedIn: 'root' }) export class CacheService { - private abortCurrent: EventEmitter = new EventEmitter(); + private docSubscription?: DocSubscription; + constructor( private readonly projectService: SFProjectService, - private readonly permissionsService: PermissionsService - ) {} - - async cache(project: SFProjectProfileDoc): Promise { - this.abortCurrent.emit(); - await this.loadAllChapters(project); + private readonly permissionsService: PermissionsService, + private readonly activatedProjectService: ActivatedProjectService, + private readonly destroyRef: DestroyRef + ) { + this.destroyRef.onDestroy(() => { + this.uncache(); + }); + activatedProjectService.projectDoc$ + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe(async (sfProjectProfileDoc?: SFProjectProfileDoc) => { + // Do not uncache until the next project is activated. + if (sfProjectProfileDoc == null) return; + this.uncache(); + this.docSubscription = new DocSubscription('CacheService'); + await this.loadAllChapters(sfProjectProfileDoc, this.docSubscription); + }); } - private async loadAllChapters(project: SFProjectProfileDoc): Promise { - let abort = false; - const sub = this.abortCurrent.subscribe(() => (abort = true)); + private uncache(): void { + this.docSubscription?.unsubscribe(); + } - if (project?.data != null) { - const sourceId = project.data.translateConfig.source?.projectRef; + private async loadAllChapters(project: SFProjectProfileDoc, docSubscription: DocSubscription): Promise { + if (project.data == null) return; + const sourceId: string | undefined = project.data.translateConfig.source?.projectRef; - for (const text of project.data.texts) { - for (const chapter of text.chapters) { - if (abort) { - sub.unsubscribe(); - return; - } + for (const text of project.data.texts) { + for (const chapter of text.chapters) { + // Keep caching if the activated project merely became undefined and is only potentially going to change. But + // stop if the activated project has changed to a different one while we are still caching. + if (this.activatedProjectService.projectDoc != null && this.activatedProjectService.projectDoc !== project) + return; - const textDocId = new TextDocId(project.id, text.bookNum, chapter.number, 'target'); - if (await this.permissionsService.canAccessTextAsync(textDocId)) { - await this.projectService.getText(textDocId); - } + const textDocId = new TextDocId(project.id, text.bookNum, chapter.number, 'target'); + if (await this.permissionsService.canAccessTextAsync(textDocId)) { + await this.projectService.getText(textDocId, docSubscription); + } - if (text.hasSource && sourceId != null) { - const sourceTextDocId = new TextDocId(sourceId, text.bookNum, chapter.number, 'target'); - if (await this.permissionsService.canAccessTextAsync(sourceTextDocId)) { - await this.projectService.getText(sourceTextDocId); - } + if (text.hasSource && sourceId != null) { + const sourceTextDocId = new TextDocId(sourceId, text.bookNum, chapter.number, 'target'); + if (await this.permissionsService.canAccessTextAsync(sourceTextDocId)) { + await this.projectService.getText(sourceTextDocId, docSubscription); } } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.html index ad1731335bc..b5dd55e0ece 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.html @@ -1,3 +1,5 @@ +{{ digestCycleCounter }} +
@if (isExpanded) { @@ -15,44 +17,148 @@

Developer Diagnostics

@if (isExpanded) { -

{{ totalDocsCount | l10nNumber }} documents tracked by realtime service

- - - - - - - - - - - @for (docType of docCountsByCollection | keyvalue; track docType.key) { - - - - - - - } - -
CollectionDocsSubscribersQueries
{{ docType.key }}{{ docType.value.docs | l10nNumber }}{{ docType.value.subscribers | l10nNumber }}{{ docType.value.queries | l10nNumber }}
+ }
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.scss index 38f73fb3203..9aa0587231e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.scss @@ -11,7 +11,7 @@ $table-border-color: #404040; color: $foreground-color; font-size: 12px; - overflow-y: auto; + max-height: calc(100vh - 56px); } .header button { @@ -19,9 +19,9 @@ $table-border-color: #404040; } .wrapper:not(.collapsed) { - padding: 4px 12px; - width: 25vw; - min-width: 300px; + display: flex; + flex-direction: column; + max-height: 100%; } .wrapper.collapsed { @@ -70,7 +70,7 @@ h3 { align-items: center; h2 { - margin: 0; + margin: 0 0 0 2em; flex-grow: 1; } } @@ -78,3 +78,37 @@ h3 { .collapsed .header { flex-direction: column; } + +.nav-and-content-wrapper { + display: flex; + gap: 8px; + overflow: hidden; +} + +.tab-content { + overflow: auto; + padding-bottom: 1em; +} + +.nav-wrapper { + writing-mode: vertical-rl; + display: flex; + + > * { + padding: 1em 0.6em; + cursor: pointer; + background-color: #3b3b3f; + + &:hover { + background-color: #4f4f53; + } + } + + .active { + border-block-start: 2px solid $foreground-color; + } +} + +a { + color: $foreground-color; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.ts index 0f378c6b6aa..979183fc2cf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.ts @@ -1,13 +1,16 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { KeyValuePipe } from '@angular/common'; import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { DiagnosticOverlayService } from 'xforge-common/diagnostic-overlay.service'; import { L10nNumberPipe } from 'xforge-common/l10n-number.pipe'; import { LocalSettingsService } from 'xforge-common/local-settings.service'; +import { RealtimeDocLifecycleMonitorService } from 'xforge-common/models/realtime-doc-lifecycle-monitor'; import { NoticeService } from 'xforge-common/notice.service'; import { RealtimeService } from 'xforge-common/realtime.service'; +import { RouterLinkDirective } from 'xforge-common/router-link.directive'; export interface DiagnosticOverlayData { bookNum: number; @@ -23,31 +26,56 @@ const diagnosticOverlayCollapsedKey = 'DIAGNOSTIC_OVERLAY_COLLAPSED'; selector: 'app-diagnostic-overlay', templateUrl: './diagnostic-overlay.component.html', styleUrl: './diagnostic-overlay.component.scss', - imports: [OverlayModule, KeyValuePipe, MatIconButton, MatIcon, L10nNumberPipe] + imports: [OverlayModule, KeyValuePipe, MatIconButton, MatIcon, L10nNumberPipe, FormsModule, RouterLinkDirective] }) export class DiagnosticOverlayComponent { isExpanded: boolean = true; isOpen: boolean = true; + tab = 0; + digestCycles = 0; + + recreateTimeThreshold: number = 250; + recreateCountThreshold: number = 1; constructor( private readonly realtimeService: RealtimeService, private readonly diagnosticOverlayService: DiagnosticOverlayService, readonly noticeService: NoticeService, - private readonly localSettings: LocalSettingsService + public readonly docLifecycleMonitor: RealtimeDocLifecycleMonitorService, + private readonly localSettings: LocalSettingsService, + private readonly l10nNumber: L10nNumberPipe ) { + docLifecycleMonitor.setMonitoringEnabled(true); if (this.localSettings.get(diagnosticOverlayCollapsedKey) === false) { this.isExpanded = false; } } - get docCountsByCollection(): { [key: string]: { docs: number; subscribers: number; queries: number } } { + get docCountsByCollection(): { + [key: string]: { docs: number; subscribers: number; activeDocSubscriptionsCount: number }; + } { return this.realtimeService.docsCountByCollection; } + get queryCountByCollection(): { [key: string]: number } { + return this.realtimeService.queryCountByCollection; + } + + get subscriberCountsByContext(): { [key: string]: { [key: string]: { all: number; active: number } } } { + return this.realtimeService.subscriberCountsByContext; + } + get totalDocsCount(): number { return this.realtimeService.totalDocCount; } + get digestCycleCounter(): string { + this.digestCycles++; + const displayElement = document.getElementById('digest-cycles'); + if (displayElement) displayElement.textContent = this.l10nNumber.transform(this.digestCycles); + return ''; + } + toggle(): void { this.isExpanded = !this.isExpanded; this.localSettings.set(diagnosticOverlayCollapsedKey, this.isExpanded); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts index 71590e08203..5031bfa9ed7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts @@ -1,15 +1,18 @@ +import { DestroyRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; -import { mock, when } from 'ts-mockito'; +import { catchError, lastValueFrom, of } from 'rxjs'; +import { anything, mock, when } from 'ts-mockito'; import { AuthGuard } from 'xforge-common/auth.guard'; import { AuthService } from 'xforge-common/auth.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { configureTestingModule } from 'xforge-common/test-utils'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { SFProjectService } from '../core/sf-project.service'; -import { DraftNavigationAuthGuard, SyncAuthGuard } from './project-router.guard'; +import { DraftNavigationAuthGuard, RouterGuard, SyncAuthGuard } from './project-router.guard'; const mockedAuthGuard = mock(AuthGuard); const mockedAuthService = mock(AuthService); @@ -103,6 +106,46 @@ describe('SyncAuthGuard', () => { }); }); +describe('RouterGuard', () => { + configureTestingModule(() => ({ + providers: [ + { provide: AuthGuard, useMock: mockedAuthGuard }, + { provide: SFProjectService, useMock: mockedProjectService }, + { + provide: TestRouterGuard, + useFactory: (authGuard: AuthGuard, projectService: SFProjectService) => + new TestRouterGuard(authGuard, projectService, {} as unknown as DestroyRef), + deps: [AuthGuard, SFProjectService] + } + ] + })); + + it('unsubscribes project subscription when profile lookup throws', async () => { + const env = new RouterGuardTestEnvironment(); + when(mockedAuthGuard.allowTransition()).thenReturn(of(true)); + when(mockedProjectService.getProfile('project01', anything())).thenReject(new Error('profile failure')); + + const canActivate: boolean = await env.allowTransition('project01'); + + expect(canActivate).toBeFalse(); + expect(env.unsubscribeSpy).toHaveBeenCalledTimes(1); + }); +}); + +class RouterGuardTestEnvironment { + readonly service: TestRouterGuard; + readonly unsubscribeSpy: jasmine.Spy; + + constructor() { + this.service = TestBed.inject(TestRouterGuard); + this.unsubscribeSpy = spyOn(DocSubscription.prototype, 'unsubscribe').and.callThrough(); + } + + async allowTransition(projectId: string): Promise { + return await lastValueFrom(this.service.allowTransition(projectId).pipe(catchError(() => of(false)))); + } +} + class DraftNavigationTestEnvironment { service: DraftNavigationAuthGuard; constructor() { @@ -122,3 +165,13 @@ class SyncAuthGuardTestEnvironment { } } } + +class TestRouterGuard extends RouterGuard { + constructor(authGuard: AuthGuard, projectService: SFProjectService, destroyRef: DestroyRef) { + super(authGuard, projectService, destroyRef); + } + + check(_: SFProjectProfileDoc): boolean { + return true; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts index e7e8eb82115..ba5aa9cf119 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanDeactivate, Router, RouterStateSnapshot } from '@angular/router'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; @@ -6,9 +6,10 @@ import { isResource } from 'realtime-server/lib/esm/scriptureforge/models/sf-pro import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { from, Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { AuthGuard } from 'xforge-common/auth.guard'; import { AuthService } from 'xforge-common/auth.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { PermissionsService } from '../core/permissions.service'; @@ -17,7 +18,8 @@ import { SFProjectService } from '../core/sf-project.service'; export abstract class RouterGuard { constructor( protected readonly authGuard: AuthGuard, - protected readonly projectService: SFProjectService + protected readonly projectService: SFProjectService, + protected readonly destroyRef: DestroyRef ) {} canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { @@ -28,14 +30,25 @@ export abstract class RouterGuard { allowTransition(projectId: string): Observable { return this.authGuard.allowTransition().pipe( switchMap(isLoggedIn => { - if (isLoggedIn) { - return from(this.projectService.getProfile(projectId)).pipe(map(projectDoc => this.check(projectDoc))); + if (!isLoggedIn) { + return of(false); } - return of(false); + + return from(this.canAccessProject(projectId)); }) ); } + private async canAccessProject(projectId: string): Promise { + const docSubscription = new DocSubscription('ProjectRouterGuard'); + try { + const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(projectId, docSubscription); + return this.check(projectDoc); + } finally { + docSubscription.unsubscribe(); + } + } + abstract check(project: SFProjectProfileDoc): boolean; } @@ -46,9 +59,10 @@ export class SettingsAuthGuard extends RouterGuard { constructor( authGuard: AuthGuard, projectService: SFProjectService, - private userService: UserService + private userService: UserService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { @@ -66,9 +80,10 @@ export class UsersAuthGuard extends RouterGuard { constructor( authGuard: AuthGuard, projectService: SFProjectService, - private userService: UserService + private userService: UserService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { @@ -87,9 +102,10 @@ export class SyncAuthGuard extends RouterGuard { authGuard: AuthGuard, projectService: SFProjectService, private readonly userService: UserService, - private readonly authService: AuthService + private readonly authService: AuthService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { @@ -120,9 +136,10 @@ export class NmtDraftAuthGuard extends RouterGuard { constructor( authGuard: AuthGuard, projectService: SFProjectService, - private userService: UserService + private userService: UserService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { @@ -147,9 +164,10 @@ export class CheckingAuthGuard extends RouterGuard { authGuard: AuthGuard, projectService: SFProjectService, private router: Router, - private readonly permissions: PermissionsService + private readonly permissions: PermissionsService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { @@ -169,9 +187,10 @@ export class TranslateAuthGuard extends RouterGuard { authGuard: AuthGuard, projectService: SFProjectService, private router: Router, - private readonly permissions: PermissionsService + private readonly permissions: PermissionsService, + destroyRef: DestroyRef ) { - super(authGuard, projectService); + super(authGuard, projectService, destroyRef); } check(projectDoc: SFProjectProfileDoc): boolean { @@ -191,8 +210,8 @@ export interface ConfirmOnLeave { providedIn: 'root' }) export class DraftNavigationAuthGuard extends RouterGuard implements CanDeactivate { - constructor(authGuard: AuthGuard, projectService: SFProjectService) { - super(authGuard, projectService); + constructor(authGuard: AuthGuard, projectService: SFProjectService, destroyRef: DestroyRef) { + super(authGuard, projectService, destroyRef); } async canDeactivate(component: ConfirmOnLeave): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.spec.ts index 05eef67c492..b68156be2c0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.spec.ts @@ -8,6 +8,7 @@ import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scri import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { anything, capture, mock, verify, when } from 'ts-mockito'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -320,8 +321,8 @@ class TestEnvironment { } } }); - when(mockedProjectService.getProfile(anything())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId) + when(mockedProjectService.getProfile(anything(), anything())).thenCall((projectId, subscription) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId, subscription) ); when(mockedUserService.currentUserId).thenReturn(args.userId!); when(mockedProjectService.onlineGetLinkSharingKey(args.projectId!, anything(), anything(), anything())).thenResolve( @@ -420,8 +421,12 @@ class TestEnvironment { this.wait(); } - updateCheckingProperties(config: CheckingConfig): Promise { - const projectDoc: SFProjectProfileDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); + async updateCheckingProperties(config: CheckingConfig): Promise { + const projectDoc: SFProjectProfileDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); return projectDoc.submitJson0Op(op => op.set(p => p.checkingConfig, config)); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.ts index 78f35703d7a..d00fa292a84 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-control.component.ts @@ -19,6 +19,7 @@ import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf- import { BehaviorSubject, combineLatest, startWith } from 'rxjs'; import { CommandError } from 'xforge-common/command.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; @@ -90,7 +91,7 @@ export class ShareControlComponent extends ShareBaseComponent { } if (this.projectDoc == null || projectId !== this._projectId) { [this.projectDoc, this.isProjectAdmin] = await Promise.all([ - this.projectService.getProfile(projectId), + this.projectService.getProfile(projectId, new DocSubscription('ShareControlComponent', this.destroyRef)), this.projectService.isProjectAdmin(projectId, this.userService.currentUserId) ]); this.roleControl.setValue(this.defaultShareRole ?? null); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.spec.ts index 5a6dfab03ac..5abc8f62dd3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.spec.ts @@ -1,3 +1,4 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; import { DebugElement } from '@angular/core'; import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; @@ -10,6 +11,7 @@ import { firstValueFrom } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { NAVIGATOR } from 'xforge-common/browser-globals'; import { Locale } from 'xforge-common/models/i18n-locale'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -55,11 +57,19 @@ describe('ShareDialogComponent', () => { })); let env: TestEnvironment; + let overlayContainer: OverlayContainer; + + beforeEach(() => { + overlayContainer = TestBed.inject(OverlayContainer); + }); + afterEach(fakeAsync(() => { if (env.closeButton != null) { env.clickElement(env.closeButton); } flush(); + // Prevents 'Error: Test did not clean up its overlay container content.' + overlayContainer.ngOnDestroy(); })); it('shows share button when sharing API is supported', fakeAsync(() => { @@ -288,21 +298,21 @@ describe('ShareDialogComponent', () => { expect(env.configLinkUsage).toBeTruthy(); })); - it('should close dialog if project settings change and sharing becomes disabled', fakeAsync(() => { + it('should close dialog if project settings change and sharing becomes disabled', fakeAsync(async () => { env = new TestEnvironment({ userId: TestUsers.CommunityChecker, translateShareEnabled: false }); expect(env.isDialogOpen).toBe(true); - env.disableCheckingSharing(); + await env.disableCheckingSharing(); expect(env.isDialogOpen).toBe(false); })); - it('should remove checking role as an option if remote project settings change', fakeAsync(() => { + it('should remove checking role as an option if remote project settings change', fakeAsync(async () => { env = new TestEnvironment({ userId: TestUsers.Admin }); let roles: SFProjectRole[] = env.component.availableRoles; expect(roles).toContain(SFProjectRole.CommunityChecker); expect(roles).toContain(SFProjectRole.Viewer); expect(env.canChangeLinkUsage).toBe(true); - env.disableCheckingSharing(); + await env.disableCheckingSharing(); roles = env.component.availableRoles; expect(roles).not.toContain(SFProjectRole.CommunityChecker); @@ -406,8 +416,9 @@ class TestEnvironment { return Promise.resolve(undefined); } } as Clipboard); - when(mockedProjectService.getProfile(anything())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId) + when(mockedProjectService.getProfile(anything(), anything())).thenCall( + async (projectId, subscription) => + await this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId, subscription) ); when(mockedUserService.currentUserId).thenReturn(userId); when(mockedUserService.getCurrentUser()).thenResolve({ data: createTestUser() } as UserDoc); @@ -494,9 +505,13 @@ class TestEnvironment { tick(); } - disableCheckingSharing(): void { - const projectDoc: SFProjectProfileDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); - projectDoc.submitJson0Op( + async disableCheckingSharing(): Promise { + const projectDoc: SFProjectProfileDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op( op => op.set(p => p.checkingConfig, { checkingEnabled: false, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.ts index d9a963fed65..2e7a5943205 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/share/share-dialog.component.ts @@ -22,6 +22,7 @@ import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf- import { NAVIGATOR } from 'xforge-common/browser-globals'; import { I18nService } from 'xforge-common/i18n.service'; import { Locale } from 'xforge-common/models/i18n-locale'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -98,7 +99,7 @@ export class ShareDialogComponent extends ShareBaseComponent { super(userService); this.projectId = this.data.projectId; void Promise.all([ - this.projectService.getProfile(this.projectId), + this.projectService.getProfile(this.projectId, new DocSubscription('ShareDialogComponent', this.destroyRef)), this.projectService.isProjectAdmin(this.projectId, this.userService.currentUserId) ]).then(value => { this.projectDoc = value[0]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts index 2df32343875..e4785cdb8f8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts @@ -18,6 +18,7 @@ import { LocalPresence } from 'sharedb/lib/sharedb'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; import { MockConsole } from 'xforge-common/mock-console'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -72,7 +73,7 @@ describe('TextComponent', () => { await firstValueFrom(quillService.getQuill()); }); - it('shows proper placeholder messages for situations', fakeAsync(() => { + it('shows proper placeholder messages for situations', fakeAsync(async () => { // Suppose a user comes to a page with a text component, which has no content to show. The placeholder will be a // 'no-content' indication, or that specified as a placeholder Input. Here we will not specify the placeholder // input. @@ -93,7 +94,7 @@ describe('TextComponent', () => { // The user goes to a location. The id input is set to a particular location. The text component is now expected to // have content, so the placeholder should be 'loading'. - env.runWithDelayedGetText(env.matTextDocId, () => { + await env.runWithDelayedGetText(env.matTextDocId, () => { env.id = env.matTextDocId; env.waitForEditor(); expect(env.component.placeholder).toEqual('text.loading'); @@ -103,7 +104,7 @@ describe('TextComponent', () => { // why they can't see the content if and while it has not loaded yet. env.onlineStatus = false; env.waitForEditor(); - env.runWithDelayedGetText(env.lukTextDocId, () => { + await env.runWithDelayedGetText(env.lukTextDocId, () => { env.id = env.lukTextDocId; env.waitForEditor(); expect(env.component.placeholder).toEqual('text.not_available_offline'); @@ -115,7 +116,7 @@ describe('TextComponent', () => { }); // The user goes to a location that the project does not have. The placeholder should indicate 'no-content'. - env.runWithDelayedGetText(env.notPresentTextDocId, () => { + await env.runWithDelayedGetText(env.notPresentTextDocId, () => { env.id = env.notPresentTextDocId; env.waitForEditor(); expect(env.component.placeholder).toEqual('text.this_book_does_not_exist'); @@ -125,7 +126,7 @@ describe('TextComponent', () => { // 'no-content'. env.onlineStatus = false; env.waitForEditor(); - env.runWithDelayedGetText(env.notPresentTextDocId, () => { + await env.runWithDelayedGetText(env.notPresentTextDocId, () => { env.id = env.notPresentTextDocId; env.waitForEditor(); expect(env.component.placeholder).toEqual('text.this_book_does_not_exist'); @@ -154,7 +155,7 @@ describe('TextComponent', () => { data: createTestUser({}, 2) }); when(mockedUserService.getCurrentUser()).thenCall(() => - env.realtimeService.subscribe(UserDoc.COLLECTION, 'user02') + env.realtimeService.subscribe(UserDoc.COLLECTION, 'user02', new DocSubscription('spec')) ); }; const env2: TestEnvironment = new TestEnvironment({ callback }); @@ -164,7 +165,7 @@ describe('TextComponent', () => { expect(env2.component.placeholder).toEqual('text.permission_denied'); })); - it('placeholder uses specified placeholder input', fakeAsync(() => { + it('placeholder uses specified placeholder input', fakeAsync(async () => { // TextComponent allows a placeholder to be specified to override the default no-content message. // Suppose a user comes to a page with a text component, which has no content to show. The placeholder will be the @@ -186,14 +187,14 @@ describe('TextComponent', () => { // The user goes to a location. The id input is set to a particular location. The text component is now expected to // have content, so the placeholder should be 'loading'. - env.runWithDelayedGetText(env.matTextDocId, () => { + await env.runWithDelayedGetText(env.matTextDocId, () => { env.id = env.matTextDocId; env.waitForEditor(); expect(env.component.placeholder).toEqual('text.loading'); }); // The user goes to a location that the project does not have. The placeholder should indicate 'no-content'. - env.runWithDelayedGetText(env.notPresentTextDocId, () => { + await env.runWithDelayedGetText(env.notPresentTextDocId, () => { env.id = env.notPresentTextDocId; env.waitForEditor(); expect(env.component.placeholder).toEqual('my custom no-content message'); @@ -206,7 +207,7 @@ describe('TextComponent', () => { expect(env.component.placeholder).toEqual('my custom no-content message'); })); - it('shows book is empty placeholder messages', fakeAsync(() => { + it('shows book is empty placeholder messages', fakeAsync(async () => { // Suppose the user navigates to a text location. We fetch the text, but some aspect of the received TextDoc // indicates that it is considered "empty". The placeholder will indicate this. @@ -217,18 +218,16 @@ describe('TextComponent', () => { const textDocIdWithEmpty: TextDocId = env.matTextDocId; - let textDocBeingGotten: TextDoc = {} as TextDoc; - instance(mockedProjectService) - .getText(textDocIdWithEmpty.toString()) - .then((value: TextDoc) => { - textDocBeingGotten = value; - }); + const textDocBeingGotten: TextDoc = await instance(mockedProjectService).getText( + textDocIdWithEmpty.toString(), + new DocSubscription('spec') + ); tick(); // The textdoc will have an undefined data field. Object.defineProperty(textDocBeingGotten, 'data', { get: () => undefined }); - when(mockedProjectService.getText(textDocIdWithEmpty)).thenResolve(textDocBeingGotten); + when(mockedProjectService.getText(textDocIdWithEmpty, anything())).thenResolve(textDocBeingGotten); // The user goes to a location that has an 'empty' textdoc. The placeholder indicates empty. env.id = textDocIdWithEmpty; @@ -637,7 +636,7 @@ describe('TextComponent', () => { data: createTestUser({ displayName: '', sites: { sf: { projects: ['project01'] } } }, 2) }); when(mockedUserService.getCurrentUser()).thenCall(() => - env.realtimeService.subscribe(UserDoc.COLLECTION, 'user02') + env.realtimeService.subscribe(UserDoc.COLLECTION, 'user02', new DocSubscription('spec')) ); }; const env: TestEnvironment = new TestEnvironment({ callback }); @@ -787,14 +786,14 @@ describe('TextComponent', () => { expect(presenceChannelReceiveSpy).toHaveBeenCalledTimes(1); })); - it('should update presence if the user data changes', fakeAsync(() => { + it('should update presence if the user data changes', fakeAsync(async () => { const updatedAvatarUrl: string = 'https://example.com/avatar-updated.png'; const env: TestEnvironment = new TestEnvironment(); env.fixture.detectChanges(); env.id = new TextDocId('project01', 40, 1); env.waitForEditor(); const presenceChannelSubmit = spyOn(env.localPresenceChannel, 'submit'); - const userDoc: UserDoc = env.getUserDoc('user01'); + const userDoc: UserDoc = await env.getUserDoc('user01'); expect(userDoc.data?.avatarUrl).not.toEqual(updatedAvatarUrl); userDoc.submitJson0Op(op => op.set(u => u.avatarUrl, updatedAvatarUrl)); @@ -1493,48 +1492,39 @@ describe('TextComponent', () => { expect(wasLoaded).toBeUndefined(); })); - it('knows if project has book with chapter', fakeAsync(() => { + it('knows if project has book with chapter', fakeAsync(async () => { const env: TestEnvironment = new TestEnvironment(); env.fixture.detectChanges(); env.id = new TextDocId('project01', 40, 1); tick(); env.fixture.detectChanges(); - let result: boolean | undefined; - env.component.projectHasText().then(res => { - result = res; - }); + const result: boolean = await env.component.projectHasText(); tick(); expect(result).toBe(true); })); - it('knows if project does not have chapter', fakeAsync(() => { + it('knows if project does not have chapter', fakeAsync(async () => { const env: TestEnvironment = new TestEnvironment(); env.fixture.detectChanges(); env.id = new TextDocId('project01', 40, 99); // Non-existent chapter tick(); env.fixture.detectChanges(); - let result: boolean | undefined; - env.component.projectHasText().then(res => { - result = res; - }); + const result: boolean = await env.component.projectHasText(); tick(); expect(result).toBe(false); })); - it('knows if project does not have book', fakeAsync(() => { + it('knows if project does not have book', fakeAsync(async () => { const env: TestEnvironment = new TestEnvironment(); env.fixture.detectChanges(); env.id = new TextDocId('project01', 3, 1); // Non-existent book tick(); env.fixture.detectChanges(); - let result: boolean | undefined; - env.component.projectHasText().then(res => { - result = res; - }); + const result: boolean = await env.component.projectHasText(); tick(); expect(result).toBe(false); @@ -1555,7 +1545,7 @@ describe('TextComponent', () => { it('should throw error if profile data is null', fakeAsync(() => { const env: TestEnvironment = new TestEnvironment(); - when(mockedProjectService.getProfile(anything())).thenResolve({ + when(mockedProjectService.getProfile(anything(), anything())).thenResolve({ data: null } as unknown as SFProjectProfileDoc); env.fixture.detectChanges(); @@ -1854,14 +1844,14 @@ class TestEnvironment { ) }); - when(mockedProjectService.getText(anything())).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockedProjectService.getText(anything(), anything())).thenCall(id => + this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), new DocSubscription('spec')) ); - when(mockedProjectService.getProfile(anything())).thenCall(() => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 'project01') + when(mockedProjectService.getProfile(anything(), anything())).thenCall(() => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 'project01', new DocSubscription('spec')) ); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01') + this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')) ); if (callback != null) { @@ -1936,8 +1926,8 @@ class TestEnvironment { this.fixture.detectChanges(); } - getUserDoc(userId: string): UserDoc { - return this.realtimeService.get(UserDoc.COLLECTION, userId); + async getUserDoc(userId: string): Promise { + return await this.realtimeService.get(UserDoc.COLLECTION, userId, new DocSubscription('spec')); } getSegment(segmentRef: string): HTMLElement | null { @@ -2115,16 +2105,14 @@ class TestEnvironment { /** Run the specified code while waiting for getText() to resolve. This allows the placeholder to be examined while * waiting for getText(), rather than examining the placeholder after getText() finishes. */ - runWithDelayedGetText(textDocId: TextDocId, code: () => void): void { + async runWithDelayedGetText(textDocId: TextDocId, code: () => void): Promise { let resolver: (_: TextDoc) => void = _ => {}; - let textDocBeingGotten: TextDoc = {} as TextDoc; - instance(mockedProjectService) - .getText(textDocId.toString()) - .then((value: TextDoc) => { - textDocBeingGotten = value; - }); + const textDocBeingGotten: TextDoc = await instance(mockedProjectService).getText( + textDocId.toString(), + new DocSubscription('spec') + ); tick(); - when(mockedProjectService.getText(textDocId)).thenReturn( + when(mockedProjectService.getText(textDocId, anything())).thenReturn( new Promise(resolve => { // (Don't resolve until we manually cause it.) resolver = resolve; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts index 800d32b6e97..bf96ed83ff2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts @@ -30,6 +30,7 @@ import tinyColor from 'tinycolor2'; import { WINDOW } from 'xforge-common/browser-globals'; import { DialogService } from 'xforge-common/dialog.service'; import { LocaleDirection } from 'xforge-common/models/i18n-locale'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; @@ -1111,7 +1112,10 @@ export class TextComponent implements AfterViewInit, OnDestroy { this.loadingState = 'permission-denied'; return false; } - const profileDoc: SFProjectProfileDoc = await this.projectService.getProfile(this.projectId); + const profileDoc: SFProjectProfileDoc = await this.projectService.getProfile( + this.projectId, + new DocSubscription('TextComponent', this.destroyRef) + ); if (profileDoc.data == null) throw new Error('Failed to fetch project profile.'); this.project = profileDoc.data; const chapterExists = this.project.texts.some( @@ -1197,7 +1201,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { // the text in IndexedDB, we will unfortunately briefly show that a book is unavailable offline, before it loads. // But if getText does not return, then we are showing a good message. this.loadingState = 'offline-or-loading'; - const textDoc = await this.projectService.getText(this._id); + const textDoc = await this.projectService.getText(this._id, new DocSubscription('TextComponent', this.destroyRef)); this.loadingState = 'loading'; this.viewModel.bind(this._id, textDoc, this.subscribeToUpdates); if (this.viewModel.isEmpty) this.loadingState = 'empty-viewModel'; @@ -1982,7 +1986,9 @@ export class TextComponent implements AfterViewInit, OnDestroy { if (!this.userProjects?.includes(this.projectId)) { return; } - const project = (await this.projectService.getProfile(this.projectId)).data; + const project = ( + await this.projectService.getProfile(this.projectId, new DocSubscription('TextComponent', this.destroyRef)) + ).data; if (project == null) { return; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.spec.ts index 8c9f4327977..950d61a3efa 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.spec.ts @@ -6,6 +6,7 @@ import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models import { firstValueFrom } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; @@ -39,91 +40,91 @@ describe('SyncProgressComponent', () => { it('does not initialize if projectDoc is undefined', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user01' }); expect(env.host.projectDoc).toBeUndefined(); - verify(mockedProjectService.get('sourceProject02')).never(); + verify(mockedProjectService.subscribe('sourceProject02', anything())).never(); expect(await env.getMode()).toBe('indeterminate'); })); it('does not initialize if app is offline', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user01' }); - env.setupProjectDoc(); + await env.setupProjectDoc(); env.onlineStatus = false; - verify(mockedProjectService.get('sourceProject02')).never(); + verify(mockedProjectService.subscribe('sourceProject02', anything())).never(); expect(await env.getMode()).toBe('indeterminate'); })); it('ignores source if source project is invalid', fakeAsync(async () => { when(mockedProjectService.onlineGetProjectRole('invalid_source')).thenResolve(SFProjectRole.None); const env = new TestEnvironment({ userId: 'user01', sourceProject: 'invalid_source' }); - env.setupProjectDoc(); + await env.setupProjectDoc(); verify(mockedProjectService.onlineGetProjectRole('invalid_source')).once(); - env.updateSyncProgress(0.5, 'testProject01'); + await env.updateSyncProgress(0.5, 'testProject01'); expect(env.host.inProgress).toBe(true); expect(await env.getPercent()).toEqual(50); expect(env.syncStatus).not.toBeNull(); - env.emitSyncComplete(true, 'testProject01'); + await env.emitSyncComplete(true, 'testProject01'); expect(env.host.inProgress).toBe(false); })); it('should show progress when sync is active', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user01' }); - env.setupProjectDoc(); + await env.setupProjectDoc(); // Simulate sync starting - env.updateSyncProgress(0, 'testProject01'); + await env.updateSyncProgress(0, 'testProject01'); expect(env.progressBar).not.toBeNull(); expect(await env.getMode()).toBe('indeterminate'); verify(mockedProjectService.onlineGetProjectRole('sourceProject02')).never(); // Simulate sync in progress - env.updateSyncProgress(0.5, 'testProject01'); + await env.updateSyncProgress(0.5, 'testProject01'); expect(await env.getMode()).toBe('determinate'); expect(env.syncStatus).not.toBeNull(); // Simulate sync completed - env.emitSyncComplete(true, 'testProject01'); + await env.emitSyncComplete(true, 'testProject01'); tick(); })); - it('show progress as source and target combined', fakeAsync(() => { + it('show progress as source and target combined', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user01', sourceProject: 'sourceProject02', translationSuggestionsEnabled: true }); - env.setupProjectDoc(); - env.checkCombinedProgress(); + await env.setupProjectDoc(); + await env.checkCombinedProgress(); expect(env.syncStatus).not.toBeNull(); tick(); })); - it('show source and target progress combined when translation suggestions disabled', fakeAsync(() => { + it('show source and target progress combined when translation suggestions disabled', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user01', sourceProject: 'sourceProject02', translationSuggestionsEnabled: false }); - env.setupProjectDoc(); - env.checkCombinedProgress(); + await env.setupProjectDoc(); + await env.checkCombinedProgress(); expect(env.syncStatus).not.toBeNull(); tick(); })); it('does not access source project if user does not have a paratext role', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user02', sourceProject: 'sourceProject02' }); - env.setupProjectDoc(); - env.updateSyncProgress(0, 'testProject01'); - env.updateSyncProgress(0, 'sourceProject02'); - verify(mockedProjectService.get('sourceProject02')).never(); - env.emitSyncComplete(true, 'sourceProject02'); - env.updateSyncProgress(0.5, 'testProject01'); + await env.setupProjectDoc(); + await env.updateSyncProgress(0, 'testProject01'); + await env.updateSyncProgress(0, 'sourceProject02'); + verify(mockedProjectService.subscribe('sourceProject02', anything())).never(); + await env.emitSyncComplete(true, 'sourceProject02'); + await env.updateSyncProgress(0.5, 'testProject01'); expect(await env.getPercent()).toEqual(50); expect(env.syncStatus).not.toBeNull(); - env.emitSyncComplete(true, 'testProject01'); + await env.emitSyncComplete(true, 'testProject01'); })); - it('does not throw error if get project role times out', fakeAsync(() => { + it('does not throw error if get project role times out', fakeAsync(async () => { const env = new TestEnvironment({ userId: 'user01', sourceProject: 'sourceProject02' }); when(mockedProjectService.onlineGetProjectRole('sourceProject02')).thenReject(new Error('504: Gateway Timeout')); - env.setupProjectDoc(); + await env.setupProjectDoc(); verify(mockedProjectService.onlineGetProjectRole('sourceProject02')).once(); - verify(mockedProjectService.get('sourceProject02')).never(); + verify(mockedProjectService.subscribe('sourceProject02', anything())).never(); verify(mockedErrorReportingService.silentError(anything(), anything())).once(); expect(env.progressBar).not.toBeNull(); expect(env.syncStatus).not.toBeNull(); @@ -141,8 +142,8 @@ class HostComponent { constructor(private readonly projectService: SFProjectService) {} - setProjectDoc(): void { - this.projectService.get('testProject01').then(doc => (this.projectDoc = doc)); + async setProjectDoc(): Promise { + this.projectDoc = await this.projectService.subscribe('testProject01', new DocSubscription('spec')); } } @@ -205,11 +206,11 @@ class TestEnvironment { ) }); } - when(mockedProjectService.get('testProject01')).thenCall(() => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'testProject01') + when(mockedProjectService.subscribe('testProject01', anything())).thenCall(() => + this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'testProject01', new DocSubscription('spec')) ); - when(mockedProjectService.get('sourceProject02')).thenCall(() => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'sourceProject02') + when(mockedProjectService.subscribe('sourceProject02', anything())).thenCall(() => + this.realtimeService.subscribe(SFProjectDoc.COLLECTION, 'sourceProject02', new DocSubscription('spec')) ); when(mockedProjectService.onlineGetProjectRole('sourceProject02')).thenResolve(this.userRoleSource[args.userId]); @@ -234,8 +235,12 @@ class TestEnvironment { this.fixture.detectChanges(); } - updateSyncProgress(percentCompleted: number, projectId: string): void { - const projectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, projectId); + async updateSyncProgress(percentCompleted: number, projectId: string): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); projectDoc.submitJson0Op(ops => { ops.set(p => p.sync.queuedCount, 1); }, false); @@ -245,9 +250,13 @@ class TestEnvironment { tick(); } - emitSyncComplete(successful: boolean, projectId: string): void { + async emitSyncComplete(successful: boolean, projectId: string): Promise { this.host.syncProgress['updateProgressState'](projectId, new ProgressState(1)); - const projectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, projectId); + const projectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); projectDoc.submitJson0Op(ops => { ops.set(p => p.sync.queuedCount, 0); ops.set(p => p.sync.lastSyncSuccessful, successful); @@ -258,37 +267,37 @@ class TestEnvironment { this.fixture.detectChanges(); } - setupProjectDoc(): void { - this.host.setProjectDoc(); + async setupProjectDoc(): Promise { + await this.host.setProjectDoc(); tick(); this.fixture.detectChanges(); tick(); } async checkCombinedProgress(): Promise { - this.updateSyncProgress(0, 'testProject01'); - this.updateSyncProgress(0, 'sourceProject02'); + await this.updateSyncProgress(0, 'testProject01'); + await this.updateSyncProgress(0, 'sourceProject02'); verify(mockedProjectService.onlineGetProjectRole('sourceProject02')).once(); - verify(mockedProjectService.get('sourceProject02')).once(); + verify(mockedProjectService.subscribe('sourceProject02', anything())).once(); expect(this.progressBar).not.toBeNull(); expect(await this.getMode()).toBe('indeterminate'); - this.updateSyncProgress(0.8, 'sourceProject02'); + await this.updateSyncProgress(0.8, 'sourceProject02'); expect(await this.getPercent()).toEqual(40); expect(await this.getMode()).toBe('determinate'); - this.emitSyncComplete(true, 'sourceProject02'); + await this.emitSyncComplete(true, 'sourceProject02'); expect(await this.getPercent()).toEqual(50); expect(await this.getMode()).toBe('determinate'); - this.updateSyncProgress(0.8, 'testProject01'); + await this.updateSyncProgress(0.8, 'testProject01'); expect(await this.getPercent()).toEqual(90); - this.emitSyncComplete(true, 'testProject01'); + await this.emitSyncComplete(true, 'testProject01'); } async getMode(): Promise { - return firstValueFrom(this.host.syncProgress.syncProgressMode$); + return await firstValueFrom(this.host.syncProgress.syncProgressMode$); } async getPercent(): Promise { - return firstValueFrom(this.host.syncProgress.syncProgressPercent$); + return await firstValueFrom(this.host.syncProgress.syncProgressPercent$); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.ts index c5c6b791cd2..1ec4550b654 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-progress/sync-progress.component.ts @@ -7,6 +7,7 @@ import { isParatextRole } from 'realtime-server/lib/esm/scriptureforge/models/sf import { BehaviorSubject, map, merge, Observable } from 'rxjs'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectDoc } from '../../core/models/sf-project-doc'; @@ -113,7 +114,10 @@ export class SyncProgressComponent { const role: string = await this.projectService.onlineGetProjectRole(sourceProjectId); // Only show progress for the source project when the user has sync if (isParatextRole(role)) { - this.sourceProjectDoc = await this.projectService.get(sourceProjectId); + this.sourceProjectDoc = await this.projectService.subscribe( + sourceProjectId, + new DocSubscription('SyncProgressComponent', this.destroyRef) + ); // Subscribe to SignalR notifications for the source project await this.projectNotificationService.subscribeToProject(this.sourceProjectDoc.id); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts index bc8844023bd..85c3a367ab2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts @@ -11,6 +11,7 @@ import { AuthService } from 'xforge-common/auth.service'; import { BugsnagService } from 'xforge-common/bugsnag.service'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -89,10 +90,10 @@ describe('SyncComponent', () => { expect(env.offlineMessage).toBeNull(); })); - it('should sync project when the button is clicked', fakeAsync(() => { + it('should sync project when the button is clicked', fakeAsync(async () => { const env = new TestEnvironment(); const previousLastSyncDate = env.component.lastSyncDate; - verify(mockedProjectService.get(env.projectId)).once(); + verify(mockedProjectService.subscribe(env.projectId, anything())).once(); env.clickElement(env.syncButton); @@ -102,7 +103,7 @@ describe('SyncComponent', () => { expect(env.cancelButton).not.toBeNull(); expect(env.paratextAccountNotice).toBeNull(); expect(env.syncButton).toBeNull(); - env.emitSyncComplete(true, env.projectId); + await env.emitSyncComplete(true, env.projectId); expect(env.component.lastSyncDate!.getTime()).toBeGreaterThan(previousLastSyncDate!.getTime()); verify(mockedNoticeService.show('Successfully synchronized Sync Test Project with Paratext.')).once(); })); @@ -120,35 +121,35 @@ describe('SyncComponent', () => { expect(env.component.syncActive).toBe(false); })); - it('should report error if sync has a problem', fakeAsync(() => { + it('should report error if sync has a problem', fakeAsync(async () => { const env = new TestEnvironment(); - verify(mockedProjectService.get(env.projectId)).once(); + verify(mockedProjectService.subscribe(env.projectId, anything())).once(); env.clickElement(env.syncButton); verify(mockedProjectService.onlineSync(env.projectId)).once(); expect(env.component.syncActive).toBe(true); expect(env.progressBar).not.toBeNull(); // Simulate sync in progress - env.setQueuedCount(env.projectId); + await env.setQueuedCount(env.projectId); // Simulate sync error - env.emitSyncComplete(false, env.projectId); + await env.emitSyncComplete(false, env.projectId); expect(env.component.syncActive).toBe(false); verify(mockedDialogService.message(anything())).once(); })); - it('should report user permissions error if sync failed for that reason', fakeAsync(() => { + it('should report user permissions error if sync failed for that reason', fakeAsync(async () => { const env = new TestEnvironment({ lastSyncErrorCode: -1, lastSyncWasSuccessful: false }); - verify(mockedProjectService.get(env.projectId)).once(); + verify(mockedProjectService.subscribe(env.projectId, anything())).once(); env.clickElement(env.syncButton); verify(mockedProjectService.onlineSync(env.projectId)).once(); expect(env.component.syncActive).toBe(true); expect(env.progressBar).not.toBeNull(); // Simulate sync in progress - env.setQueuedCount(env.projectId); + await env.setQueuedCount(env.projectId); // Simulate sync error - env.emitSyncComplete(false, env.projectId); + await env.emitSyncComplete(false, env.projectId); expect(env.component.syncActive).toBe(false); expect(env.component.showSyncUserPermissionsFailureMessage).toBe(true); @@ -207,18 +208,18 @@ describe('SyncComponent', () => { expect(env.syncDisabledMessage).toBeNull(); })); - it('should not report if sync was cancelled', fakeAsync(() => { + it('should not report if sync was cancelled', fakeAsync(async () => { const env = new TestEnvironment(); const previousLastSyncDate = env.component.lastSyncDate; - verify(mockedProjectService.get(env.projectId)).once(); + verify(mockedProjectService.subscribe(env.projectId, anything())).once(); env.clickElement(env.syncButton); verify(mockedProjectService.onlineSync(env.projectId)).once(); expect(env.component.syncActive).toBe(true); expect(env.progressBar).not.toBeNull(); - env.setQueuedCount(env.projectId); + await env.setQueuedCount(env.projectId); env.clickElement(env.cancelButton); - env.emitSyncComplete(false, env.projectId); + await env.emitSyncComplete(false, env.projectId); expect(env.component.syncActive).toBe(false); expect(env.component.lastSyncDate).toEqual(previousLastSyncDate); @@ -226,16 +227,16 @@ describe('SyncComponent', () => { verify(mockedDialogService.message(anything())).never(); })); - it('should report success if sync was cancelled but had finished', fakeAsync(() => { + it('should report success if sync was cancelled but had finished', fakeAsync(async () => { const env = new TestEnvironment(); - verify(mockedProjectService.get(env.projectId)).once(); + verify(mockedProjectService.subscribe(env.projectId, anything())).once(); env.clickElement(env.syncButton); verify(mockedProjectService.onlineSync(env.projectId)).once(); expect(env.component.syncActive).toBe(true); expect(env.progressBar).not.toBeNull(); env.clickElement(env.cancelButton); - env.emitSyncComplete(true, env.projectId); + await env.emitSyncComplete(true, env.projectId); verify(mockedNoticeService.show('Successfully synchronized Sync Test Project with Paratext.')).once(); verify(mockedDialogService.message(anything())).never(); @@ -298,8 +299,9 @@ class TestEnvironment { }) }); - when(mockedProjectService.get(anyString())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId) + when(mockedProjectService.subscribe(anyString(), anything())).thenCall( + async projectId => + await this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId, new DocSubscription('spec')) ); this.fixture = TestBed.createComponent(SyncComponent); @@ -368,14 +370,22 @@ class TestEnvironment { tick(); } - setQueuedCount(projectId: string): void { - const projectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, projectId); + async setQueuedCount(projectId: string): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); projectDoc.submitJson0Op(op => op.set(p => p.sync.queuedCount, 1), false); this.fixture.detectChanges(); } - emitSyncComplete(successful: boolean, projectId: string): void { - const projectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, projectId); + async emitSyncComplete(successful: boolean, projectId: string): Promise { + const projectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); projectDoc.submitJson0Op(ops => { ops.set(p => p.sync.queuedCount, 0); ops.set(p => p.sync.lastSyncSuccessful!, successful); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts index ea90e8be6fd..413e6a3506e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts @@ -14,6 +14,7 @@ import { CommandErrorCode } from 'xforge-common/command.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -187,7 +188,10 @@ export class SyncComponent extends DataLoadingComponent implements OnInit { ); projectId$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe(async projectId => { - this.projectDoc = await this.projectService.get(projectId); + this.projectDoc = await this.projectService.subscribe( + projectId, + new DocSubscription('SyncComponent', this.destroyRef) + ); this.checkSyncStatus(); this.loadingFinished(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts index 4dff5573bcc..4cfb6680ba9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts @@ -14,6 +14,7 @@ import * as RichText from 'rich-text'; import { firstValueFrom, of } from 'rxjs'; import { anything, instance, mock, spy, when } from 'ts-mockito'; import { DOCUMENT } from 'xforge-common/browser-globals'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; @@ -473,7 +474,7 @@ class TestEnvironment { const chooserDialogResult = new VerseRef('LUK', '1', '2'); when(this.mockedScriptureChooserMatDialogRef.afterClosed()).thenReturn(of(chooserDialogResult)); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01') + this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')) ); this.fixture.detectChanges(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-term-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-term-dialog.component.spec.ts index 65353edf4f3..df494196b47 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-term-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-term-dialog.component.spec.ts @@ -13,6 +13,7 @@ import { } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; import { mock, when } from 'ts-mockito'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; import { @@ -111,7 +112,7 @@ describe('BiblicalTermDialogComponent', () => { env.closeDialog(); })); - it('should save changes to the biblical term', fakeAsync(() => { + it('should save changes to the biblical term', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData('en'); env.wait(); @@ -121,12 +122,12 @@ describe('BiblicalTermDialogComponent', () => { env.setTextFieldValue(env.description, 'updatedDescription'); env.click(env.submitButton); env.wait(); - const biblicalTerm = env.getBiblicalTermDoc('id01'); + const biblicalTerm = await env.getBiblicalTermDoc('id01'); expect(biblicalTerm.data?.renderings).toEqual(['updatedRendering', 'secondRendering', 'thirdRendering']); expect(biblicalTerm.data?.description).toBe('updatedDescription'); })); - it('should remove empty lines from renderings', fakeAsync(() => { + it('should remove empty lines from renderings', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData('en'); env.wait(); @@ -136,12 +137,12 @@ describe('BiblicalTermDialogComponent', () => { env.setTextFieldValue(env.description, ''); env.click(env.submitButton); env.wait(); - const biblicalTerm = env.getBiblicalTermDoc('id01'); + const biblicalTerm = await env.getBiblicalTermDoc('id01'); expect(biblicalTerm.data?.renderings).toEqual([]); expect(biblicalTerm.data?.description).toBe(''); })); - it('should not save renderings with unbalanced parentheses', fakeAsync(() => { + it('should not save renderings with unbalanced parentheses', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData('en'); env.wait(); @@ -150,12 +151,12 @@ describe('BiblicalTermDialogComponent', () => { env.setTextFieldValue(env.renderings, '('); env.click(env.submitButton); env.wait(); - const biblicalTerm = env.getBiblicalTermDoc('id01'); + const biblicalTerm = await env.getBiblicalTermDoc('id01'); expect(biblicalTerm.data?.renderings).toEqual(['rendering01']); env.closeDialog(); })); - it('should save renderings with balanced parentheses', fakeAsync(() => { + it('should save renderings with balanced parentheses', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData('en'); env.wait(); @@ -164,7 +165,7 @@ describe('BiblicalTermDialogComponent', () => { env.setTextFieldValue(env.renderings, '()'); env.click(env.submitButton); env.wait(); - const biblicalTerm = env.getBiblicalTermDoc('id01'); + const biblicalTerm = await env.getBiblicalTermDoc('id01'); expect(biblicalTerm.data?.renderings).toEqual(['()']); })); @@ -228,23 +229,28 @@ class TestEnvironment { tick(matDialogCloseDelay); } - getBiblicalTermDoc(id: string): BiblicalTermDoc { - return this.realtimeService.get(BiblicalTermDoc.COLLECTION, id); + async getBiblicalTermDoc(id: string): Promise { + return await this.realtimeService.get(BiblicalTermDoc.COLLECTION, id, new DocSubscription('spec')); } - getProjectDoc(id: string): SFProjectProfileDoc { - return this.realtimeService.get(SFProjectProfileDoc.COLLECTION, id); + async getProjectDoc(id: string): Promise { + return await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + id, + new DocSubscription('spec') + ); } openDialog(biblicalTermId: string, userId: string = 'user01'): void { this.realtimeService .subscribe( SF_PROJECT_USER_CONFIGS_COLLECTION, - getSFProjectUserConfigDocId('project01', userId) + getSFProjectUserConfigDocId('project01', userId), + new DocSubscription('spec') ) - .then(projectUserConfigDoc => { - const biblicalTermDoc = this.getBiblicalTermDoc(biblicalTermId); - const projectDoc = this.getProjectDoc('project01'); + .then(async projectUserConfigDoc => { + const biblicalTermDoc = await this.getBiblicalTermDoc(biblicalTermId); + const projectDoc = await this.getProjectDoc('project01'); const viewContainerRef = this.fixture.componentInstance.childViewContainer; const config: MatDialogConfig = { data: { biblicalTermDoc, projectDoc, projectUserConfigDoc }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.spec.ts index 3a02f6aa8bc..23fd0cf5a18 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.spec.ts @@ -27,6 +27,7 @@ import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; import { GenericDialogComponent, GenericDialogOptions } from 'xforge-common/generic-dialog/generic-dialog.component'; import { I18nService } from 'xforge-common/i18n.service'; import { Locale } from 'xforge-common/models/i18n-locale'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { QueryParameters } from 'xforge-common/query-parameters'; import { noopDestroyRef } from 'xforge-common/realtime.service'; @@ -122,7 +123,7 @@ describe('BiblicalTermsComponent', () => { expect((env.biblicalTermsCategory[0] as HTMLElement).innerText).toBe('category03_en'); })); - it('should filter biblical terms by category', fakeAsync(() => { + it('should filter biblical terms by category', fakeAsync(async () => { const env = new TestEnvironment('project01', 1, 1, '1'); env.setupProjectData('en'); env.wait(); @@ -133,12 +134,12 @@ describe('BiblicalTermsComponent', () => { expect(env.biblicalTermsTerm.length).toBe(1); expect((env.biblicalTermsTerm[0] as HTMLElement).innerText).toBe('termId04'); expect((env.biblicalTermsCategory[0] as HTMLElement).innerText).toBe('category04_en'); - const projectUserConfig = env.getProjectUserConfigDoc('project01', 'user01').data; + const projectUserConfig = (await env.getProjectUserConfigDoc('project01', 'user01')).data; expect(projectUserConfig?.selectedBiblicalTermsFilter).toBe('current_book'); expect(projectUserConfig?.selectedBiblicalTermsCategory).toBe('category04_en'); })); - it('should filter biblical terms by book', fakeAsync(() => { + it('should filter biblical terms by book', fakeAsync(async () => { const env = new TestEnvironment('project01', 1, 1, '1'); env.setupProjectData('en'); env.wait(); @@ -151,10 +152,12 @@ describe('BiblicalTermsComponent', () => { expect((env.biblicalTermsCategory[1] as HTMLElement).innerText).toBe('category04_en'); expect((env.biblicalTermsTerm[2] as HTMLElement).innerText).toBe('termId05'); expect((env.biblicalTermsCategory[2] as HTMLElement).innerText).toBe('category05_en'); - expect(env.getProjectUserConfigDoc('project01', 'user01').data?.selectedBiblicalTermsFilter).toBe('current_book'); + expect((await env.getProjectUserConfigDoc('project01', 'user01')).data?.selectedBiblicalTermsFilter).toBe( + 'current_book' + ); })); - it('should filter biblical terms by chapter', fakeAsync(() => { + it('should filter biblical terms by chapter', fakeAsync(async () => { const env = new TestEnvironment('project01', 1, 1, '1'); env.setupProjectData('en'); env.wait(); @@ -165,7 +168,7 @@ describe('BiblicalTermsComponent', () => { expect((env.biblicalTermsCategory[0] as HTMLElement).innerText).toBe('category01_en'); expect((env.biblicalTermsTerm[1] as HTMLElement).innerText).toBe('termId04'); expect((env.biblicalTermsCategory[1] as HTMLElement).innerText).toBe('category04_en'); - expect(env.getProjectUserConfigDoc('project01', 'user01').data?.selectedBiblicalTermsFilter).toBe( + expect((await env.getProjectUserConfigDoc('project01', 'user01')).data?.selectedBiblicalTermsFilter).toBe( 'current_chapter' ); })); @@ -292,7 +295,7 @@ describe('BiblicalTermsComponent', () => { expect((env.biblicalTermsTerm[0] as HTMLElement).innerText).toBe('transliteration01'); })); - it('can save a new note thread for a biblical term', fakeAsync(() => { + it('can save a new note thread for a biblical term', fakeAsync(async () => { const projectId = 'project01'; const env = new TestEnvironment(projectId, 2, 2, '2'); env.setupProjectData('en'); @@ -310,9 +313,9 @@ describe('BiblicalTermsComponent', () => { const biblicalTermId: string = (config as MatDialogConfig).data!.biblicalTermId; expect(biblicalTermId.toString()).toEqual('dataId02'); - const biblicalTerm = env.getBiblicalTermDoc(projectId, biblicalTermId); + const biblicalTerm = await env.getBiblicalTermDoc(projectId, biblicalTermId); const verseData: VerseRefData = fromVerseRef(new VerseRef(biblicalTerm.data!.references[0])); - verify(mockedProjectService.createNoteThread(projectId, anything())).once(); + verify(mockedProjectService.createNoteThread(projectId, anything(), anything())).once(); const [, noteThread] = capture(mockedProjectService.createNoteThread).last(); expect(noteThread.verseRef).toEqual(verseData); expect(noteThread.originalSelectedText).toEqual(''); @@ -320,11 +323,11 @@ describe('BiblicalTermsComponent', () => { expect(noteThread.notes[0].ownerRef).toEqual('user01'); expect(noteThread.notes[0].content).toEqual(XmlUtils.encodeForXml(noteContent)); expect(noteThread.notes[0].tagId).toEqual(BIBLICAL_TERM_TAG_ID); - const projectUserConfigDoc: SFProjectUserConfigDoc = env.getProjectUserConfigDoc('project01', 'user01'); + const projectUserConfigDoc: SFProjectUserConfigDoc = await env.getProjectUserConfigDoc('project01', 'user01'); expect(projectUserConfigDoc.data!.noteRefsRead).not.toContain(noteThread.notes[0].dataId); })); - it('can save a note for an existing biblical term', fakeAsync(() => { + it('can save a note for an existing biblical term', fakeAsync(async () => { const projectId = 'project01'; const noteDataId = 'dataId01'; const env = new TestEnvironment(projectId, 1, 1); @@ -343,20 +346,20 @@ describe('BiblicalTermsComponent', () => { const biblicalTermId: string = (config as MatDialogConfig).data!.biblicalTermId; expect(biblicalTermId.toString()).toEqual(noteDataId); - const biblicalTerm = env.getBiblicalTermDoc(projectId, biblicalTermId); + const biblicalTerm = await env.getBiblicalTermDoc(projectId, biblicalTermId); const verseData: VerseRefData = fromVerseRef(new VerseRef(biblicalTerm.data!.references[0])); - const noteThread = env.getNoteThreadDoc(projectId, 'threadId01').data!; + const noteThread = (await env.getNoteThreadDoc(projectId, 'threadId01')).data!; expect(noteThread.verseRef).toEqual(verseData); expect(noteThread.originalSelectedText).toEqual(''); expect(noteThread.publishedToSF).toBe(true); expect(noteThread.notes[1].ownerRef).toEqual('user01'); expect(noteThread.notes[1].content).toEqual(noteContent); expect(noteThread.notes[1].tagId).toEqual(BIBLICAL_TERM_TAG_ID); - const projectUserConfigDoc: SFProjectUserConfigDoc = env.getProjectUserConfigDoc('project01', 'user01'); + const projectUserConfigDoc: SFProjectUserConfigDoc = await env.getProjectUserConfigDoc('project01', 'user01'); expect(projectUserConfigDoc.data!.noteRefsRead).toContain(noteThread.notes[1].dataId); })); - it('can resolve a note for a biblical term', fakeAsync(() => { + it('can resolve a note for a biblical term', fakeAsync(async () => { const projectId = 'project01'; const env = new TestEnvironment(projectId, 1, 1); env.setupProjectData('en'); @@ -369,7 +372,7 @@ describe('BiblicalTermsComponent', () => { env.wait(); verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const noteThread: NoteThread = env.getNoteThreadDoc(projectId, 'threadId01').data!; + const noteThread: NoteThread = (await env.getNoteThreadDoc(projectId, 'threadId01')).data!; expect(noteThread.status).toBe(NoteStatus.Resolved); expect(noteThread.notes[1].content).toBeUndefined(); expect(noteThread.notes[1].status).toBe(NoteStatus.Resolved); @@ -383,7 +386,7 @@ describe('BiblicalTermsComponent', () => { env.wait(); // Make the note editable - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc(projectId, 'threadId01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc(projectId, 'threadId01'); await noteThreadDoc.submitJson0Op(op => op.set(nt => nt.notes[0].editable, true)); // SUT @@ -393,13 +396,13 @@ describe('BiblicalTermsComponent', () => { env.wait(); verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const noteThread: NoteThread = env.getNoteThreadDoc(projectId, 'threadId01').data!; + const noteThread: NoteThread = (await env.getNoteThreadDoc(projectId, 'threadId01')).data!; expect(noteThread.status).toBe(NoteStatus.Resolved); expect(noteThread.notes[0].content).toBe(newContent); expect(noteThread.notes[0].status).toBe(NoteStatus.Resolved); })); - it('cannot resolve a non-editable note for a biblical term', fakeAsync(() => { + it('cannot resolve a non-editable note for a biblical term', fakeAsync(async () => { const projectId = 'project01'; const env = new TestEnvironment(projectId, 1, 1); env.setupProjectData('en'); @@ -415,7 +418,7 @@ describe('BiblicalTermsComponent', () => { env.wait(); verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const noteThread: NoteThread = env.getNoteThreadDoc(projectId, 'threadId01').data!; + const noteThread: NoteThread = (await env.getNoteThreadDoc(projectId, 'threadId01')).data!; expect(noteThread.status).toEqual(NoteStatus.Todo); expect(noteThread.notes.length).toBe(1); expect(dialogMessage).toHaveBeenCalledTimes(1); @@ -487,26 +490,32 @@ class TestEnvironment { const parameters: QueryParameters = { [obj().pathStr(t => t.projectRef)]: sfProjectId }; - return this.realtimeService.subscribeQuery(BiblicalTermDoc.COLLECTION, parameters, noopDestroyRef); + return this.realtimeService.subscribeQuery(BiblicalTermDoc.COLLECTION, 'spec', parameters, noopDestroyRef); }); when(mockedProjectService.queryBiblicalTermNoteThreads(anything(), anything())).thenCall(sfProjectId => { const parameters: QueryParameters = { [obj().pathStr(t => t.projectRef)]: sfProjectId, [obj().pathStr(t => t.biblicalTermId)]: { $ne: null } }; - return this.realtimeService.subscribeQuery(NoteThreadDoc.COLLECTION, parameters, noopDestroyRef); + return this.realtimeService.subscribeQuery(NoteThreadDoc.COLLECTION, 'spec', parameters, noopDestroyRef); }); - when(mockedProjectService.getBiblicalTerm(anything())).thenCall(id => - this.realtimeService.subscribe(BiblicalTermDoc.COLLECTION, id) + when(mockedProjectService.getBiblicalTerm(anything(), anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(BiblicalTermDoc.COLLECTION, id, subscriber) ); - when(mockedProjectService.getNoteThread(anything())).thenCall(id => - this.realtimeService.subscribe(NoteThreadDoc.COLLECTION, id) + when(mockedProjectService.getNoteThread(anything(), anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(NoteThreadDoc.COLLECTION, id, subscriber) ); - when(mockedProjectService.getUserConfig(anything(), anything())).thenCall((projectId, userId) => - this.realtimeService.get(SFProjectUserConfigDoc.COLLECTION, getSFProjectUserConfigDocId(projectId, userId)) + when(mockedProjectService.getUserConfig(anything(), anything(), anything())).thenCall( + (projectId, userId, subscriber) => + this.realtimeService.subscribe( + SFProjectUserConfigDoc.COLLECTION, + getSFProjectUserConfigDocId(projectId, userId), + subscriber + ) ); - when(mockedProjectService.getProfile(anything())).thenCall(sfProjectId => - this.realtimeService.get(SFProjectProfileDoc.COLLECTION, sfProjectId) + when(mockedProjectService.getProfile(anything(), anything())).thenCall( + async (sfProjectId, subscriber) => + await this.realtimeService.get(SFProjectProfileDoc.COLLECTION, sfProjectId, subscriber) ); when(mockedMatDialog.open(GenericDialogComponent, anything())).thenReturn(instance(this.mockedDialogRef)); when(this.mockedDialogRef.afterClosed()).thenReturn(of()); @@ -565,17 +574,29 @@ class TestEnvironment { this.fixture.detectChanges(); } - getBiblicalTermDoc(projectId: string, dataId: string): BiblicalTermDoc { - return this.realtimeService.get(BiblicalTermDoc.COLLECTION, getBiblicalTermDocId(projectId, dataId)); + async getBiblicalTermDoc(projectId: string, dataId: string): Promise { + return await this.realtimeService.get( + BiblicalTermDoc.COLLECTION, + getBiblicalTermDocId(projectId, dataId), + new DocSubscription('spec') + ); } - getNoteThreadDoc(projectId: string, threadId: string): NoteThreadDoc { - return this.realtimeService.get(NoteThreadDoc.COLLECTION, getNoteThreadDocId(projectId, threadId)); + async getNoteThreadDoc(projectId: string, threadId: string): Promise { + return await this.realtimeService.get( + NoteThreadDoc.COLLECTION, + getNoteThreadDocId(projectId, threadId), + new DocSubscription('spec') + ); } - getProjectUserConfigDoc(projectId: string, userId: string): SFProjectUserConfigDoc { + async getProjectUserConfigDoc(projectId: string, userId: string): Promise { const id: string = getSFProjectUserConfigDocId(projectId, userId); - return this.realtimeService.get(SFProjectUserConfigDoc.COLLECTION, id); + return await this.realtimeService.get( + SFProjectUserConfigDoc.COLLECTION, + id, + new DocSubscription('spec') + ); } setLanguage(language: string): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.ts index 021991b70e7..cc4b08fee07 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/biblical-terms/biblical-terms.component.ts @@ -42,6 +42,7 @@ import { filter, switchMap } from 'rxjs/operators'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -350,8 +351,15 @@ export class BiblicalTermsComponent extends DataLoadingComponent implements OnDe quietTakeUntilDestroyed(this.destroyRef) ) .subscribe(async projectId => { - this.projectDoc = await this.projectService.getProfile(projectId); - this.projectUserConfigDoc = await this.projectService.getUserConfig(projectId, this.userService.currentUserId); + this.projectDoc = await this.projectService.getProfile( + projectId, + new DocSubscription('BiblicalTermsComponent', this.destroyRef) + ); + this.projectUserConfigDoc = await this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('BiblicalTermsComponent', this.destroyRef) + ); // Subscribe to any project, book, chapter, verse, locale, biblical term, or note changes this.loadingStarted(); @@ -426,7 +434,10 @@ export class BiblicalTermsComponent extends DataLoadingComponent implements OnDe } async editRendering(id: string): Promise { - const biblicalTermDoc = await this.projectService.getBiblicalTerm(getBiblicalTermDocId(this._projectId!, id)); + const biblicalTermDoc = await this.projectService.getBiblicalTerm( + getBiblicalTermDocId(this._projectId!, id), + new DocSubscription('BiblicalTermsComponent', this.destroyRef) + ); this.dialogService.openMatDialog(BiblicalTermDialogComponent, { data: { biblicalTermDoc, projectDoc: this.projectDoc, projectUserConfigDoc: this.projectUserConfigDoc }, width: '560px' @@ -583,7 +594,8 @@ export class BiblicalTermsComponent extends DataLoadingComponent implements OnDe return; } const biblicalTermDoc = await this.projectService.getBiblicalTerm( - getBiblicalTermDocId(this._projectId!, params.biblicalTermId) + getBiblicalTermDocId(this._projectId!, params.biblicalTermId), + new DocSubscription('BiblicalTermsComponent', this.destroyRef) ); if (biblicalTermDoc?.data == null) { return; @@ -644,11 +656,16 @@ export class BiblicalTermsComponent extends DataLoadingComponent implements OnDe transliteration: biblicalTermDoc.data.transliteration } }; - await this.projectService.createNoteThread(this._projectId, noteThread); + await this.projectService.createNoteThread( + this._projectId, + noteThread, + new DocSubscription('BiblicalTermsComponent', this.destroyRef) + ); } else { // updated the existing note const threadDoc: NoteThreadDoc = await this.projectService.getNoteThread( - getNoteThreadDocId(this._projectId, params.threadDataId) + getNoteThreadDocId(this._projectId, params.threadDataId), + new DocSubscription('BiblicalTermsComponent', this.destroyRef) ); const noteIndex: number = threadDoc.data!.notes.findIndex(n => n.dataId === params.dataId); if (noteIndex >= 0) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.spec.ts index cc7a9b3d201..a8c32dbe2d0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.spec.ts @@ -18,6 +18,7 @@ import { ExternalUrlService } from 'xforge-common/external-url.service'; import { FileService } from 'xforge-common/file.service'; import { I18nService } from 'xforge-common/i18n.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -94,23 +95,22 @@ describe('ConfigureSourcesComponent', () => { overlayContainer.ngOnDestroy(); }); - it('loads projects and resources on init', fakeAsync(() => { - const env = new TestEnvironment(); + it('loads projects and resources on init', fakeAsync(async () => { + const env = await TestEnvironment.create(); verify(mockedParatextService.getProjects()).once(); verify(mockedParatextService.getResources()).once(); expect(env.component.projects).toBeDefined(); expect(env.component.resources).toBeDefined(); })); - it('suppresses network errors', fakeAsync(() => { - const env = new TestEnvironment({ projectLoadSuccessful: false }); - tick(); + it('suppresses network errors', fakeAsync(async () => { + const env = await TestEnvironment.create({ projectLoadSuccessful: false }); env.fixture.detectChanges(); expect(env.component.projects).toBeUndefined(); })); - it('loads projects and resources when returning online', fakeAsync(() => { - const env = new TestEnvironment({ isOnline: false }); + it('loads projects and resources when returning online', fakeAsync(async () => { + const env = await TestEnvironment.create({ isOnline: false }); verify(mockedParatextService.getProjects()).never(); verify(mockedParatextService.getResources()).never(); expect(env.component.projects).toBeUndefined(); @@ -127,9 +127,8 @@ describe('ConfigureSourcesComponent', () => { })); describe('save', () => { - it('should save the settings', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('should save the settings', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); expect(env.component['changesMade']).toBe(false); env.clickLanguageCodesConfirmationCheckbox(); @@ -153,7 +152,7 @@ describe('ConfigureSourcesComponent', () => { verify(mockedDialogService.confirm(anything(), anything(), anything())).never(); // SUT - env.component.save(); + await env.component.save(); tick(); verify(mockedSFProjectService.onlineUpdateSettings(env.activatedProjectDoc.id, anything())).once(); const actualSettingsChangeRequest: SFProjectSettings = capture( @@ -162,9 +161,8 @@ describe('ConfigureSourcesComponent', () => { expect(actualSettingsChangeRequest).toEqual(expectedSettingsChangeRequest); })); - it('clearing second training source works', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('clearing second training source works', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); expect(env.component['changesMade']).toBe(false); @@ -194,7 +192,7 @@ describe('ConfigureSourcesComponent', () => { verify(mockedDialogService.confirm(anything(), anything(), anything())).once(); // SUT - env.component.save(); + await env.component.save(); tick(); verify(mockedSFProjectService.onlineUpdateSettings(env.activatedProjectDoc.id, anything())).once(); const actualSettingsChangeRequest: SFProjectSettings = capture( @@ -203,9 +201,8 @@ describe('ConfigureSourcesComponent', () => { expect(actualSettingsChangeRequest).toEqual(expectedSettingsChangeRequest); })); - it('clearing first training source works', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('clearing first training source works', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); expect(env.component['changesMade']).toBe(false); env.clickLanguageCodesConfirmationCheckbox(); @@ -232,7 +229,7 @@ describe('ConfigureSourcesComponent', () => { tick(); // SUT - env.component.save(); + await env.component.save(); tick(); verify(mockedSFProjectService.onlineUpdateSettings(env.activatedProjectDoc.id, anything())).once(); const actualSettingsChangeRequest: SFProjectSettings = capture( @@ -241,9 +238,8 @@ describe('ConfigureSourcesComponent', () => { expect(actualSettingsChangeRequest).toEqual(expectedSettingsChangeRequest); })); - it('fails to save and sync', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('fails to save and sync', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); @@ -260,14 +256,13 @@ describe('ConfigureSourcesComponent', () => { ); // SUT - env.component.save(); + await env.component.save(); tick(); verify(mockedSFProjectService.onlineUpdateSettings(env.activatedProjectDoc.id, anything())).once(); })); - it('can edit second source after first is cleared', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('can edit second source after first is cleared', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); @@ -285,9 +280,8 @@ describe('ConfigureSourcesComponent', () => { expect(env.component.trainingSources.length).toEqual(2); })); - it('saves the selected training files', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('saves the selected training files', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); @@ -303,7 +297,7 @@ describe('ConfigureSourcesComponent', () => { env.component.onTrainingDataSelect([{ dataId: 'test1' } as TrainingData, { dataId: 'test2' } as TrainingData]); - env.component.save(); + await env.component.save(); tick(); verify(mockedSFProjectService.onlineUpdateSettings(env.activatedProjectDoc.id, anything())).once(); const actualSettingsChangeRequest: SFProjectSettings = capture( @@ -312,9 +306,8 @@ describe('ConfigureSourcesComponent', () => { expect(actualSettingsChangeRequest).toEqual(expectedSettingsChangeRequest); })); - it('creates training data for added files', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('creates training data for added files', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); @@ -325,14 +318,13 @@ describe('ConfigureSourcesComponent', () => { const newFile = { dataId: 'test1' } as TrainingData; env.component.onTrainingDataSelect([newFile]); - env.component.save(); + await env.component.save(); verify(mockTrainingDataService.createTrainingDataAsync(newFile)).once(); })); - it('deletes training data for removed files', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('deletes training data for removed files', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); @@ -344,7 +336,7 @@ describe('ConfigureSourcesComponent', () => { expect(env.component.availableTrainingFiles.length).toEqual(2); env.component.onTrainingDataSelect([savedFile1]); - env.component.save(); + await env.component.save(); tick(); verify(mockTrainingDataService.deleteTrainingDataAsync(savedFile2)).once(); @@ -352,9 +344,8 @@ describe('ConfigureSourcesComponent', () => { verify(mockTrainingDataService.createTrainingDataAsync(anything())).never(); })); - it('deletes added files on discard from confirmLeave', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('deletes added files on discard from confirmLeave', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); env.clickLanguageCodesConfirmationCheckbox(); when(mockedDialogService.confirm(anything(), anything(), anything())).thenResolve(true); @@ -396,9 +387,8 @@ describe('ConfigureSourcesComponent', () => { verify(mockTrainingDataService.deleteTrainingDataAsync(anything())).never(); })); - it('preserves unsaved training file changes when query updates', fakeAsync(() => { - const env = new TestEnvironment(); - tick(); + it('preserves unsaved training file changes when query updates', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.fixture.detectChanges(); const initialFile1 = { dataId: 'file1' } as TrainingData; @@ -611,8 +601,8 @@ describe('ConfigureSourcesComponent', () => { }); }); - it('should disable save and sync button and display offline message when offline', fakeAsync(() => { - const env = new TestEnvironment(); + it('should disable save and sync button and display offline message when offline', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.testOnlineStatusService.setIsOnline(false); env.fixture.detectChanges(); tick(); @@ -624,8 +614,8 @@ describe('ConfigureSourcesComponent', () => { expect(saveButton.attributes.disabled).toBe('true'); })); - it('should enable save & sync button and not display offline message when online', fakeAsync(() => { - const env = new TestEnvironment(); + it('should enable save & sync button and not display offline message when online', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.testOnlineStatusService.setIsOnline(true); env.fixture.detectChanges(); tick(); @@ -640,56 +630,74 @@ describe('ConfigureSourcesComponent', () => { }); class TestEnvironment { - readonly component: ConfigureSourcesComponent; - readonly fixture: ComponentFixture; + private _component: ConfigureSourcesComponent | undefined; + private _fixture: ComponentFixture | undefined; readonly realtimeService: TestRealtimeService; - readonly activatedProjectDoc: WithData; + private _activatedProjectDoc: WithData | undefined; readonly testOnlineStatusService: TestOnlineStatusService = TestBed.inject( OnlineStatusService ) as TestOnlineStatusService; readonly activeTrainingData$: Subject = new Subject(); - private projectsLoaded$: Subject = new Subject(); - constructor( - args: { isOnline?: boolean; projectLoadSuccessful?: boolean } = { isOnline: true, projectLoadSuccessful: true } - ) { + private constructor() { + this.realtimeService = TestBed.inject(TestRealtimeService); + } + + static async create({ + isOnline = true, + projectLoadSuccessful = true + }: { isOnline?: boolean; projectLoadSuccessful?: boolean } = {}): Promise { + const env = new TestEnvironment(); + await env.init({ isOnline, projectLoadSuccessful }); + return env; + } + + private async init({ + isOnline = true, + projectLoadSuccessful = true + }: { isOnline?: boolean; projectLoadSuccessful?: boolean } = {}): Promise { const userSFProjectsAndResourcesCount: number = 6; const userNonSFProjectsCount: number = 3; const userNonSFResourcesCount: number = 3; - this.realtimeService = TestBed.inject(TestRealtimeService); - // Make some projects and resources, already on SF, that the user has access to. These will be available as a // variety of types. - const projects: MultiTypeProjectDescription[] = Array.from( - { length: userSFProjectsAndResourcesCount }, - (_, i) => - ({ - id: `sf-id-${i}`, - data: createTestProject( - { - paratextId: `pt-id-${i}`, - resourceConfig: - i < userSFProjectsAndResourcesCount / 2 - ? undefined - : { - createdTimestamp: new Date(), - manifestChecksum: '1234', - permissionsChecksum: '2345', - revision: 1 - } - }, - i - ) - }) as SFProjectDoc + const projects: MultiTypeProjectDescription[] = ( + await Promise.all( + Array.from( + { length: userSFProjectsAndResourcesCount }, + (_, i) => + ({ + id: `sf-id-${i}`, + data: createTestProject( + { + paratextId: `pt-id-${i}`, + resourceConfig: + i < userSFProjectsAndResourcesCount / 2 + ? undefined + : { + createdTimestamp: new Date(), + manifestChecksum: '1234', + permissionsChecksum: '2345', + revision: 1 + } + }, + i + ) + }) as SFProjectDoc + ).map(async o => { + // Run it into and out of realtime service so it has fields like `remoteChanges$`. + this.realtimeService.addSnapshot(SFProjectDoc.COLLECTION, o); + return await this.realtimeService.get( + SFProjectDoc.COLLECTION, + o.id, + new DocSubscription('spec') + ); + }) + ) ) - .map(o => { - // Run it into and out of realtime service so it has fields like `remoteChanges$`. - this.realtimeService.addSnapshot(SFProjectDoc.COLLECTION, o); - return this.realtimeService.get(SFProjectDoc.COLLECTION, o.id); - }) .filter(hasData) .map(o => ({ sfProjectDoc: o, @@ -768,7 +776,7 @@ class TestEnvironment { .map(o => o.translateSource) .filter(notNull); - this.activatedProjectDoc = usersProjectsAndResourcesOnSF[0]; + this._activatedProjectDoc = usersProjectsAndResourcesOnSF[0]; // Now that various projects and resources are defined with known SF project ids, and as various needed types, write // the sf project 0's translate config values. @@ -790,7 +798,7 @@ class TestEnvironment { when(mockedParatextService.getResources()).thenResolve( projects.filter(o => o.projectType === 'resource').map(o => o.selectableProjectWithLanguageCode) ); - if (args.projectLoadSuccessful === false) { + if (projectLoadSuccessful === false) { when(mockedParatextService.getProjects()).thenReject(new Error('504 Gateway Timeout')); } when(mockedSFUserProjectsService.projectDocs$).thenReturn(of(usersProjectsAndResourcesOnSF)); @@ -801,23 +809,38 @@ class TestEnvironment { when(mockedActivatedProjectService.projectDoc).thenReturn(this.activatedProjectDoc); when(mockedActivatedProjectService.projectId).thenReturn(this.activatedProjectDoc.id); when(mockedActivatedProjectService.projectDoc).thenReturn(this.activatedProjectDoc); - this.testOnlineStatusService.setIsOnline(!!args.isOnline); + this.testOnlineStatusService.setIsOnline(!!isOnline); when(mockTrainingDataService.getTrainingData(anything(), anything())).thenReturn(this.activeTrainingData$); this.activeTrainingData$.next([]); - this.fixture = TestBed.createComponent(ConfigureSourcesComponent); - this.component = this.fixture.componentInstance; + this._fixture = TestBed.createComponent(ConfigureSourcesComponent); + this._component = this.fixture.componentInstance; this.fixture.detectChanges(); tick(); - if (args.projectLoadSuccessful !== false) { + if (projectLoadSuccessful !== false) { this.loadingFinished(); } tick(); this.fixture.detectChanges(); } + get component(): ConfigureSourcesComponent { + if (this._component == null) throw new Error('Uninitialized'); + return this._component; + } + + get fixture(): ComponentFixture { + if (this._fixture == null) throw new Error('Uninitialized'); + return this._fixture; + } + + get activatedProjectDoc(): WithData { + if (this._activatedProjectDoc == null) throw new Error('Uninitialized'); + return this._activatedProjectDoc; + } + clickLanguageCodesConfirmationCheckbox(): void { const languageCodesConfirmationComponent: DebugElement = this.fixture.debugElement.query( By.css('app-language-codes-confirmation') diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index 381e2457e05..d6e1fa0bf72 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts @@ -1512,7 +1512,7 @@ describe('DraftGenerationStepsComponent', () => { }) } as SFProjectProfileDoc; - when(mockProjectService.getProfile(projectId)).thenResolve(profileDoc); + when(mockProjectService.getProfile(projectId, anything())).thenResolve(profileDoc); const books: BookProgress[] = texts.map(b => ({ bookId: Canon.bookNumberToId(b), verseSegments: 100, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts index 5d8da196326..f1db84590ad 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts @@ -73,7 +73,7 @@ describe('DraftHistoryEntryComponent', () => { when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected(anything())).thenReturn(true); when(mockedPermissionsService.isUserOnProject(anything())).thenResolve(true); const userDoc = { id: 'sf-user-id', data: { displayName: 'User 01' } } as UserProfileDoc; - when(mockedUserService.getProfile(anything())).thenResolve(userDoc); + when(mockedUserService.getProfile(anything(), anything())).thenResolve(userDoc); const activeTrainingData: TrainingData[] = [{ dataId: 'file01', title: 'training-data.txt' } as TrainingData]; when(mockedTrainingDataService.getTrainingData(anything(), anything(), anything())).thenReturn( of(activeTrainingData) @@ -162,7 +162,7 @@ describe('DraftHistoryEntryComponent', () => { fixture.detectChanges(); verify(mockedPermissionsService.isUserOnProject('project02')).twice(); - verify(mockedSFProjectService.getProfile('project02')).never(); + verify(mockedSFProjectService.getProfile('project02', anything())).never(); expect(component.translationSource).toEqual('src \u2022'); expect(component.sourceLanguage).toBe('fr'); expect(component.trainingConfiguration).toEqual([{ target: 'tar', source: 'src', scriptureRange: 'GEN' }]); @@ -203,11 +203,11 @@ describe('DraftHistoryEntryComponent', () => { const targetProjectDoc = { id: 'project01' } as SFProjectProfileDoc; - when(mockedSFProjectService.getProfile('project01')).thenResolve(targetProjectDoc); + when(mockedSFProjectService.getProfile('project01', anything())).thenResolve(targetProjectDoc); const sourceProjectDoc = { id: 'project02' } as SFProjectProfileDoc; - when(mockedSFProjectService.getProfile('project02')).thenResolve(sourceProjectDoc); + when(mockedSFProjectService.getProfile('project02', anything())).thenResolve(sourceProjectDoc); const entry = { engine: { id: 'project01' @@ -251,12 +251,12 @@ describe('DraftHistoryEntryComponent', () => { when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected(anything())).thenReturn(false); when(mockedDraftOptionsService.areFormattingOptionsSupportedForBuild(anything())).thenReturn(true); const userDoc = { id: 'sf-user-id', data: undefined } as UserProfileDoc; - when(mockedUserService.getProfile(anything())).thenResolve(userDoc); + when(mockedUserService.getProfile(anything(), anything())).thenResolve(userDoc); const targetProjectDoc = { id: 'project01', data: createTestProjectProfile({ shortName: 'tar', writingSystem: { tag: 'en' } }) } as SFProjectProfileDoc; - when(mockedSFProjectService.getProfile('project01')).thenResolve(targetProjectDoc); + when(mockedSFProjectService.getProfile('project01', anything())).thenResolve(targetProjectDoc); when(mockedActivatedProjectService.changes$).thenReturn(of(targetProjectDoc)); const entry = { engine: { id: 'project01' }, @@ -538,7 +538,7 @@ describe('DraftHistoryEntryComponent', () => { id: 'sf-user-id', data: createTestUserProfile({ displayName: user }) } as UserProfileDoc; - when(mockedUserService.getProfile('sf-user-id')).thenResolve(userDoc); + when(mockedUserService.getProfile('sf-user-id', anything())).thenResolve(userDoc); const targetProjectDoc = { id: 'project01', data: createTestProjectProfile({ @@ -552,13 +552,13 @@ describe('DraftHistoryEntryComponent', () => { } }) } as SFProjectProfileDoc; - when(mockedSFProjectService.getProfile('project01')).thenResolve(targetProjectDoc); + when(mockedSFProjectService.getProfile('project01', anything())).thenResolve(targetProjectDoc); when(mockedActivatedProjectService.changes$).thenReturn(of(targetProjectDoc)); const sourceProjectDoc = { id: 'project02', data: createTestProjectProfile({ shortName: 'src', writingSystem: { tag: 'fr' } }) } as SFProjectProfileDoc; - when(mockedSFProjectService.getProfile('project02')).thenResolve(sourceProjectDoc); + when(mockedSFProjectService.getProfile('project02', anything())).thenResolve(sourceProjectDoc); const entry = { engine: { id: 'project01' diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts index 1ef11e8929f..f25c477d037 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts @@ -27,6 +27,7 @@ import { TranslocoMarkupModule } from 'ngx-transloco-markup'; import { Subject, takeUntil } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserService } from 'xforge-common/user.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; @@ -101,6 +102,11 @@ interface SourceInfo { templateUrl: './draft-history-entry.component.html', styleUrl: './draft-history-entry.component.scss' }) +/** + * Displays a single draft generation build entry within the draft history list. Shows information about + * the build and provides actions like downloading or importing the draft. + * Used as a repeated item inside DraftHistoryListComponent on the draft generation page. + */ export class DraftHistoryEntryComponent { private _entry?: BuildDto; private entryChanged: Subject = new Subject(); @@ -117,11 +123,16 @@ export class DraftHistoryEntryComponent { // Get the user who requested the build this._buildRequestedByUserName = undefined; if (this._entry?.additionalInfo?.requestedByUserId != null) { - void this.userService.getProfile(this._entry.additionalInfo.requestedByUserId).then(user => { - if (user.data != null) { - this._buildRequestedByUserName = user.data.displayName; - } - }); + void this.userService + .getProfile( + this._entry.additionalInfo.requestedByUserId, + new DocSubscription('DraftHistoryEntry', this.destroyRef) + ) + .then(user => { + if (user.data != null) { + this._buildRequestedByUserName = user.data.displayName; + } + }); } // Clear the data for the table @@ -388,14 +399,20 @@ export class DraftHistoryEntryComponent { let target: SFProjectProfileDoc | undefined = undefined; let draftSources: DraftSourcesAsTranslateSourceArrays | undefined; if (targetId != null) { - target = await this.projectService.getProfile(targetId); + target = await this.projectService.getProfile( + targetId, + new DocSubscription('DraftHistoryEntry', this.destroyRef) + ); if (target?.data != null) { draftSources = projectToDraftSources(target.data); } } let source: SourceInfo | undefined; if (await this.permissionsService.isUserOnProject(sourceId)) { - const translationSource: SFProjectProfileDoc | undefined = await this.projectService.getProfile(sourceId); + const translationSource: SFProjectProfileDoc | undefined = await this.projectService.getProfile( + sourceId, + new DocSubscription('DraftHistoryEntry', this.destroyRef) + ); source = { projectRef: sourceId, shortName: translationSource?.data?.shortName, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.spec.ts index a01c84e85f6..d33e3fdab1d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.spec.ts @@ -10,6 +10,7 @@ import { of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { AuthService } from 'xforge-common/auth.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; @@ -314,20 +315,21 @@ class TestEnvironment { { bookId: 'DEU', verseSegments: 0, blankVerseSegments: 0 } ]) ); - when(mockProjectService.getText(anything())).thenResolve({ + when(mockProjectService.getText(anything(), anything())).thenResolve({ getNonEmptyVerses: (): string[] => ['verse_1_1'] } as TextDoc); when(mockProjectService.onlineCreate(anything())).thenResolve('project02'); - when(mockProjectService.get(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) + when(mockProjectService.subscribe(anything(), anything())).thenCall((id: string, subscriber: DocSubscription) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, subscriber) ); when(mockTextDocService.userHasGeneralEditRight(anything())).thenReturn(true); this.realtimeService.addSnapshot(SFProjectUserConfigDoc.COLLECTION, { id: 'project01:user01', data: { selectedDraftTargetParatextId: 'paratext02' } as SFProjectUserConfig }); - when(mockProjectService.getUserConfig(anything(), anything())).thenCall(() => - this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, 'project01:user01') + when(mockProjectService.getUserConfig(anything(), anything(), anything())).thenCall( + (_id: string, _userId: string, subscriber: DocSubscription) => + this.realtimeService.subscribe(SFProjectUserConfigDoc.COLLECTION, 'project01:user01', subscriber) ); this.fixture.detectChanges(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts index 6a42b31fcc0..cf2c28dd70c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts @@ -25,6 +25,7 @@ import { AuthService } from 'xforge-common/auth.service'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { I18nService } from 'xforge-common/i18n.service'; import { LocationService } from 'xforge-common/location.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -199,7 +200,10 @@ export class DraftImportWizardComponent implements OnInit { } // Reload project data after connection - const projectDoc = await this.projectService.get(this.targetProjectId); + const projectDoc = await this.projectService.subscribe( + this.targetProjectId, + new DocSubscription('DraftImportWizard', this.destroyRef) + ); await this.loadTargetProjectAndValidate(projectDoc); this.isConnecting = false; @@ -227,7 +231,10 @@ export class DraftImportWizardComponent implements OnInit { // updateConnectStatus() will handle the sync finishing and move to the next step after "connecting" this.stepper?.next(); - const projectDoc = await this.projectService.get(this.targetProjectId); + const projectDoc = await this.projectService.subscribe( + this.targetProjectId, + new DocSubscription('DraftImportWizard', this.destroyRef) + ); this.targetProjectDoc$.next(projectDoc); } @@ -364,7 +371,11 @@ export class DraftImportWizardComponent implements OnInit { quietTakeUntilDestroyed(this.destroyRef) ) .subscribe(async projectId => { - this.projectUserConfigDoc = await this.projectService.getUserConfig(projectId!, this.userService.currentUserId); + this.projectUserConfigDoc = await this.projectService.getUserConfig( + projectId!, + this.userService.currentUserId, + new DocSubscription('DraftImportWizard', this.destroyRef) + ); await this.loadProjects(); this.initializeAvailableBooks(); this.sourceProjectId = projectId; @@ -467,7 +478,10 @@ export class DraftImportWizardComponent implements OnInit { // Get the project profile to analyze this.isLoadingProject = true; try { - const projectDoc = await this.projectService.get(this.targetProjectId); + const projectDoc = await this.projectService.subscribe( + this.targetProjectId, + new DocSubscription('DraftImportWizard', this.destroyRef) + ); await this.loadTargetProjectAndValidate(projectDoc); } finally { this.isLoadingProject = false; @@ -509,7 +523,10 @@ export class DraftImportWizardComponent implements OnInit { } if (this.targetProjectDoc$.value?.data == null) { - const profileDoc = await this.projectService.get(this.targetProjectId); + const profileDoc = await this.projectService.subscribe( + this.targetProjectId, + new DocSubscription('DraftImportWizard', this.destroyRef) + ); if (profileDoc?.data == null) { return false; } @@ -606,8 +623,13 @@ export class DraftImportWizardComponent implements OnInit { } private async hasTextInChapter(textDocId: TextDocId): Promise { - const textDoc: TextDoc = await this.projectService.getText(textDocId); - return textDoc.getNonEmptyVerses().length > 0; + const docSubscription = new DocSubscription('DraftImportWizard.hasTextInChapter'); + try { + const textDoc: TextDoc = await this.projectService.getText(textDocId, docSubscription); + return textDoc.getNonEmptyVerses().length > 0; + } finally { + docSubscription.unsubscribe(); + } } async startImport(): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts index 9912b5ba04d..a476e67249b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { BehaviorSubject } from 'rxjs'; -import { mock, when } from 'ts-mockito'; +import { anything, mock, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { configureTestingModule } from 'xforge-common/test-utils'; @@ -186,16 +186,16 @@ describe('DraftSourcesService', () => { data: targetProject } as SFProjectProfileDoc) ); - when(mockProjectService.getProfile('source_project')).thenResolve({ + when(mockProjectService.getProfile('source_project', anything())).thenResolve({ data: sourceProject } as SFProjectProfileDoc); - when(mockProjectService.getProfile('first_drafting_source')).thenResolve({ + when(mockProjectService.getProfile('first_drafting_source', anything())).thenResolve({ data: firstDraftingSourceProject } as SFProjectProfileDoc); - when(mockProjectService.getProfile('first_training_source')).thenResolve({ + when(mockProjectService.getProfile('first_training_source', anything())).thenResolve({ data: firstTrainingSourceProject } as SFProjectProfileDoc); - when(mockProjectService.getProfile('second_training_source')).thenResolve({ + when(mockProjectService.getProfile('second_training_source', anything())).thenResolve({ data: secondTrainingSourceProject } as SFProjectProfileDoc); when(mockUserService.getCurrentUser()).thenResolve({ diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts index b5199bd4ad2..2f06f47a8da 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts @@ -4,6 +4,7 @@ import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/t import { asyncScheduler, combineLatest, defer, from, Observable } from 'rxjs'; import { switchMap, throttleTime } from 'rxjs/operators'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { UserService } from 'xforge-common/user.service'; import { environment } from '../../../environments/environment'; @@ -17,8 +18,9 @@ import { projectToDraftSources } from './draft-utils'; providedIn: 'root' }) export class DraftSourcesService { - private readonly currentUser$: Observable = defer(() => from(this.userService.getCurrentUser())); - /** Duration to throttle large amounts of incoming project changes. 100 is a guess for what may be useful. */ + private readonly currentUser$: Observable = defer(() => + from(this.userService.getCurrentUser()) + ); /** Duration to throttle large amounts of incoming project changes. 100 is a guess for what may be useful. */ private readonly projectChangeThrottlingMs = 100; constructor( @@ -81,7 +83,12 @@ export class DraftSourcesService { let project: SFProjectProfile | undefined; if (source === currentProjectDoc?.data) project = currentProjectDoc.data; else if (currentUser.data?.sites[environment.siteId].projects?.includes(projectId)) { - project = (await this.projectService.getProfile(projectId)).data; + const docSubscription = new DocSubscription('DraftSourcesService.mapDraftSourceToFullSource'); + try { + project = (await this.projectService.getProfile(projectId, docSubscription)).data; + } finally { + docSubscription.unsubscribe(); + } } if (project != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts index 23b916d6329..acb6b253a96 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts @@ -22,6 +22,7 @@ import { anything, deepEqual, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -385,7 +386,7 @@ class TestEnvironment { of(this.getTestBuildDto(args.canDenormalizeQuotes ?? true)) ); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, this.userId) + this.realtimeService.subscribe(UserDoc.COLLECTION, this.userId, new DocSubscription('spec')) ); const bookDraft: Map = new Map(); bookDraft.set('1', [ @@ -479,7 +480,7 @@ class TestEnvironment { id: 'source01', data: createTestProjectProfile({ texts }) } as SFProjectProfileDoc; - when(mockedProjectService.getProfile('source01')).thenResolve(translateSourceProjectDoc); + when(mockedProjectService.getProfile('source01', anything())).thenResolve(translateSourceProjectDoc); const draftSources: DraftSourcesAsArrays = { trainingSources: [ diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts index 5ba92882cfa..dfda9c7350a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts @@ -23,6 +23,7 @@ import { ActivatedProjectService } from 'xforge-common/activated-project.service import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; @@ -297,7 +298,12 @@ export class DraftUsfmFormatComponent extends DataLoadingComponent implements Af .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(async ([source]) => { if (currentUser.data?.sites[environment.siteId].projects.includes(source.draftingSources[0].projectRef)) { - this.translateSource ??= (await this.projectService.getProfile(source.draftingSources[0].projectRef)).data; + this.translateSource ??= ( + await this.projectService.getProfile( + source.draftingSources[0].projectRef, + new DocSubscription('DraftUsfmFormatComponent', this.destroyRef) + ) + ).data; } this.showDraftSourceWarning = this.translateSource == null; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts index 74c9691ae75..704c1d0613b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts @@ -9,6 +9,7 @@ import { TestRealtimeService } from 'xforge-common/test-realtime.service'; import { configureTestingModule } from 'xforge-common/test-utils'; import { TypeRegistry } from 'xforge-common/type-registry'; import { PROJECTS_URL } from 'xforge-common/url-constants'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { TrainingDataDoc } from '../../../core/models/training-data-doc'; import { TrainingDataService } from './training-data.service'; @@ -87,9 +88,10 @@ describe('TrainingDataService', () => { await trainingDataService.createTrainingDataAsync(newTrainingData); tick(); - const trainingDataDoc = realtimeService.get( + const trainingDataDoc = await realtimeService.get( TrainingDataDoc.COLLECTION, - getTrainingDataId('project01', 'data03') + getTrainingDataId('project01', 'data03'), + new DocSubscription('spec') ); expect(trainingDataDoc.data).toEqual(newTrainingData); })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.ts index ade8a8461ce..0628b2aba2c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.ts @@ -3,6 +3,7 @@ import { obj } from 'realtime-server/lib/esm/common/utils/obj-path'; import { getTrainingDataId, TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; import { finalize, from, map, merge, Observable, switchMap } from 'rxjs'; import { CommandService } from 'xforge-common/command.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { QueryParameters } from 'xforge-common/query-parameters'; import { RealtimeService } from 'xforge-common/realtime.service'; @@ -15,12 +16,18 @@ import { TrainingDataDoc } from '../../../core/models/training-data-doc'; export class TrainingDataService { constructor( private readonly realtimeService: RealtimeService, - private readonly commandService: CommandService + private readonly commandService: CommandService, + private readonly destroyRef: DestroyRef ) {} async createTrainingDataAsync(trainingData: TrainingData): Promise { const docId: string = getTrainingDataId(trainingData.projectRef, trainingData.dataId); - await this.realtimeService.create(TrainingDataDoc.COLLECTION, docId, trainingData); + await this.realtimeService.create( + TrainingDataDoc.COLLECTION, + docId, + trainingData, + new DocSubscription('TrainingDataService', this.destroyRef) + ); } async deleteTrainingDataAsync(trainingData: TrainingData): Promise { @@ -62,6 +69,11 @@ export class TrainingDataService { if (options?.includeDeleted !== true) { queryParams[obj().pathStr(t => t.deleted)] = false; } - return this.realtimeService.subscribeQuery(TrainingDataDoc.COLLECTION, queryParams, destroyRef); + return this.realtimeService.subscribeQuery( + TrainingDataDoc.COLLECTION, + 'query_training_data', + queryParams, + destroyRef + ); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts index 3dc877e0da6..f4e7c11efcc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts @@ -39,6 +39,7 @@ import { DialogService } from 'xforge-common/dialog.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -417,7 +418,9 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { } private getTargetOps(): Observable { - return from(this.projectService.getText(this.textDocId!)).pipe( + return from( + this.projectService.getText(this.textDocId!, new DocSubscription('EditorDraftComponent', this.destroyRef)) + ).pipe( switchMap(textDoc => { if (textDoc.isLoaded) return textDoc.changes$.pipe( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts index 1c8b81aa3ac..1ffa0108676 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts @@ -82,7 +82,7 @@ describe('EditorHistoryComponent', () => { it('should load the project document when the project id is set', fakeAsync(() => { const testProjectId = 'test_project_id'; const projectDoc = { data: createTestProjectProfile(), id: testProjectId } as SFProjectProfileDoc; - when(mockSFProjectService.getProfile(testProjectId)).thenResolve(projectDoc); + when(mockSFProjectService.getProfile(testProjectId, anything())).thenResolve(projectDoc); expect(component.projectDoc).toBeUndefined(); component.projectId = testProjectId; @@ -107,7 +107,7 @@ describe('EditorHistoryComponent', () => { editor: mockEditor }); - when(mockSFProjectService.getText(anything())).thenResolve(textDoc); + when(mockSFProjectService.getText(anything(), anything())).thenResolve(textDoc); component.historyChooser = mockHistoryChooserComponent; component.snapshotText = mockTextComponent; @@ -138,7 +138,7 @@ describe('EditorHistoryComponent', () => { editor: mockEditor }); - when(mockSFProjectService.getText(anything())).thenResolve(textDoc); + when(mockSFProjectService.getText(anything(), anything())).thenResolve(textDoc); component.historyChooser = mockHistoryChooserComponent; component.snapshotText = mockTextComponent; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts index a833dcb216e..d73844d70b8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts @@ -16,6 +16,7 @@ import { Delta } from 'quill'; import { combineLatest, startWith, tap } from 'rxjs'; import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; @@ -42,7 +43,7 @@ export class EditorHistoryComponent implements OnChanges, OnInit, AfterViewInit public set projectId(value: string | undefined) { this._projectId = value; if (value != null) { - this.projectService.getProfile(value).then( + this.projectService.getProfile(value, new DocSubscription('EditorHistoryComponent', this.destroyRef)).then( p => (this.projectDoc = p), () => (this.projectDoc = undefined) ); @@ -115,7 +116,10 @@ export class EditorHistoryComponent implements OnChanges, OnInit, AfterViewInit // Show the diff, if requested if (showDiff && this.diffText?.id != null) { - const textDoc: TextDoc = await this.projectService.getText(this.diffText.id); + const textDoc: TextDoc = await this.projectService.getText( + this.diffText.id, + new DocSubscription('EditorHistoryComponent', this.destroyRef) + ); const targetContents: Delta = new Delta(textDoc.data?.ops); const diff = this.editorHistoryService.processDiff(snapshotContents, targetContents); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts index 574ef06c44f..fc8b22c0990 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts @@ -280,8 +280,8 @@ describe('HistoryChooserComponent', () => { v: 1, isValid: this.isSnapshotValid }); - when(mockedProjectService.getProfile('project01')).thenCall(() => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 'project01') + when(mockedProjectService.getProfile('project01', anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, subscriber) ); when(mockedTextDocService.canRestore(anything(), 40, 1)).thenReturn(true); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts index 0844f362121..ea0e619faa5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts @@ -36,6 +36,7 @@ import { isNetworkError } from 'xforge-common/command.service'; import { DialogService } from 'xforge-common/dialog.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { Snapshot } from 'xforge-common/models/snapshot'; import { TextSnapshot } from 'xforge-common/models/textsnapshot'; import { NoticeService } from 'xforge-common/notice.service'; @@ -127,7 +128,7 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { private projectDoc: SFProjectProfileDoc | undefined; constructor( - destroyRef: DestroyRef, + private readonly destroyRef: DestroyRef, private readonly dialogService: DialogService, private readonly onlineStatusService: OnlineStatusService, private readonly noticeService: NoticeService, @@ -196,7 +197,10 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { if (this.projectId != null && this.bookNum != null && this.chapter != null) { this.loading$.next(true); try { - this.projectDoc = await this.projectService.getProfile(this.projectId); + this.projectDoc = await this.projectService.getProfile( + this.projectId, + new DocSubscription('HistoryChooserComponent', this.destroyRef) + ); if (this.historyRevisions.length === 0) { this.historyRevisions = (await this.paratextService.getRevisions(this.projectId, this.bookId, this.chapter)) ?? []; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts index 169dda056cb..5aadc145cf2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts @@ -47,17 +47,17 @@ describe('EditorResourceComponent', () => { component['initProjectDetails'](); component.resourceText.editorCreated.next(); - verify(mockSFProjectService.getProfile(anything())).never(); + verify(mockSFProjectService.getProfile(anything(), anything())).never(); component.projectId = 'test'; component.bookNum = undefined; component.inputChanged$.next(); - verify(mockSFProjectService.getProfile(anything())).never(); + verify(mockSFProjectService.getProfile(anything(), anything())).never(); component.bookNum = 1; component.chapter = undefined; component.inputChanged$.next(); - verify(mockSFProjectService.getProfile(anything())).never(); + verify(mockSFProjectService.getProfile(anything(), anything())).never(); }); it('should init when projectId, bookNum, and chapter are defined', fakeAsync(() => { @@ -65,11 +65,11 @@ describe('EditorResourceComponent', () => { component.projectId = projectId; component.bookNum = 1; component.chapter = 1; - when(mockSFProjectService.getProfile(projectId)).thenResolve(projectDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); - verify(mockSFProjectService.getProfile(projectId)).once(); + verify(mockSFProjectService.getProfile(projectId, anything())).once(); verify(mockFontService.getFontFamilyFromProject(projectDoc)).once(); })); @@ -82,7 +82,7 @@ describe('EditorResourceComponent', () => { id: projectId, data: createTestProjectProfile({ isRightToLeft: true }) } as SFProjectProfileDoc; - when(mockSFProjectService.getProfile(projectId)).thenResolve(rtlProjectDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(rtlProjectDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); @@ -98,7 +98,7 @@ describe('EditorResourceComponent', () => { id: projectId, data: createTestProjectProfile({ copyrightBanner: 'Test copyright', copyrightNotice: 'Test notice' }) } as SFProjectProfileDoc; - when(mockSFProjectService.getProfile(projectId)).thenResolve(projectNoticeDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectNoticeDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); @@ -112,7 +112,7 @@ describe('EditorResourceComponent', () => { component.projectId = projectId; component.bookNum = 1; component.chapter = 1; - when(mockSFProjectService.getProfile(projectId)).thenResolve(projectDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.ts index e6d7aefb80f..7a1224c4d23 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.ts @@ -1,6 +1,7 @@ import { AfterViewInit, Component, DestroyRef, Input, OnChanges, ViewChild } from '@angular/core'; import { combineLatest, EMPTY, startWith, Subject, switchMap } from 'rxjs'; import { FontService } from 'xforge-common/font.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { SFProjectService } from '../../../core/sf-project.service'; @@ -55,7 +56,10 @@ export class EditorResourceComponent implements AfterViewInit, OnChanges { return EMPTY; } - return this.projectService.getProfile(this.projectId); + return this.projectService.getProfile( + this.projectId, + new DocSubscription('EditorResourceComponent', this.destroyRef) + ); }) ) .subscribe((projectDoc: SFProjectProfileDoc) => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts index 14a13e5f5b1..cb04e3efdba 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts @@ -64,12 +64,13 @@ import { fromVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/vers import * as RichText from 'rich-text'; import { DeltaOperation, StringMap } from 'rich-text'; import { BehaviorSubject, defer, firstValueFrom, Observable, of, Subject, take } from 'rxjs'; -import { anything, capture, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { anyString, anything, capture, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { AuthService } from 'xforge-common/auth.service'; import { CONSOLE } from 'xforge-common/browser-globals'; import { BugsnagService } from 'xforge-common/bugsnag.service'; import { GenericDialogComponent, GenericDialogOptions } from 'xforge-common/generic-dialog/generic-dialog.component'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -246,7 +247,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('response to remote text deletion', fakeAsync(() => { + it('response to remote text deletion', fakeAsync(async () => { const env = new TestEnvironment(); flush(); env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); @@ -256,14 +257,14 @@ describe('EditorComponent', () => { env.setupDialogRef(); const textDocId = new TextDocId('project02', 40, 1, 'target'); - env.deleteText(textDocId.toString()); + await env.deleteText(textDocId.toString()); expect(dialogMessage).toHaveBeenCalledTimes(1); tick(); expect(env.location.path()).toEqual('/projects/project02/translate'); env.dispose(); })); - it('remote user config should not change segment', fakeAsync(() => { + it('remote user config should not change segment', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, @@ -274,7 +275,8 @@ describe('EditorComponent', () => { env.wait(); expect(env.component.target!.segmentRef).toEqual('verse_2_1'); - env.getProjectUserConfigDoc().submitJson0Op(op => op.set(puc => puc.selectedSegment, 'verse_2_2'), false); + const projectUserConfigDoc: SFProjectUserConfigDoc = await env.getProjectUserConfigDoc(); + await projectUserConfigDoc.submitJson0Op(op => op.set(puc => puc.selectedSegment, 'verse_2_2'), false); env.wait(); expect(env.component.target!.segmentRef).toEqual('verse_2_1'); @@ -419,7 +421,7 @@ describe('EditorComponent', () => { const env = new TestEnvironment(); const sourceId = new TextDocId('project02', 40, 1); let resolve: (value: TextDoc | PromiseLike) => void; - when(mockedSFProjectService.getText(deepEqual(sourceId))).thenReturn(new Promise(r => (resolve = r))); + when(mockedSFProjectService.getText(deepEqual(sourceId), anything())).thenReturn(new Promise(r => (resolve = r))); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); env.wait(); expect(env.component.target!.segmentRef).toBe('verse_1_2'); @@ -435,7 +437,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('select non-blank segment', fakeAsync(() => { + it('select non-blank segment', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); env.wait(); @@ -452,14 +454,14 @@ describe('EditorComponent', () => { // The selection gets adjusted to come after the note icon embed. expect(selection!.index).toBe(range!.index + 1); expect(selection!.length).toBe(0); - expect(env.getProjectUserConfigDoc().data!.selectedSegment).toBe('verse_1_3'); + expect((await env.getProjectUserConfigDoc()).data!.selectedSegment).toBe('verse_1_3'); verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); expect(env.component.showSuggestions).toBe(false); env.dispose(); })); - it('select blank segment', fakeAsync(() => { + it('select blank segment', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); env.wait(); @@ -473,7 +475,7 @@ describe('EditorComponent', () => { const selection = env.targetEditor.getSelection(); expect(selection!.index).toBe(33); expect(selection!.length).toBe(0); - expect(env.getProjectUserConfigDoc().data!.selectedSegment).toBe('verse_1_2'); + expect((await env.getProjectUserConfigDoc()).data!.selectedSegment).toBe('verse_1_2'); verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); expect(env.component.showSuggestions).toBe(true); expect(env.component.suggestions[0].words).toEqual(['target']); @@ -1012,7 +1014,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('selected segment checksum unset on server', fakeAsync(() => { + it('selected segment checksum unset on server', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, @@ -1025,7 +1027,9 @@ describe('EditorComponent', () => { expect(env.component.target!.segmentRef).toBe('verse_1_1'); expect(env.component.target!.segment!.initialChecksum).toBe(0); - env.getProjectUserConfigDoc().submitJson0Op(op => op.unset(puc => puc.selectedSegmentChecksum!), false); + await ( + await env.getProjectUserConfigDoc() + ).submitJson0Op(op => op.unset(puc => puc.selectedSegmentChecksum!), false); env.wait(); expect(env.component.target!.segmentRef).toBe('verse_1_1'); expect(env.component.target!.segment!.initialChecksum).not.toBe(0); @@ -1204,7 +1208,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('user can edit a chapter with permission', fakeAsync(() => { + it('user can edit a chapter with permission', fakeAsync(async () => { const env = new TestEnvironment(); env.setCurrentUser('user03'); env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); @@ -1222,7 +1226,7 @@ describe('EditorComponent', () => { const sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); expect(sourceText).toEqual('This book is empty. Add chapters in Paratext.'); - env.setDataInSync('project01', false); + await env.setDataInSync('project01', false); expect(env.component.canEdit).toBe(false); expect(env.outOfSyncWarning).not.toBeNull(); env.dispose(); @@ -1258,7 +1262,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('user cannot edit a text if their permissions change', fakeAsync(() => { + it('user cannot edit a text if their permissions change', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProject(); env.setProjectUserConfig(); @@ -1266,7 +1270,7 @@ describe('EditorComponent', () => { const userId: string = 'user01'; const projectId: string = 'project01'; - let projectDoc = env.getProjectDoc(projectId); + let projectDoc = await env.getProjectDoc(projectId); expect(projectDoc.data?.userRoles[userId]).toBe(SFProjectRole.ParatextTranslator); expect(env.bookName).toEqual('Matthew'); expect(env.component.canEdit).toBe(true); @@ -1278,13 +1282,13 @@ describe('EditorComponent', () => { verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); // Change user role on the project and run a sync to force remote updates - env.changeUserRole(projectId, userId, SFProjectRole.Viewer); - env.setDataInSync(projectId, true, false); - env.setDataInSync(projectId, false, false); + await env.changeUserRole(projectId, userId, SFProjectRole.Viewer); + await env.setDataInSync(projectId, true, false); + await env.setDataInSync(projectId, false, false); env.wait(); resetCalls(env.mockedRemoteTranslationEngine); - projectDoc = env.getProjectDoc(projectId); + projectDoc = await env.getProjectDoc(projectId); expect(projectDoc.data?.userRoles[userId]).toBe(SFProjectRole.Viewer); expect(env.bookName).toEqual('Matthew'); expect(env.component.canEdit).toBe(false); @@ -1298,7 +1302,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('uses default font size', fakeAsync(() => { + it('uses default font size', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProject({ defaultFontSize: 18 }); env.setProjectUserConfig(); @@ -1308,16 +1312,16 @@ describe('EditorComponent', () => { expect(env.targetTextEditor.style.fontSize).toEqual(18 / ptToRem + 'rem'); expect(env.sourceTextEditor.style.fontSize).toEqual(18 / ptToRem + 'rem'); - env.updateFontSize('project01', 24); + await env.updateFontSize('project01', 24); expect(env.component.fontSize).toEqual(24 / ptToRem + 'rem'); expect(env.targetTextEditor.style.fontSize).toEqual(24 / ptToRem + 'rem'); - env.updateFontSize('project02', 24); + await env.updateFontSize('project02', 24); expect(env.sourceTextEditor.style.fontSize).toEqual(24 / ptToRem + 'rem'); env.dispose(); })); it('user has no resource access', fakeAsync(() => { - when(mockedSFProjectService.getProfile('resource01')).thenResolve({ + when(mockedSFProjectService.getProfile('resource01', anything())).thenResolve({ id: 'resource01', data: createTestProjectProfile() } as SFProjectProfileDoc); @@ -1341,7 +1345,7 @@ describe('EditorComponent', () => { env.setProjectUserConfig(); env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); env.wait(); - verify(mockedSFProjectService.get('resource01')).never(); + verify(mockedSFProjectService.subscribe('resource01', anything())).never(); expect(env.bookName).toEqual('Acts'); expect(env.component.chapter).toBe(1); expect(env.component.sourceLabel).toEqual('SRC'); @@ -1547,7 +1551,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('ensure inserting in a blank segment only produces required delta ops', fakeAsync(() => { + it('ensure inserting in a blank segment only produces required delta ops', fakeAsync(async () => { const env = new TestEnvironment(); env.wait(); @@ -1587,7 +1591,7 @@ describe('EditorComponent', () => { { retain: 1 } ]; expect(textChangeOps).toEqual(expectedOps); - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); const attributes: StringMap = textDoc.data!.ops![5].attributes!; expect(Object.keys(attributes)).toEqual(['segment']); env.dispose(); @@ -1669,7 +1673,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('handles text doc updates with note embed offset', fakeAsync(() => { + it('handles text doc updates with note embed offset', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); env.wait(); @@ -1688,7 +1692,7 @@ describe('EditorComponent', () => { segment: 'verse_1_2', 'highlight-segment': true }); - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); const textOps = textDoc.data!.ops!; expect(textOps[2].insert!['verse']['number']).toBe('1'); expect(textOps[3].insert).toBe('target: chapter 1, verse 1.'); @@ -1727,22 +1731,22 @@ describe('EditorComponent', () => { env.dispose(); })); - it('uses note thread text anchor as anchor', fakeAsync(() => { + it('uses note thread text anchor as anchor', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - let doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + let doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); const noteStart1 = env.component.target!.getSegmentRange('verse_1_1')!.index + doc.data!.position.start; - doc = env.getNoteThreadDoc('project01', 'dataid02'); + doc = await env.getNoteThreadDoc('project01', 'dataid02'); const noteStart2 = env.component.target!.getSegmentRange('verse_1_3')!.index + doc.data!.position.start; - doc = env.getNoteThreadDoc('project01', 'dataid03'); + doc = await env.getNoteThreadDoc('project01', 'dataid03'); // Add 1 for the one previous embed in the segment const noteStart3 = env.component.target!.getSegmentRange('verse_1_3')!.index + doc.data!.position.start + 1; - doc = env.getNoteThreadDoc('project01', 'dataid04'); + doc = await env.getNoteThreadDoc('project01', 'dataid04'); // Add 2 for the two previous embeds const noteStart4 = env.component.target!.getSegmentRange('verse_1_3')!.index + doc.data!.position.start + 2; - doc = env.getNoteThreadDoc('project01', 'dataid05'); + doc = await env.getNoteThreadDoc('project01', 'dataid05'); const noteStart5 = env.component.target!.getSegmentRange('verse_1_4')!.index + doc.data!.position.start; // positions are 11, 34, 55, 56, 94 const expected = [noteStart1, noteStart2, noteStart3, noteStart4, noteStart5]; @@ -1750,7 +1754,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('note position correctly accounts for footnote symbols', fakeAsync(() => { + it('note position correctly accounts for footnote symbols', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -1761,7 +1765,7 @@ describe('EditorComponent', () => { expect(contents.ops![1].insert).toEqual({ note: { caller: '*' } }); const note2Position = env.getNoteThreadEditorPosition('dataid02'); expect(range.index).toEqual(note2Position); - const noteThreadDoc3 = env.getNoteThreadDoc('project01', 'dataid03'); + const noteThreadDoc3 = await env.getNoteThreadDoc('project01', 'dataid03'); const noteThread3StartPosition = 20; expect(noteThreadDoc3.data!.position).toEqual({ start: noteThread3StartPosition, length: 7 }); const note3Position = env.getNoteThreadEditorPosition('dataid03'); @@ -1784,19 +1788,19 @@ describe('EditorComponent', () => { env.dispose(); })); - it('shows reattached note in updated location', fakeAsync(() => { + it('shows reattached note in updated location', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); // active position of thread04 when reattached to verse 4 const position: TextAnchor = { start: 19, length: 5 }; // reattach thread04 from MAT 1:3 to MAT 1:4 - env.reattachNote('project01', 'dataid04', 'MAT 1:4', position); + await env.reattachNote('project01', 'dataid04', 'MAT 1:4', position); // SUT env.wait(); const range: Range = env.component.target!.getSegmentRange('verse_1_4')!; const note4Position: number = env.getNoteThreadEditorPosition('dataid04'); - const note4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04')!; + const note4Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid04')!; const note4Anchor: TextAnchor = note4Doc.data!.position; expect(note4Anchor).toEqual(position); expect(note4Position).toEqual(range.index + position.start); @@ -1805,27 +1809,27 @@ describe('EditorComponent', () => { env.dispose(); })); - it('shows an invalid reattached note in original location', fakeAsync(() => { + it('shows an invalid reattached note in original location', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); // invalid reattachment string - env.reattachNote('project01', 'dataid04', 'MAT 1:4 invalid note error', undefined, true); + await env.reattachNote('project01', 'dataid04', 'MAT 1:4 invalid note error', undefined, true); // SUT env.wait(); const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; const note4Position: number = env.getNoteThreadEditorPosition('dataid04'); - const note4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04')!; + const note4Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid04')!; expect(note4Position).toEqual(range.index + 1); // The note thread is on verse 3 expect(note4Doc.data!.verseRef.verseNum).toEqual(3); env.dispose(); })); - it('does not display conflict notes', fakeAsync(() => { + it('does not display conflict notes', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); - env.convertToConflictNote('project01', 'dataid02'); + await env.convertToConflictNote('project01', 'dataid02'); env.wait(); expect(env.getNoteThreadIconElement('verse_1_3', 'dataid02')).toBeNull(); @@ -1845,7 +1849,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('highlights note icons when new content is unread', fakeAsync(() => { + it('highlights note icons when new content is unread', fakeAsync(async () => { const env = new TestEnvironment(); env.setCurrentUser('user02'); env.setProjectUserConfig({ noteRefsRead: ['thread01_note0', 'thread02_note0'] }); @@ -1857,14 +1861,14 @@ describe('EditorComponent', () => { expect(env.isNoteIconHighlighted('dataid04')).toBe(true); expect(env.isNoteIconHighlighted('dataid05')).toBe(true); - let puc: SFProjectUserConfigDoc = env.getProjectUserConfigDoc('user01'); + let puc: SFProjectUserConfigDoc = await env.getProjectUserConfigDoc('user01'); expect(puc.data!.noteRefsRead).not.toContain('thread01_note1'); expect(puc.data!.noteRefsRead).not.toContain('thread01_note2'); let iconElement: HTMLElement = env.getNoteThreadIconElement('verse_1_1', 'dataid01')!; iconElement.click(); env.wait(); - puc = env.getProjectUserConfigDoc('user02'); + puc = await env.getProjectUserConfigDoc('user02'); expect(puc.data!.noteRefsRead).toContain('thread01_note1'); expect(puc.data!.noteRefsRead).toContain('thread01_note2'); expect(env.isNoteIconHighlighted('dataid01')).toBe(false); @@ -1873,19 +1877,19 @@ describe('EditorComponent', () => { iconElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; iconElement.click(); env.wait(); - puc = env.getProjectUserConfigDoc('user02'); + puc = await env.getProjectUserConfigDoc('user02'); expect(puc.data!.noteRefsRead).toContain('thread02_note0'); expect(puc.data!.noteRefsRead.filter(ref => ref === 'thread02_note0').length).toEqual(1); expect(env.isNoteIconHighlighted('dataid02')).toBe(false); env.dispose(); })); - it('should update note position when inserting text', fakeAsync(() => { + it('should update note position when inserting text', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); env.wait(); - let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + let noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); // edit before start position @@ -1908,7 +1912,7 @@ describe('EditorComponent', () => { expect(noteThreadDoc.data!.position).toEqual({ start: length * 2 + 8, length: 9 + length }); // edit immediately after verse note - noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid02'); + noteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid02'); notePosition = env.getNoteThreadEditorPosition('dataid02'); expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); env.targetEditor.setSelection(notePosition, 0, 'user'); @@ -1919,12 +1923,12 @@ describe('EditorComponent', () => { env.dispose(); })); - it('should update note position when deleting text', fakeAsync(() => { + it('should update note position when deleting text', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); // delete text before note @@ -1951,14 +1955,14 @@ describe('EditorComponent', () => { env.dispose(); })); - it('does not try to update positions with an unchanged value', fakeAsync(() => { + it('does not try to update positions with an unchanged value', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); env.wait(); const priorThreadId = 'dataid02'; - const priorThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', priorThreadId); - const laterThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); + const priorThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', priorThreadId); + const laterThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid04'); const origPriorThreadDocAnchorStart: number = priorThreadDoc.data!.position.start; const origPriorThreadDocAnchorLength: number = priorThreadDoc.data!.position.length; const origLaterThreadDocAnchorStart: number = laterThreadDoc.data!.position.start; @@ -1994,7 +1998,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('re-embeds a note icon when a user deletes it', fakeAsync(() => { + it('re-embeds a note icon when a user deletes it', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -2004,12 +2008,12 @@ describe('EditorComponent', () => { env.targetEditor.setSelection(11, 1, 'user'); env.deleteCharacters(); expect(Array.from(env.component.target!.embeddedElements.values())).toEqual([11, 34, 55, 56, 94]); - const textDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); expect(textDoc.data!.ops![3].insert).toBe('target: chapter 1, verse 1.'); // replace icon and characters with new text env.targetEditor.setSelection(9, 5, 'user'); - const noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); env.typeCharacters('t'); // 4 characters deleted and 1 character inserted @@ -2054,7 +2058,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('handles deleting parts of two notes text anchors', fakeAsync(() => { + it('handles deleting parts of two notes text anchors', fakeAsync(async () => { const env = new TestEnvironment(); env.addParatextNoteThread(6, 'MAT 1:1', 'verse', { start: 19, length: 5 }, ['user01']); env.setProjectUserConfig(); @@ -2064,22 +2068,22 @@ describe('EditorComponent', () => { env.targetEditor.setSelection(19); // Emulate mouse click into segment env.targetEditor.setSelection(19, 7, 'user'); env.deleteCharacters(); - const note1 = env.getNoteThreadDoc('project01', 'dataid01'); + const note1 = await env.getNoteThreadDoc('project01', 'dataid01'); expect(note1.data!.position).toEqual({ start: 8, length: 7 }); - const note2 = env.getNoteThreadDoc('project01', 'dataid06'); + const note2 = await env.getNoteThreadDoc('project01', 'dataid06'); expect(note2.data!.position).toEqual({ start: 15, length: 3 }); - const textDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); expect(textDoc.data!.ops![3].insert).toEqual('target: chapterrse 1.'); env.dispose(); })); - it('updates notes anchors in subsequent verse segments', fakeAsync(() => { + it('updates notes anchors in subsequent verse segments', fakeAsync(async () => { const env = new TestEnvironment(); env.addParatextNoteThread(6, 'MAT 1:4', 'chapter 1', { start: 8, length: 9 }, ['user01']); env.setProjectUserConfig(); env.wait(); - const noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid05'); + const noteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid05'); expect(noteThreadDoc.data!.position).toEqual({ start: 28, length: 9 }); env.targetEditor.setSelection(86, 0, 'user'); const text = ' new text '; @@ -2089,12 +2093,12 @@ describe('EditorComponent', () => { env.dispose(); })); - it('should update note position if deleting across position end boundary', fakeAsync(() => { + it('should update note position if deleting across position end boundary', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); // delete text that spans across the end boundary const notePosition = env.getNoteThreadEditorPosition('dataid01'); @@ -2112,14 +2116,14 @@ describe('EditorComponent', () => { env.dispose(); })); - it('handles insert at the last character position', fakeAsync(() => { + it('handles insert at the last character position', fakeAsync(async () => { const env = new TestEnvironment(); env.addParatextNoteThread(6, 'MAT 1:1', '1', { start: 16, length: 1 }, ['user01']); env.addParatextNoteThread(7, 'MAT 1:3', '.', { start: 27, length: 1 }, ['user01']); env.setProjectUserConfig(); env.wait(); - const thread1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const thread1Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); const thread1Position = env.getNoteThreadEditorPosition('dataid01'); expect(thread1Doc.data!.position).toEqual({ start: 8, length: 9 }); @@ -2139,24 +2143,24 @@ describe('EditorComponent', () => { expect(thread1Doc.data!.position).toEqual({ start: 8, length: 10 }); // insert in an adjacent text anchor should not be included in the previous note - const noteThread3Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); + const noteThread3Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid03'); expect(noteThread3Doc.data!.position).toEqual({ start: 20, length: 7 }); const index = env.getNoteThreadEditorPosition('dataid07'); env.targetEditor.setSelection(index + 1, 0, 'user'); env.typeCharacters('c'); expect(noteThread3Doc.data!.position).toEqual({ start: 20, length: 7 }); - const noteThread7Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', `dataid07`); + const noteThread7Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', `dataid07`); expect(noteThread7Doc.data!.position).toEqual({ start: 27, length: 1 + 'c'.length }); env.dispose(); })); - it('should default a note to the beginning if all text is deleted', fakeAsync(() => { + it('should default a note to the beginning if all text is deleted', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + let noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); // delete the entire text anchor @@ -2168,7 +2172,7 @@ describe('EditorComponent', () => { expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); // delete text that includes the entire text anchor - noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); + noteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid03'); expect(noteThreadDoc.data!.position).toEqual({ start: 20, length: 7 }); notePosition = env.getNoteThreadEditorPosition('dataid03'); length = 8; @@ -2179,18 +2183,18 @@ describe('EditorComponent', () => { env.dispose(); })); - it('should update paratext notes position after editing verse with multiple notes', fakeAsync(() => { + it('should update paratext notes position after editing verse with multiple notes', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); env.wait(); - const thread3Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); + const thread3Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid03'); const thread3AnchorLength = 7; const thread4AnchorLength = 5; expect(thread3Doc.data!.position).toEqual({ start: 20, length: thread3AnchorLength }); - const otherNoteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); + const otherNoteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid04'); expect(otherNoteThreadDoc.data!.position).toEqual({ start: 20, length: thread4AnchorLength }); - const verseNoteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid02'); + const verseNoteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid02'); expect(verseNoteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); // edit before paratext note let thread3Position = env.getNoteThreadEditorPosition('dataid03'); @@ -2257,12 +2261,12 @@ describe('EditorComponent', () => { env.dispose(); })); - it('update note thread anchors when multiple edits within a verse', fakeAsync(() => { + it('update note thread anchors when multiple edits within a verse', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); const origNoteAnchor: TextAnchor = { start: 8, length: 9 }; expect(noteThreadDoc.data!.position).toEqual(origNoteAnchor); @@ -2298,7 +2302,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('updates note anchor for non-verse segments', fakeAsync(() => { + it('updates note anchor for non-verse segments', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); const origThread06Pos: TextAnchor = { start: 38, length: 7 }; @@ -2310,7 +2314,7 @@ describe('EditorComponent', () => { const range: Range = env.component.target!.getSegmentRange('s_2')!; const notePosition: number = env.getNoteThreadEditorPosition('dataid06'); expect(range.index + textBeforeNote.length).toEqual(notePosition); - const thread06Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid06'); + const thread06Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid06'); let textAnchor: TextAnchor = thread06Doc.data!.position; expect(textAnchor).toEqual(origThread06Pos); @@ -2337,11 +2341,11 @@ describe('EditorComponent', () => { env.dispose(); })); - it('note belongs to a segment after a blank', fakeAsync(() => { + it('note belongs to a segment after a blank', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid05'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid05'); expect(noteThreadDoc.data!.position).toEqual({ start: 28, length: 9 }); let verse4p1Index = env.component.target!.getSegmentRange('verse_1_4/p_1')!.index; expect(env.getNoteThreadEditorPosition('dataid05')).toEqual(verse4p1Index); @@ -2382,11 +2386,11 @@ describe('EditorComponent', () => { env.dispose(); })); - it('remote edits correctly applied to editor', fakeAsync(() => { + it('remote edits correctly applied to editor', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); // The remote user inserts text after the thread01 note @@ -2401,12 +2405,12 @@ describe('EditorComponent', () => { ); // $ represents a note thread embed // target: $chap|ter 1, verse 1. - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); const insertDelta: Delta = new Delta(); (insertDelta as any).push({ retain: remoteEditTextPos } as DeltaOperation); (insertDelta as any).push({ insert: 'abc' } as DeltaOperation); // Simulate remote changes coming in - textDoc.submit(insertDelta); + await textDoc.submit(insertDelta); // SUT 1 env.wait(); @@ -2424,7 +2428,7 @@ describe('EditorComponent', () => { noteCountBeforePosition = 2; remoteEditPositionAfterNote = 5; remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); - const originalNotePosInVerse: number = env.getNoteThreadDoc('project01', 'dataid03').data!.position.start; + const originalNotePosInVerse: number = (await env.getNoteThreadDoc('project01', 'dataid03')).data!.position.start; // $*targ|->et: cha<-|pter 1, $$verse 3. // ------- 7 characters get replaced locally by the text 'defgh' const selectionLength: number = 'et: cha'.length; @@ -2432,7 +2436,7 @@ describe('EditorComponent', () => { (insertDeleteDelta as any).push({ retain: remoteEditTextPos } as DeltaOperation); (insertDeleteDelta as any).push({ insert: 'defgh' } as DeltaOperation); (insertDeleteDelta as any).push({ delete: selectionLength } as DeltaOperation); - textDoc.submit(insertDeleteDelta); + await textDoc.submit(insertDeleteDelta); // SUT 2 env.wait(); @@ -2447,13 +2451,17 @@ describe('EditorComponent', () => { (deleteDelta as any).push({ retain: remoteEditTextPos } as DeltaOperation); // the remote edit deletes 4, but locally it is expanded to 6 to include the 2 note embeds (deleteDelta as any).push({ delete: 4 } as DeltaOperation); - textDoc.submit(deleteDelta); + await textDoc.submit(deleteDelta); // SUT 3 env.wait(); expect(env.component.target!.getSegmentText('verse_1_3')).toEqual('targdefghpter ' + 'erse 3.'); - expect(env.getNoteThreadDoc('project01', 'dataid03').data!.position.start).toEqual(originalNotePosInVerse); - expect(env.getNoteThreadDoc('project01', 'dataid04').data!.position.start).toEqual(originalNotePosInVerse); + expect((await env.getNoteThreadDoc('project01', 'dataid03')).data!.position.start).toEqual( + originalNotePosInVerse + ); + expect((await env.getNoteThreadDoc('project01', 'dataid04')).data!.position.start).toEqual( + originalNotePosInVerse + ); const verse3Index: number = env.component.target!.getSegmentRange('verse_1_3')!.index; // The note is re-embedded at the position in the note thread doc. // Applying remote changes must not affect text anchors @@ -2464,13 +2472,13 @@ describe('EditorComponent', () => { env.dispose(); })); - it('remote edits do not affect note thread text anchors', fakeAsync(() => { + it('remote edits do not affect note thread text anchors', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); - const noteThread1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - const noteThread4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); + const noteThread1Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); + const noteThread4Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid04'); const originalNoteThread1TextPos: TextAnchor = noteThread1Doc.data!.position; const originalNoteThread4TextPos: TextAnchor = noteThread4Doc.data!.position; expect(originalNoteThread1TextPos).toEqual({ start: 8, length: 9 }); @@ -2491,8 +2499,8 @@ describe('EditorComponent', () => { let insert = 'abc'; let deltaOps: DeltaOperation[] = [{ retain: remoteEditTextPos }, { insert: insert }]; const inSegmentDelta = new Delta(deltaOps); - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); - textDoc.submit(inSegmentDelta); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); + await textDoc.submit(inSegmentDelta); // SUT 1 env.wait(); @@ -2507,7 +2515,7 @@ describe('EditorComponent', () => { insert = 'def'; deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; const outOfSegmentDelta = new Delta(deltaOps); - textDoc.submit(outOfSegmentDelta); + await textDoc.submit(outOfSegmentDelta); // SUT 2 env.wait(); @@ -2523,10 +2531,10 @@ describe('EditorComponent', () => { insert = 'before'; deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; const insertDelta = new Delta(deltaOps); - textDoc.submit(insertDelta); - const note1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + await textDoc.submit(insertDelta); + const note1Doc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); const anchor: TextAnchor = { start: 8 + insert.length, length: 12 }; - note1Doc.submitJson0Op(op => op.set(nt => nt.position, anchor)); + await note1Doc.submitJson0Op(op => op.set(nt => nt.position, anchor)); // SUT 3 env.wait(); @@ -2546,7 +2554,7 @@ describe('EditorComponent', () => { insert = 'ghi'; deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; const insertAfterNoteDelta = new Delta(deltaOps); - textDoc.submit(insertAfterNoteDelta); + await textDoc.submit(insertAfterNoteDelta); // SUT 4 env.wait(); @@ -2581,7 +2589,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('remote edits next to note on verse applied correctly', fakeAsync(() => { + it('remote edits next to note on verse applied correctly', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -2601,8 +2609,8 @@ describe('EditorComponent', () => { ); const insert: string = 'abc'; const deltaOps: DeltaOperation[] = [{ retain: remoteEditTextPos }, { insert: insert }]; - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); - textDoc.submit(new Delta(deltaOps)); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); + await textDoc.submit(new Delta(deltaOps)); env.wait(); expect(env.getNoteThreadEditorPosition('dataid02')).toEqual(notePosition); @@ -2616,7 +2624,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('undo delete-a-note-icon removes the duplicate recreated icon', fakeAsync(() => { + it('undo delete-a-note-icon removes the duplicate recreated icon', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); const noteThread6Anchor: TextAnchor = { start: 19, length: 5 }; @@ -2624,10 +2632,10 @@ describe('EditorComponent', () => { env.wait(); // undo deleting just the note - const noteThread1: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThread1: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); const noteThread1Anchor: TextAnchor = { start: 8, length: 9 }; expect(noteThread1.data!.position).toEqual(noteThread1Anchor); - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); expect(textDoc.data!.ops![3].insert).toEqual('target: chapter 1, verse 1.'); const note1Position: number = env.getNoteThreadEditorPosition('dataid01'); // target: |->$<-|chapter 1, $verse 1. @@ -2675,7 +2683,7 @@ describe('EditorComponent', () => { // undo deleting a second note in verse does not affect first note const note6Position: number = env.getNoteThreadEditorPosition('dataid06'); - const noteThread6: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid06'); + const noteThread6: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid06'); deleteLength = 3; const text = 'abc'; // target: $chapter 1, |->$ve<-|rse 1. @@ -2690,8 +2698,8 @@ describe('EditorComponent', () => { expect(textDoc.data!.ops![3].insert).toEqual('target: chapter 1, verse 1.'); // undo deleting multiple notes - const noteThread3: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); - const noteThread4: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); + const noteThread3: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid03'); + const noteThread4: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid04'); const noteThread3Anchor: TextAnchor = { start: 20, length: 7 }; const noteThread4Anchor: TextAnchor = { start: 20, length: 5 }; expect(noteThread3.data!.position).toEqual(noteThread3Anchor); @@ -2718,7 +2726,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('note icon is changed after remote update', fakeAsync(() => { + it('note icon is changed after remote update', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -2736,11 +2744,11 @@ describe('EditorComponent', () => { ); // Update the last note on the thread as that is the icon displayed - const noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc(projectId, threadDataId); const index: number = noteThread.data!.notes.length - 1; const note: Note = noteThread.data!.notes[index]; note.tagId = 2; - noteThread.submitJson0Op(op => op.insert(nt => nt.notes, index, note), false); + await noteThread.submitJson0Op(op => op.insert(nt => nt.notes, index, note), false); verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; expect(verse1Note.getAttribute('style')).toEqual(`--icon-file: url(/assets/icons/TagIcons/${newIconTag}.png);`); env.dispose(); @@ -2981,7 +2989,7 @@ describe('EditorComponent', () => { env.dispose(); })); - it('can edit a note with xml reserved symbols as note content', fakeAsync(() => { + it('can edit a note with xml reserved symbols as note content', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -2991,9 +2999,9 @@ describe('EditorComponent', () => { const content: string = 'content in the thread'; env.mockNoteDialogRef.close({ noteContent: content }); env.wait(); - verify(mockedSFProjectService.createNoteThread(projectId, anything())).once(); + verify(mockedSFProjectService.createNoteThread(projectId, anything(), anything())).once(); const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); - let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc(projectId, noteThread.dataId); + let noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc(projectId, noteThread.dataId); expect(noteThreadDoc.data!.notes[0].content).toEqual(content); const iconElement: HTMLElement = env.getNoteThreadIconElementAtIndex('verse_1_2', 0)!; @@ -3002,7 +3010,7 @@ describe('EditorComponent', () => { env.mockNoteDialogRef.close({ noteDataId: noteThread.notes[0].dataId, noteContent: editedContent }); env.wait(); verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); - noteThreadDoc = env.getNoteThreadDoc(projectId, noteThread.dataId); + noteThreadDoc = await env.getNoteThreadDoc(projectId, noteThread.dataId); expect(noteThreadDoc.data!.notes[0].content).toEqual(XmlUtils.encodeForXml(editedContent)); env.dispose(); })); @@ -3028,16 +3036,16 @@ describe('EditorComponent', () => { env.dispose(); })); - it('cannot insert a note when editor content unavailable', fakeAsync(() => { + it('cannot insert a note when editor content unavailable', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.onlineStatus = false; - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textDoc: TextDoc = await env.getTextDoc(new TextDocId('project01', 40, 1)); const subject: Subject = new Subject(); const promise = new Promise(resolve => { subject.subscribe(() => resolve(textDoc)); }); - when(mockedSFProjectService.getText(anything())).thenReturn(promise); + when(mockedSFProjectService.getText(anything(), anything())).thenReturn(promise); env.wait(); env.insertNoteFab.nativeElement.click(); env.wait(); @@ -3071,7 +3079,7 @@ describe('EditorComponent', () => { const noteVerseRef: VerseRef = (config as MatDialogConfig).data!.verseRef; expect(noteVerseRef.toString()).toEqual('MAT 1:4'); - verify(mockedSFProjectService.createNoteThread(projectId, anything())).once(); + verify(mockedSFProjectService.createNoteThread(projectId, anything(), anything())).once(); const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); expect(noteThread.verseRef).toEqual(fromVerseRef(noteVerseRef)); expect(noteThread.publishedToSF).toBe(true); @@ -3084,14 +3092,14 @@ describe('EditorComponent', () => { env.dispose(); })); - it('allows adding a note to an existing thread', fakeAsync(() => { + it('allows adding a note to an existing thread', fakeAsync(async () => { const projectId: string = 'project01'; const threadDataId: string = 'dataid04'; const threadId: string = 'thread04'; const segmentRef: string = 'verse_1_3'; const env = new TestEnvironment(); const content: string = 'content in the thread'; - let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + let noteThread: NoteThreadDoc = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(1); env.setProjectUserConfig(); @@ -3103,7 +3111,7 @@ describe('EditorComponent', () => { expect((noteDialogData!.data as NoteDialogData).threadDataId).toEqual(threadDataId); env.mockNoteDialogRef.close({ noteContent: content }); env.wait(); - noteThread = env.getNoteThreadDoc(projectId, threadDataId); + noteThread = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(2); expect(noteThread.data!.notes[1].threadId).toEqual(threadId); expect(noteThread.data!.notes[1].content).toEqual(content); @@ -3111,12 +3119,12 @@ describe('EditorComponent', () => { env.dispose(); })); - it('allows resolving a note', fakeAsync(() => { + it('allows resolving a note', fakeAsync(async () => { const projectId: string = 'project01'; const threadDataId: string = 'dataid01'; const content: string = 'This thread is resolved.'; const env = new TestEnvironment(); - let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + let noteThread: NoteThreadDoc = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(3); env.setProjectUserConfig(); @@ -3126,7 +3134,7 @@ describe('EditorComponent', () => { verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved }); env.wait(); - noteThread = env.getNoteThreadDoc(projectId, threadDataId); + noteThread = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(4); expect(noteThread.data!.notes[3].content).toEqual(content); expect(noteThread.data!.notes[3].status).toEqual(NoteStatus.Resolved); @@ -3142,7 +3150,7 @@ describe('EditorComponent', () => { const threadDataId: string = 'dataid01'; const content: string = 'This thread is resolved.'; const env = new TestEnvironment(); - let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + let noteThread: NoteThreadDoc = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(3); // Mark the note as editable await noteThread.submitJson0Op(op => op.set(nt => nt.notes[0].editable, true)); @@ -3153,7 +3161,7 @@ describe('EditorComponent', () => { verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved, noteDataId: 'thread01_note0' }); env.wait(); - noteThread = env.getNoteThreadDoc(projectId, threadDataId); + noteThread = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(3); expect(noteThread.data!.notes[0].content).toEqual(content); expect(noteThread.data!.notes[0].status).toEqual(NoteStatus.Resolved); @@ -3164,13 +3172,13 @@ describe('EditorComponent', () => { env.dispose(); })); - it('does not allow editing and resolving a non-editable note', fakeAsync(() => { + it('does not allow editing and resolving a non-editable note', fakeAsync(async () => { const projectId: string = 'project01'; const threadDataId: string = 'dataid01'; const content: string = 'This thread is resolved.'; const env = new TestEnvironment(); const dialogMessage = spyOn((env.component as any).dialogService, 'message').and.stub(); - let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + let noteThread: NoteThreadDoc = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(3); env.setProjectUserConfig(); env.wait(); @@ -3179,7 +3187,7 @@ describe('EditorComponent', () => { verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved, noteDataId: 'thread01_note0' }); env.wait(); - noteThread = env.getNoteThreadDoc(projectId, threadDataId); + noteThread = await env.getNoteThreadDoc(projectId, threadDataId); expect(noteThread.data!.notes.length).toEqual(3); expect(dialogMessage).toHaveBeenCalledTimes(1); env.dispose(); @@ -3439,7 +3447,8 @@ describe('EditorComponent', () => { env.dispose(); })); - it('should remove resolved notes after a remote update', fakeAsync(() => { + /** This test is under discussion. */ + xit('should remove resolved notes after a remote update', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -3448,14 +3457,14 @@ describe('EditorComponent', () => { let noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); expect(noteThreadEmbedCount).toEqual(5); - env.resolveNote('project01', 'dataid01'); + await env.resolveNote('project01', 'dataid01'); contents = env.targetEditor.getContents(); noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); expect(noteThreadEmbedCount).toEqual(4); env.dispose(); })); - it('should remove note thread icon from editor when thread is deleted', fakeAsync(() => { + it('should remove note thread icon from editor when thread is deleted', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); @@ -3464,14 +3473,14 @@ describe('EditorComponent', () => { const segmentRef = 'verse_1_3'; let thread2Elem: HTMLElement | null = env.getNoteThreadIconElement(segmentRef, threadId); expect(thread2Elem).not.toBeNull(); - env.deleteMostRecentNote('project01', segmentRef, threadId); + await env.deleteMostRecentNote('project01', segmentRef, threadId); thread2Elem = env.getNoteThreadIconElement(segmentRef, threadId); expect(thread2Elem).toBeNull(); // notes respond to edits after note icon removed const note1position: number = env.getNoteThreadEditorPosition('dataid01'); env.targetEditor.setSelection(note1position + 2, 'user'); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('project01', 'dataid01'); const originalPos: TextAnchor = { start: 8, length: 9 }; expect(noteThreadDoc.data!.position).toEqual(originalPos); env.typeCharacters('t'); @@ -3860,7 +3869,7 @@ describe('EditorComponent', () => { })); it('user has no resource access', fakeAsync(() => { - when(mockedSFProjectService.getProfile('resource01')).thenResolve({ + when(mockedSFProjectService.getProfile('resource01', anything())).thenResolve({ id: 'resource01', data: createTestProjectProfile() } as SFProjectProfileDoc); @@ -3884,7 +3893,7 @@ describe('EditorComponent', () => { env.setProjectUserConfig(); env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); env.wait(); - verify(mockedSFProjectService.get('resource01')).never(); + verify(mockedSFProjectService.subscribe('resource01', anything())).never(); expect(env.bookName).toEqual('Acts'); expect(env.component.chapter).toBe(1); expect(env.component.sourceLabel).toEqual('SRC'); @@ -4106,9 +4115,9 @@ describe('EditorComponent', () => { }); describe('initEditorTabs', () => { - it('should add source tab when source is defined and viewable', fakeAsync(() => { + it('should add source tab when source is defined and viewable', fakeAsync(async () => { const env = new TestEnvironment(); - const projectDoc = env.getProjectDoc('project01'); + const projectDoc = await env.getProjectDoc('project01'); const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); env.wait(); expect(spyCreateTab).toHaveBeenCalledWith('project-source', { @@ -4119,10 +4128,10 @@ describe('EditorComponent', () => { discardPeriodicTasks(); })); - it('should not add source tab when source is defined but not viewable', fakeAsync(() => { + it('should not add source tab when source is defined but not viewable', fakeAsync(async () => { const env = new TestEnvironment(); when(mockedPermissionsService.isUserOnProject('project02')).thenResolve(false); - const projectDoc = env.getProjectDoc('project01'); + const projectDoc = await env.getProjectDoc('project01'); const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); env.wait(); expect(spyCreateTab).not.toHaveBeenCalledWith('project-source', { @@ -4143,9 +4152,9 @@ describe('EditorComponent', () => { discardPeriodicTasks(); })); - it('should add target tab', fakeAsync(() => { + it('should add target tab', fakeAsync(async () => { const env = new TestEnvironment(); - const projectDoc = env.getProjectDoc('project01'); + const projectDoc = await env.getProjectDoc('project01'); const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); env.wait(); expect(spyCreateTab).toHaveBeenCalledWith('project-target', { @@ -4191,7 +4200,7 @@ describe('EditorComponent', () => { it('should exclude deleted resource tabs (tabs that have "projectDoc" but not "projectDoc.data")', fakeAsync(async () => { const absentProjectId = 'absentProjectId'; - when(mockedSFProjectService.getProfile(absentProjectId)).thenResolve({ + when(mockedSFProjectService.getProfile(absentProjectId, anything())).thenResolve({ data: undefined } as SFProjectProfileDoc); const env = new TestEnvironment(); @@ -4343,15 +4352,14 @@ describe('EditorComponent', () => { }); })); - it('should not throw exception on remote change when source is undefined', fakeAsync(() => { + it('should not throw exception on remote change when source is undefined', fakeAsync(async () => { const env = new TestEnvironment(); env.setProjectUserConfig(); env.wait(); env.component.source = undefined; - - expect(() => env.updateFontSize('project01', 24)).not.toThrow(); - + await expectAsync(env.updateFontSize('project01', 24)).not.toBeRejected(); + flush(); env.dispose(); })); }); @@ -4408,7 +4416,7 @@ describe('EditorComponent', () => { const tooltipHarness = await env.harnessLoader.getHarness( MatTooltipHarness.with({ selector: '#source-text-area .tab-header-content' }) ); - const sourceProjectDoc = env.getProjectDoc('project02'); + const sourceProjectDoc = await env.getProjectDoc('project02'); env.wait(); await tooltipHarness.show(); expect(await tooltipHarness.getTooltipText()).toBe(sourceProjectDoc.data?.translateConfig.source?.name!); @@ -4422,7 +4430,7 @@ describe('EditorComponent', () => { MatTooltipHarness.with({ selector: '#target-text-area .tab-header-content' }) ); - const targetProjectDoc = env.getProjectDoc('project01'); + const targetProjectDoc = await env.getProjectDoc('project01'); env.wait(); await tooltipHarness.show(); expect(await tooltipHarness.getTooltipText()).toBe(targetProjectDoc.data?.name!); @@ -4872,35 +4880,37 @@ class TestEnvironment { when(this.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).thenResolve(); when(this.mockedRemoteTranslationEngine.listenForTrainingStatus()).thenReturn(defer(() => this.trainingProgress$)); when(mockedSFProjectService.onlineAddTranslateMetrics('project01', anything())).thenResolve(); - when(mockedSFProjectService.getProfile('project01')).thenCall(() => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 'project01') + when(mockedSFProjectService.getProfile(anyString(), anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id, subscriber) ); - when(mockedSFProjectService.getProfile('project02')).thenCall(() => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 'project02') + when(mockedSFProjectService.tryGetForRole('project01', anything(), anything())).thenCall((id, role, subscriber) => + isParatextRole(role) ? this.realtimeService.subscribe(SFProjectDoc.COLLECTION, id, subscriber) : undefined ); - when(mockedSFProjectService.tryGetForRole('project01', anything())).thenCall((id, role) => - isParatextRole(role) ? this.realtimeService.subscribe(SFProjectDoc.COLLECTION, id) : undefined - ); - when(mockedSFProjectService.getUserConfig('project01', anything())).thenCall((_projectId, userId) => - this.realtimeService.subscribe( - SFProjectUserConfigDoc.COLLECTION, - getSFProjectUserConfigDocId('project01', userId) - ) + when(mockedSFProjectService.getUserConfig('project01', anything(), anything())).thenCall( + (_projectId, userId, subscriber) => + this.realtimeService.subscribe( + SFProjectUserConfigDoc.COLLECTION, + getSFProjectUserConfigDocId('project01', userId), + subscriber + ) ); - when(mockedSFProjectService.getUserConfig('project02', anything())).thenCall((_projectId, userId) => - this.realtimeService.subscribe( - SFProjectUserConfigDoc.COLLECTION, - getSFProjectUserConfigDocId('project02', userId) - ) + when(mockedSFProjectService.getUserConfig('project02', anything(), anything())).thenCall( + (_projectId, userId, subscriber) => + this.realtimeService.subscribe( + SFProjectUserConfigDoc.COLLECTION, + getSFProjectUserConfigDocId('project02', userId), + subscriber + ) ); - when(mockedSFProjectService.getText(anything())).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockedSFProjectService.getText(anything(), anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), subscriber) ); when(mockedSFProjectService.isProjectAdmin('project01', 'user04')).thenResolve(true); when(mockedSFProjectService.queryNoteThreads(anything(), anything(), anything(), anything())).thenCall( (id, bookNum, chapterNum, _) => this.realtimeService.subscribeQuery( NoteThreadDoc.COLLECTION, + 'spec', { [obj().pathStr(t => t.projectRef)]: id, [obj().pathStr(t => t.status)]: NoteStatus.Todo, @@ -4913,6 +4923,7 @@ class TestEnvironment { when(mockedSFProjectService.queryBiblicalTermNoteThreads(anything(), anything())).thenCall(id => this.realtimeService.subscribeQuery( NoteThreadDoc.COLLECTION, + 'spec', { [obj().pathStr(t => t.projectRef)]: id, [obj().pathStr(t => t.biblicalTermId)]: { $ne: null } @@ -4923,18 +4934,20 @@ class TestEnvironment { when(mockedSFProjectService.queryBiblicalTerms(anything(), anything(), anything())).thenCall(id => this.realtimeService.subscribeQuery( BiblicalTermDoc.COLLECTION, + 'spec', { [obj().pathStr(t => t.projectRef)]: id }, noopDestroyRef ) ); - when(mockedSFProjectService.createNoteThread(anything(), anything())).thenCall( - (projectId: string, noteThread: NoteThread) => { + when(mockedSFProjectService.createNoteThread(anything(), anything(), anything())).thenCall( + (projectId: string, noteThread: NoteThread, subscription) => { this.realtimeService.create( NoteThreadDoc.COLLECTION, getNoteThreadDocId(projectId, noteThread.dataId), - noteThread + noteThread, + subscription ); tick(); } @@ -4953,7 +4966,7 @@ class TestEnvironment { when(this.mockedDialogRef.afterClosed()).thenReturn(of()); this.breakpointObserver.matchedResult = false; - when(mockedSFProjectService.getNoteThread(anything())).thenCall((id: string) => { + when(mockedSFProjectService.getNoteThread(anything(), anything())).thenCall((id: string) => { const [projectId, threadId] = id.split(':'); return this.getNoteThreadDoc(projectId, threadId); }); @@ -5142,17 +5155,19 @@ class TestEnvironment { this.wait(); } - deleteText(textId: string): void { - this.ngZone.run(() => { - const textDoc = this.realtimeService.get(TextDoc.COLLECTION, textId); - textDoc.delete(); + async deleteText(textId: string): Promise { + await this.ngZone.run(async () => { + const textDoc = await this.realtimeService.get(TextDoc.COLLECTION, textId, new DocSubscription('spec')); + await textDoc.delete(); }); this.wait(); } setCurrentUser(userId: string): void { when(mockedUserService.currentUserId).thenReturn(userId); - when(mockedUserService.getCurrentUser()).thenCall(() => this.realtimeService.subscribe(UserDoc.COLLECTION, userId)); + when(mockedUserService.getCurrentUser()).thenCall(() => + this.realtimeService.subscribe(UserDoc.COLLECTION, userId, new DocSubscription('spec')) + ); } setParatextReviewerUser(): void { @@ -5164,6 +5179,7 @@ class TestEnvironment { (id, bookNum, chapterNum, _) => this.realtimeService.subscribeQuery( NoteThreadDoc.COLLECTION, + 'spec', { [obj().pathStr(t => t.publishedToSF)]: userId === 'user05', [obj().pathStr(t => t.status)]: NoteStatus.Todo, @@ -5300,28 +5316,33 @@ class TestEnvironment { spyOn((this.component as any).dialogService.matDialog, 'open').and.returnValue(mockDialogRef); } - getProjectUserConfigDoc(userId: string = 'user01'): SFProjectUserConfigDoc { - return this.realtimeService.get( + async getProjectUserConfigDoc(userId: string = 'user01'): Promise { + return await this.realtimeService.get( SFProjectUserConfigDoc.COLLECTION, - getSFProjectUserConfigDocId('project01', userId) + getSFProjectUserConfigDocId('project01', userId), + new DocSubscription('spec') ); } - getProjectDoc(projectId: string): SFProjectProfileDoc { - return this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); + async getProjectDoc(projectId: string): Promise { + return await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); } getSegmentElement(segmentRef: string): HTMLElement | null { return this.targetEditor.container.querySelector('usx-segment[data-segment="' + segmentRef + '"]'); } - getTextDoc(textId: TextDocId): TextDoc { - return this.realtimeService.get(TextDoc.COLLECTION, textId.toString()); + async getTextDoc(textId: TextDocId): Promise { + return await this.realtimeService.get(TextDoc.COLLECTION, textId.toString(), new DocSubscription('spec')); } - getNoteThreadDoc(projectId: string, threadDataId: string): NoteThreadDoc { + async getNoteThreadDoc(projectId: string, threadDataId: string): Promise { const docId: string = projectId + ':' + threadDataId; - return this.realtimeService.get(NoteThreadDoc.COLLECTION, docId); + return await this.realtimeService.get(NoteThreadDoc.COLLECTION, docId, new DocSubscription('spec')); } getNoteThreadIconElement(segmentRef: string, threadDataId: string): HTMLElement | null { @@ -5354,9 +5375,9 @@ class TestEnvironment { return thread!.classList.contains('note-thread-highlight'); } - setDataInSync(projectId: string, isInSync: boolean, source?: any): void { - const projectDoc: SFProjectProfileDoc = this.getProjectDoc(projectId); - projectDoc.submitJson0Op(op => op.set(p => p.sync.dataInSync!, isInSync), source); + async setDataInSync(projectId: string, isInSync: boolean, source?: any): Promise { + const projectDoc: SFProjectProfileDoc = await this.getProjectDoc(projectId); + await projectDoc.submitJson0Op(op => op.set(p => p.sync.dataInSync!, isInSync), source); tick(); this.fixture.detectChanges(); } @@ -5371,9 +5392,9 @@ class TestEnvironment { this.fixture.detectChanges(); } - updateFontSize(projectId: string, size: number): void { - const projectDoc: SFProjectProfileDoc = this.getProjectDoc(projectId); - projectDoc.submitJson0Op(op => op.set(p => p.defaultFontSize, size), false); + async updateFontSize(projectId: string, size: number): Promise { + const projectDoc: SFProjectProfileDoc = await this.getProjectDoc(projectId); + await projectDoc.submitJson0Op(op => op.set(p => p.defaultFontSize, size), false); tick(); this.fixture.detectChanges(); } @@ -5413,11 +5434,11 @@ class TestEnvironment { this.wait(); } - changeUserRole(projectId: string, userId: string, role: SFProjectRole): void { - const projectDoc: SFProjectProfileDoc = this.getProjectDoc(projectId); + async changeUserRole(projectId: string, userId: string, role: SFProjectRole): Promise { + const projectDoc: SFProjectProfileDoc = await this.getProjectDoc(projectId); const userRoles = cloneDeep(this.userRolesOnProject); userRoles[userId] = role; - projectDoc.submitJson0Op(op => op.set(p => p.userRoles, userRoles), false); + await projectDoc.submitJson0Op(op => op.set(p => p.userRoles, userRoles), false); this.wait(); } @@ -5518,6 +5539,7 @@ class TestEnvironment { this.wait(); this.component.metricsSession?.dispose(); this.waitForPresenceTimer(); + flush(); } addTextDoc(id: TextDocId, textType: TextType = 'target', corrupt: boolean = false, tooLong: boolean = false): void { @@ -5637,14 +5659,14 @@ class TestEnvironment { }); } - reattachNote( + async reattachNote( projectId: string, threadDataId: string, verseStr: string, position?: TextAnchor, doNotParseReattachedVerseStr: boolean = false - ): void { - const noteThreadDoc: NoteThreadDoc = this.getNoteThreadDoc(projectId, threadDataId); + ): Promise { + const noteThreadDoc: NoteThreadDoc = await this.getNoteThreadDoc(projectId, threadDataId); const template: Note = noteThreadDoc.data!.notes[0]; let reattached: string; if (doNotParseReattachedVerseStr || position == null) { @@ -5677,15 +5699,15 @@ class TestEnvironment { reattached }; const index: number = noteThreadDoc.data!.notes.length; - noteThreadDoc.submitJson0Op(op => { + await noteThreadDoc.submitJson0Op(op => { op.set(nt => nt.position, position); op.insert(nt => nt.notes, index, note); }); } - convertToConflictNote(projectId: string, threadDataId: string): void { - const noteThreadDoc: NoteThreadDoc = this.getNoteThreadDoc(projectId, threadDataId); - noteThreadDoc.submitJson0Op(op => { + async convertToConflictNote(projectId: string, threadDataId: string): Promise { + const noteThreadDoc: NoteThreadDoc = await this.getNoteThreadDoc(projectId, threadDataId); + await noteThreadDoc.submitJson0Op(op => { op.set(nt => nt.notes[0].conflictType, NoteConflictType.VerseTextConflict); op.set(nt => nt.notes[0].type, NoteType.Conflict); }); @@ -5701,19 +5723,19 @@ class TestEnvironment { return noteEmbedCount; } - resolveNote(projectId: string, threadId: string): void { - const noteDoc: NoteThreadDoc = this.getNoteThreadDoc(projectId, threadId); - noteDoc.submitJson0Op(op => op.set(n => n.status, NoteStatus.Resolved)); + async resolveNote(projectId: string, threadId: string): Promise { + const noteDoc: NoteThreadDoc = await this.getNoteThreadDoc(projectId, threadId); + await noteDoc.submitJson0Op(op => op.set(n => n.status, NoteStatus.Resolved)); this.realtimeService.updateQueryAdaptersRemote(); this.wait(); } - deleteMostRecentNote(projectId: string, segmentRef: string, threadId: string): void { + async deleteMostRecentNote(projectId: string, segmentRef: string, threadId: string): Promise { const noteThreadIconElem: HTMLElement = this.getNoteThreadIconElement(segmentRef, threadId)!; noteThreadIconElem.click(); this.wait(); - const noteDoc: NoteThreadDoc = this.getNoteThreadDoc(projectId, threadId); - noteDoc.submitJson0Op(op => op.set(d => d.notes[0].deleted, true)); + const noteDoc: NoteThreadDoc = await this.getNoteThreadDoc(projectId, threadId); + await noteDoc.submitJson0Op(op => op.set(d => d.notes[0].deleted, true)); this.mockNoteDialogRef.close({ deleted: true }); this.realtimeService.updateQueryAdaptersRemote(); this.wait(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts index 65866df3be1..565376e866a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts @@ -98,6 +98,7 @@ import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { Breakpoint, MediaBreakpointService } from 'xforge-common/media-breakpoints/media-breakpoint.service'; import { LocaleDirection } from 'xforge-common/models/i18n-locale'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; @@ -784,11 +785,18 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, const prevProjectId = this.projectDoc == null ? '' : this.projectDoc.id; if (projectId !== prevProjectId) { - this.projectDoc = await this.projectService.getProfile(projectId); + this.projectDoc = await this.projectService.getProfile( + projectId, + new DocSubscription('EditorComponent', this.destroyRef) + ); const userRole: string | undefined = this.userRole; if (userRole != null) { - const projectDoc: SFProjectDoc | undefined = await this.projectService.tryGetForRole(projectId, userRole); + const projectDoc: SFProjectDoc | undefined = await this.projectService.tryGetForRole( + projectId, + userRole, + new DocSubscription('EditorComponent', this.destroyRef) + ); if (projectDoc?.data?.paratextUsers != null) { this.paratextUsers = projectDoc.data.paratextUsers; } @@ -797,7 +805,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.isParatextUserRole = isParatextRole(this.userRole); this.projectUserConfigDoc = await this.projectService.getUserConfig( projectId, - this.userService.currentUserId + this.userService.currentUserId, + new DocSubscription('EditorComponent', this.destroyRef) ); this.sourceProjectDoc = await this.getSourceProjectDoc(); @@ -1339,7 +1348,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, persistedTabs.map(async tabData => { let projectDoc: SFProjectProfileDoc | undefined = undefined; if (tabData.projectId != null) { - projectDoc = await this.projectService.getProfile(tabData.projectId); + projectDoc = await this.projectService.getProfile( + tabData.projectId, + new DocSubscription('EditorComponent', this.destroyRef) + ); } return { @@ -1533,10 +1545,15 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, status: NoteStatus.Todo, publishedToSF: true }; - await this.projectService.createNoteThread(this.projectId, noteThread); + await this.projectService.createNoteThread( + this.projectId, + noteThread, + new DocSubscription('EditorComponent', this.destroyRef) + ); } else { const threadDoc: NoteThreadDoc = await this.projectService.getNoteThread( - getNoteThreadDocId(this.projectId, params.threadDataId) + getNoteThreadDocId(this.projectId, params.threadDataId), + new DocSubscription('EditorComponent', this.destroyRef) ); const noteIndex: number = threadDoc.data!.notes.findIndex(n => n.dataId === params.dataId); if (noteIndex >= 0) { @@ -1838,7 +1855,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.target.id = targetId; this.setSegment(); - const textDoc = await this.projectService.getText(targetId); + const textDoc = await this.projectService.getText( + targetId, + new DocSubscription('EditorComponent', this.destroyRef) + ); if (this.onTargetDeleteSub != null) { this.onTargetDeleteSub.unsubscribe(); @@ -2093,7 +2113,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, // Only get the project doc if the user is on the project to avoid an error. if (this.sourceProjectId == null) return undefined; if (this.currentUser?.sites[environment.siteId].projects.includes(this.sourceProjectId) !== true) return undefined; - return await this.projectService.getProfile(this.sourceProjectId); + return await this.projectService.getProfile( + this.sourceProjectId, + new DocSubscription('EditorComponent', this.destroyRef) + ); } private async loadNoteThreadDocs(sfProjectId: string, bookNum: number, chapterNum: number): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts index 7df22315f7f..a4208d882dc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts @@ -886,7 +886,7 @@ class TestEnvironment { when(mockTextDoc.data).thenReturn(mockTextDocData); // This is the critical fix - ensure getText returns a Promise, not null - when(mockSFProjectService.getText(anything())).thenResolve(instance(mockTextDoc)); + when(mockSFProjectService.getText(anything(), anything())).thenResolve(instance(mockTextDoc)); when(mockTextDoc.getSegmentText(anything())).thenReturn('Sample text content for testing'); when(mockRouter.navigate(anything(), anything())).thenResolve(true); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts index fe8082f1c16..38576ffe862 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts @@ -24,6 +24,7 @@ import { asapScheduler, combineLatest, debounceTime, map, tap } from 'rxjs'; import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { isWhitespace } from 'xforge-common/util/string-util'; import { SFProjectService } from '../../../../../core/sf-project.service'; @@ -847,29 +848,31 @@ export class LynxInsightsPanelComponent implements AfterViewInit { } // Create and cache the promise for loading the document - return this.projectService.getText(insight.textDocId).then(textDoc => { - const textDocData: TextData | undefined = textDoc.data; + return this.projectService + .getText(insight.textDocId, new DocSubscription('LynxInsightsPanelComponent', this.destroyRef)) + .then(textDoc => { + const textDocData: TextData | undefined = textDoc.data; - if (textDocData != null) { - this.textDocDataCache.set(textDocIdStr, textDocData); + if (textDocData != null) { + this.textDocDataCache.set(textDocIdStr, textDocData); - if (textDocData.ops != null) { - this.textDocSegments.set(textDocIdStr, this.editorSegmentService.parseSegments(textDocData.ops)); + if (textDocData.ops != null) { + this.textDocSegments.set(textDocIdStr, this.editorSegmentService.parseSegments(textDocData.ops)); + } } - } - // On text edits, update cached text doc data and segment map for text doc - textDoc.changes$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe((changes: TextData) => { - if (changes?.ops != null) { - const prevDocOps: DeltaOperation[] | undefined = this.textDocDataCache.get(textDocIdStr)?.ops; - const newTextDocData: TextData = new Delta(prevDocOps).compose(new Delta(changes.ops)); - this.textDocDataCache.set(textDocIdStr, newTextDocData); - this.textDocSegments.set(textDocIdStr, this.editorSegmentService.parseSegments(newTextDocData.ops ?? [])); - } - }); + // On text edits, update cached text doc data and segment map for text doc + textDoc.changes$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe((changes: TextData) => { + if (changes?.ops != null) { + const prevDocOps: DeltaOperation[] | undefined = this.textDocDataCache.get(textDocIdStr)?.ops; + const newTextDocData: TextData = new Delta(prevDocOps).compose(new Delta(changes.ops)); + this.textDocDataCache.set(textDocIdStr, newTextDocData); + this.textDocSegments.set(textDocIdStr, this.editorSegmentService.parseSegments(newTextDocData.ops ?? [])); + } + }); - return this.textDocDataCache.get(textDocIdStr); - }); + return this.textDocDataCache.get(textDocIdStr); + }); } /** diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts index 5512db59895..2b93855c1f1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts @@ -7,12 +7,13 @@ import { Canon } from '@sillsdev/scripture'; import Delta, { Op } from 'quill-delta'; import { LynxConfig } from 'realtime-server/lib/esm/scriptureforge/models/lynx-config'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; -import { BehaviorSubject, Subject, Subscription } from 'rxjs'; +import { BehaviorSubject, filter, Subject, Subscription } from 'rxjs'; import { anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; import { Locale } from 'xforge-common/models/i18n-locale'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeService } from 'xforge-common/realtime.service'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; @@ -60,18 +61,23 @@ describe('LynxWorkspaceService', () => { readonly localeTestSubject$ = new BehaviorSubject(defaultLocale); readonly diagnosticsChangedTestSubject$ = new Subject(); - constructor(autoInit = true) { + private constructor() { this.setupMocks(); + } - if (autoInit) { - this.init(); - } + static async create(): Promise { + const env = new TestEnvironment(); + await env.init(); + return env; } setupMocks(): void { when(mockI18nService.localeCode).thenReturn(defaultLocale.canonicalTag); when(mockI18nService.locale$).thenReturn(this.localeTestSubject$); when(mockActivatedProjectService.projectDoc$).thenReturn(this.projectDocTestSubject$); + when(mockActivatedProjectService.differentDefinedDoc$).thenReturn( + this.projectDocTestSubject$.pipe(filter((doc): doc is SFProjectProfileDoc => doc != null)) + ); when(mockActivatedBookChapterService.activatedBookChapter$).thenReturn(this.bookChapterTestSubject$); when(mockDestroyRef.onDestroy(anything())).thenCall((callback: () => void) => callback()); @@ -147,20 +153,24 @@ describe('LynxWorkspaceService', () => { }); } - init(): void { + private async init(): Promise { this.realtimeService = TestBed.inject(RealtimeService as any); - when(mockProjectService.getText(anything())).thenCall(textDocId => { + when(mockProjectService.getText(anything(), anything())).thenCall(async textDocId => { const id = typeof textDocId === 'string' ? textDocId : textDocId.toString(); - const existingDoc = this.realtimeService.get(TextDoc.COLLECTION, id); - return Promise.resolve(existingDoc || this.createTextDoc()); + const existingDoc = await this.realtimeService.get( + TextDoc.COLLECTION, + id, + new DocSubscription('spec') + ); + return existingDoc ?? (await this.createTextDoc()); }); - this.createTextDoc(CHAPTER_NUM); - this.createTextDoc(CHAPTER_NUM + 1); + await this.createTextDoc(CHAPTER_NUM); + await this.createTextDoc(CHAPTER_NUM + 1); this.service = TestBed.inject(LynxWorkspaceService); - this.service.init(); + await this.service.init(); tick(); } @@ -171,7 +181,7 @@ describe('LynxWorkspaceService', () => { resetCalls(mockProjectService); } - createTextDoc(chapter: number = CHAPTER_NUM, content: Delta | string = TEST_CONTENT): TextDoc { + async createTextDoc(chapter: number = CHAPTER_NUM, content: Delta | string = TEST_CONTENT): Promise { const textDocId = new TextDocId(PROJECT_ID, BOOK_NUM, chapter); const id = textDocId.toString(); const delta = typeof content === 'string' ? new Delta().insert(content) : content; @@ -182,10 +192,10 @@ describe('LynxWorkspaceService', () => { data: delta }); - return this.realtimeService.get(TextDoc.COLLECTION, id); + return await this.realtimeService.get(TextDoc.COLLECTION, id, new DocSubscription('spec')); } - createMockProjectDoc(id: string = PROJECT_ID, lynxConfig?: LynxConfig): SFProjectProfileDoc { + async createMockProjectDoc(id: string = PROJECT_ID, lynxConfig?: LynxConfig): Promise { const projectData = createTestProjectProfile({ texts: [ { @@ -206,7 +216,11 @@ describe('LynxWorkspaceService', () => { data: projectData }); - return this.realtimeService.get(SFProjectProfileDoc.COLLECTION, id); + return await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + id, + new DocSubscription('spec') + ); } createMockScriptureDeltaDoc(): any { @@ -263,8 +277,8 @@ describe('LynxWorkspaceService', () => { tick(); } - triggerProjectChange(id: string, lynxConfig?: LynxConfig): void { - this.projectDocTestSubject$.next(this.createMockProjectDoc(id, lynxConfig)); + async triggerProjectChange(id: string, lynxConfig?: LynxConfig): Promise { + this.projectDocTestSubject$.next(await this.createMockProjectDoc(id, lynxConfig)); tick(); } @@ -344,8 +358,8 @@ describe('LynxWorkspaceService', () => { }); describe('Initialization', () => { - it('should update language when locale changes', fakeAsync(() => { - const env = new TestEnvironment(); + it('should update language when locale changes', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Override the workspace factory for this test to use a plain object with a spy const changeLanguageSpy = jasmine.createSpy('changeLanguage').and.returnValue(Promise.resolve()); @@ -374,7 +388,7 @@ describe('LynxWorkspaceService', () => { when(mockWorkspaceFactory.createWorkspace(anything(), anything())).thenReturn(workspaceMock as any); // Set up project and workspace - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -402,28 +416,28 @@ describe('LynxWorkspaceService', () => { }); describe('Project activation', () => { - it('should reset document manager when project is activated', fakeAsync(() => { - const env = new TestEnvironment(); + it('should reset document manager when project is activated', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.service['projectId'] = 'different-project-id'; resetCalls(mockDocumentManager); - env.triggerProjectChange(PROJECT_ID); + await env.triggerProjectChange(PROJECT_ID); verify(mockDocumentManager.reset()).once(); })); - it('should not reset document manager when project id is unchanged', fakeAsync(() => { - const env = new TestEnvironment(); + it('should not reset document manager when project id is unchanged', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.service['projectId'] = PROJECT_ID; resetCalls(mockDocumentManager); - env.triggerProjectChange(PROJECT_ID); + await env.triggerProjectChange(PROJECT_ID); verify(mockDocumentManager.reset()).never(); })); - it('should clear insights when project changes', fakeAsync(() => { - const env = new TestEnvironment(); + it('should clear insights when project changes', fakeAsync(async () => { + const env = await TestEnvironment.create(); const insight = env.createTestInsight(); env.addInsightToService(insight); @@ -431,15 +445,15 @@ describe('LynxWorkspaceService', () => { expect(env.service.currentInsights.length).toBeGreaterThan(0); env.service['projectId'] = 'different-id'; - env.triggerProjectChange('new-project'); + await env.triggerProjectChange('new-project'); expect(env.service.currentInsights.length).toBe(0); })); }); describe('Task running status', () => { - it('should emit false when no project is active', fakeAsync(() => { - const env = new TestEnvironment(); + it('should emit false when no project is active', fakeAsync(async () => { + const env = await TestEnvironment.create(); let taskRunning: boolean | undefined; env.service.taskRunningStatus$.subscribe(status => { @@ -450,8 +464,8 @@ describe('LynxWorkspaceService', () => { expect(taskRunning).toBe(false); })); - it('should emit true when project is activated, then false after insights arrive', fakeAsync(() => { - const env = new TestEnvironment(); + it('should emit true when project is activated, then false after insights arrive', fakeAsync(async () => { + const env = await TestEnvironment.create(); const statusValues: boolean[] = []; env.service.taskRunningStatus$.subscribe(status => { @@ -463,12 +477,12 @@ describe('LynxWorkspaceService', () => { expect(statusValues).toEqual([false]); // When project is activated, should emit true (task running) - env.triggerProjectChange(PROJECT_ID); + await env.triggerProjectChange(PROJECT_ID); expect(statusValues).toEqual([false, true, false]); })); - it('should restart loading cycle when different project is activated', fakeAsync(() => { - const env = new TestEnvironment(); + it('should restart loading cycle when different project is activated', fakeAsync(async () => { + const env = await TestEnvironment.create(); const statusValues: boolean[] = []; env.service.taskRunningStatus$.subscribe(status => { @@ -477,22 +491,22 @@ describe('LynxWorkspaceService', () => { // Initial state and first project tick(); - env.triggerProjectChange(PROJECT_ID); + await env.triggerProjectChange(PROJECT_ID); env.setupActiveTextDocId(); tick(); // Allow workspace setup to complete expect(statusValues).toEqual([false, true, false]); // Switch to different project - should restart loading cycle const differentProjectId = 'project02'; - env.triggerProjectChange(differentProjectId); + await env.triggerProjectChange(differentProjectId); env.service['projectId'] = differentProjectId; env.service['textDocId'] = new TextDocId(differentProjectId, BOOK_NUM, CHAPTER_NUM); tick(); // Allow workspace setup to complete expect(statusValues).toEqual([false, true, false, true]); })); - it('should handle empty insights correctly', fakeAsync(() => { - const env = new TestEnvironment(); + it('should handle empty insights correctly', fakeAsync(async () => { + const env = await TestEnvironment.create(); const statusValues: boolean[] = []; env.service.taskRunningStatus$.subscribe(status => { @@ -500,12 +514,12 @@ describe('LynxWorkspaceService', () => { }); tick(); - env.triggerProjectChange(PROJECT_ID); + await env.triggerProjectChange(PROJECT_ID); expect(statusValues).toEqual([false, true, false]); })); - it('should use shareReplay to avoid multiple subscriptions triggering multiple emissions', fakeAsync(() => { - const env = new TestEnvironment(); + it('should use shareReplay to avoid multiple subscriptions triggering multiple emissions', fakeAsync(async () => { + const env = await TestEnvironment.create(); const statusValues1: boolean[] = []; const statusValues2: boolean[] = []; @@ -514,7 +528,7 @@ describe('LynxWorkspaceService', () => { env.service.taskRunningStatus$.subscribe(status => statusValues2.push(status)); tick(); - env.triggerProjectChange(PROJECT_ID); + await env.triggerProjectChange(PROJECT_ID); env.setupActiveTextDocId(); env.triggerDiagnostics(['Test insight']); @@ -525,11 +539,11 @@ describe('LynxWorkspaceService', () => { }); describe('Book chapter activation', () => { - it('should fire document closed event when chapter changes', fakeAsync(() => { - const env = new TestEnvironment(); + it('should fire document closed event when chapter changes', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Create project with lynx features enabled so documents will be opened - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -555,11 +569,11 @@ describe('LynxWorkspaceService', () => { verify(mockDocumentManager.fireClosed(anything())).once(); })); - it('should handle book chapters with undefined chapter number', fakeAsync(() => { - const env = new TestEnvironment(); + it('should handle book chapters with undefined chapter number', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Create project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -598,11 +612,11 @@ describe('LynxWorkspaceService', () => { expect(() => env.service['textDocId']).not.toThrow(); })); - it('should open document when chapter is activated', fakeAsync(() => { - const env = new TestEnvironment(); + it('should open document when chapter is activated', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Create project with lynx features enabled so documents will be opened - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -621,11 +635,11 @@ describe('LynxWorkspaceService', () => { verify(mockDocumentManager.fireOpened(anything(), anything())).once(); })); - it('should update textDocId when chapter changes', fakeAsync(() => { - const env = new TestEnvironment(); + it('should update textDocId when chapter changes', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled so documents will be opened - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -651,11 +665,11 @@ describe('LynxWorkspaceService', () => { }); describe('Insights processing', () => { - it('should process diagnostics into insights', fakeAsync(() => { - const env = new TestEnvironment(); + it('should process diagnostics into insights', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -680,11 +694,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should convert diagnostic severity to appropriate insight type', fakeAsync(() => { - const env = new TestEnvironment(); + it('should convert diagnostic severity to appropriate insight type', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -717,11 +731,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should maintain insight ids for matching insights', fakeAsync(() => { - const env = new TestEnvironment(); + it('should maintain insight ids for matching insights', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -745,11 +759,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should remove insights when empty diagnostics are sent', fakeAsync(() => { - const env = new TestEnvironment(); + it('should remove insights when empty diagnostics are sent', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -777,11 +791,11 @@ describe('LynxWorkspaceService', () => { }); describe('getOnTypeEdits', () => { - it('should return edits for trigger characters', fakeAsync(() => { - const env = new TestEnvironment(); + it('should return edits for trigger characters', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with auto-corrections enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: false, punctuationCheckerEnabled: true, @@ -798,7 +812,7 @@ describe('LynxWorkspaceService', () => { const mockEmbedCountsToOffset = (_offset: number): number => 0; // No embeds in this test let result: Delta[] = []; - env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset).then(res => (result = res)); + result = await env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset); tick(); expect(result.length).toBe(1); @@ -806,8 +820,8 @@ describe('LynxWorkspaceService', () => { expect(result[0].ops).toEqual([{ retain: 5 }, { insert: ' ' }]); })); - it('should handle multiple trigger characters', fakeAsync(() => { - const env = new TestEnvironment(); + it('should handle multiple trigger characters', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCustomWorkspaceMock((workspaceMock: any) => { when(workspaceMock.getOnTypeEdits(anything(), anything(), ',')).thenResolve([ @@ -821,7 +835,7 @@ describe('LynxWorkspaceService', () => { }); // Set up project with auto-corrections enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: false, punctuationCheckerEnabled: true, @@ -837,7 +851,7 @@ describe('LynxWorkspaceService', () => { const mockEmbedCountsToOffset = (_offset: number): number => 0; // No embeds in this test let result: Delta[] = []; - env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset).then(res => (result = res)); + result = await env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset); tick(); expect(result.length).toBe(2); @@ -845,26 +859,26 @@ describe('LynxWorkspaceService', () => { expect(result[1].ops).toEqual([{ retain: 6 }, { insert: ' ' }]); })); - it('should handle null document when getting on-type edits', fakeAsync(() => { - const env = new TestEnvironment(); + it('should handle null document when getting on-type edits', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.service['textDocId'] = new TextDocId(PROJECT_ID, BOOK_NUM, CHAPTER_NUM); when(mockDocumentManager.get(anything())).thenResolve(undefined); const delta = new Delta().insert('Hello,'); const mockEmbedCountsToOffset = (_offset: number): number => 0; // No embeds in this test let result: Delta[] = []; - env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset).then(res => (result = res)); + result = await env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset); tick(); expect(result).toEqual([]); })); - it('should return empty array when auto-corrections are disabled', fakeAsync(() => { - const env = new TestEnvironment(); + it('should return empty array when auto-corrections are disabled', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.service['textDocId'] = new TextDocId(PROJECT_ID, BOOK_NUM, CHAPTER_NUM); // Create project with auto-corrections disabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: false, punctuationCheckerEnabled: false, @@ -877,7 +891,7 @@ describe('LynxWorkspaceService', () => { const mockEmbedCountsToOffset = (_offset: number): number => 0; // No embeds in this test let result: Delta[] = []; - env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset).then(res => (result = res)); + result = await env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset); tick(); expect(result).toEqual([]); @@ -885,8 +899,8 @@ describe('LynxWorkspaceService', () => { verify(mockWorkspace.getOnTypeEdits(anything(), anything(), anything())).never(); })); - it('should return edits when auto-corrections are enabled', fakeAsync(() => { - const env = new TestEnvironment(); + it('should return edits when auto-corrections are enabled', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCustomWorkspaceMock((workspaceMock: any) => { when(workspaceMock.getOnTypeEdits(anything(), anything(), anything())).thenResolve([ @@ -896,7 +910,7 @@ describe('LynxWorkspaceService', () => { }); // Create project with auto-corrections enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: false, punctuationCheckerEnabled: true, @@ -912,15 +926,15 @@ describe('LynxWorkspaceService', () => { const mockEmbedCountsToOffset = (_offset: number): number => 0; // No embeds in this test let result: Delta[] = []; - env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset).then(res => (result = res)); + result = await env.service.getOnTypeEdits(delta, mockEmbedCountsToOffset); tick(); expect(result.length).toBe(1); expect(result[0].ops).toEqual([{ retain: 5 }, { insert: ' ' }]); })); - it('should adjust offset for note embeds when getting on-type edits', fakeAsync(() => { - const env = new TestEnvironment(); + it('should adjust offset for note embeds when getting on-type edits', fakeAsync(async () => { + const env = await TestEnvironment.create(); let capturedPosition: Position | undefined; env.setCustomWorkspaceMock((workspaceMock: any) => { @@ -933,7 +947,7 @@ describe('LynxWorkspaceService', () => { }); // Create project with auto-corrections enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: false, punctuationCheckerEnabled: true, @@ -965,8 +979,8 @@ describe('LynxWorkspaceService', () => { }); describe('getActions', () => { - it('should get actions for an insight', fakeAsync(() => { - const env = new TestEnvironment(); + it('should get actions for an insight', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setCustomWorkspaceMock((workspaceMock: any) => { when(workspaceMock.getDiagnosticFixes(anything(), anything())).thenResolve([ @@ -985,7 +999,7 @@ describe('LynxWorkspaceService', () => { ] as any); }); - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: true, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -999,7 +1013,7 @@ describe('LynxWorkspaceService', () => { const insight = env.createTestInsight(); let actions: LynxInsightAction[] = []; - env.service.getActions(insight).then(res => (actions = res)); + actions = await env.service.getActions(insight); tick(); expect(actions.length).toBe(1); @@ -1008,13 +1022,13 @@ describe('LynxWorkspaceService', () => { expect(actions[0].ops).toEqual([{ retain: 0 }, { insert: 'corrected' }, { delete: 10 }]); })); - it('should handle null document when getting actions', fakeAsync(() => { - const env = new TestEnvironment(); + it('should handle null document when getting actions', fakeAsync(async () => { + const env = await TestEnvironment.create(); when(mockDocumentManager.get(anything())).thenResolve(undefined); const insight = env.createTestInsight(); let actions: LynxInsightAction[] = []; - env.service.getActions(insight).then(res => (actions = res)); + actions = await env.service.getActions(insight); tick(); expect(actions).toEqual([]); @@ -1022,11 +1036,11 @@ describe('LynxWorkspaceService', () => { }); describe('2D Map Structure - Insights by URI and Source', () => { - it('should organize insights by URI and diagnostic source', fakeAsync(() => { - const env = new TestEnvironment(); + it('should organize insights by URI and diagnostic source', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -1093,11 +1107,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should preserve insights from different sources when one source is updated', fakeAsync(() => { - const env = new TestEnvironment(); + it('should preserve insights from different sources when one source is updated', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -1179,11 +1193,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should reuse insight ids for matching diagnostics within the same source', fakeAsync(() => { - const env = new TestEnvironment(); + it('should reuse insight ids for matching diagnostics within the same source', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -1269,11 +1283,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should flatten 2D map correctly when returning insights', fakeAsync(() => { - const env = new TestEnvironment(); + it('should flatten 2D map correctly when returning insights', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -1335,11 +1349,11 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should clear all sources for a URI when empty diagnostics are received', fakeAsync(() => { - const env = new TestEnvironment(); + it('should clear all sources for a URI when empty diagnostics are received', fakeAsync(async () => { + const env = await TestEnvironment.create(); // Set up project with lynx features enabled - const projectDoc = env.createMockProjectDoc(PROJECT_ID, { + const projectDoc = await env.createMockProjectDoc(PROJECT_ID, { autoCorrectionsEnabled: false, assessmentsEnabled: true, punctuationCheckerEnabled: true, @@ -1395,8 +1409,8 @@ describe('LynxWorkspaceService', () => { subscription.unsubscribe(); })); - it('should maintain consistent currentInsights getter behavior', fakeAsync(() => { - const env = new TestEnvironment(); + it('should maintain consistent currentInsights getter behavior', fakeAsync(async () => { + const env = await TestEnvironment.create(); env.setupActiveTextDocId(); // Add some insights using the internal 2D map structure diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts index 206ada740ac..022976e5958 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts @@ -37,6 +37,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../../../core/models/text-doc'; @@ -85,7 +86,7 @@ export class LynxWorkspaceService { private readonly workspaceFactory: LynxWorkspaceFactory, @Inject(DocumentManager) private readonly documentManager: DocumentManager ) { - this.activatedProjectService.projectDoc$ + this.activatedProjectService.differentDefinedDoc$ .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(projectDoc => this.onProjectActivated(projectDoc)); this.activatedBookChapterService.activatedBookChapter$ @@ -472,7 +473,10 @@ export class LynxWorkspaceService { this.textDocId = textDocId; if (this.textDocId != null && shouldOpenDoc) { const uri: string = this.textDocId.toString(); - const textDoc = await this.projectService.getText(this.textDocId); + const textDoc = await this.projectService.getText( + this.textDocId, + new DocSubscription('LynxWorkspaceService', this.destroyRef) + ); await this.documentManager.fireOpened(uri, { format: 'scripture-delta', version: textDoc.adapter.version, @@ -535,14 +539,20 @@ export class LynxWorkspaceService { export class TextDocReader implements DocumentReader { public textDocIds: Set = new Set(); - constructor(private readonly projectService: SFProjectService) {} + constructor( + private readonly projectService: SFProjectService, + private readonly activatedProjectService: ActivatedProjectService + ) {} keys(): Promise { return Promise.resolve([...this.textDocIds]); } async read(uri: string): Promise | undefined> { - const textDoc = await this.projectService.getText(uri); + const textDoc = await this.projectService.getText( + uri, + new DocSubscription('TextDocReader', this.activatedProjectService.switched$) + ); if (textDoc.data == null) return undefined; return { format: 'scripture-delta', diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/text-doc-reader.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/text-doc-reader.spec.ts new file mode 100644 index 00000000000..9d099a9652b --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/text-doc-reader.spec.ts @@ -0,0 +1,90 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { BehaviorSubject, filter, take } from 'rxjs'; +import { anything, mock, when } from 'ts-mockito'; +import { ActivatedProjectService, ActiveProjectIdService } from 'xforge-common/activated-project.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; +import { configureTestingModule } from 'xforge-common/test-utils'; +import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; +import { TextDoc, TextDocId } from '../../../../core/models/text-doc'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { TextDocReader } from './lynx-workspace.service'; + +const PROJECT_ID = 'project01'; +const NEXT_PROJECT_ID = 'project02'; +const mockedProjectService = mock(SFProjectService); + +describe('TextDocReader', () => { + let env: TestEnvironment; + + configureTestingModule(() => ({ + providers: [ + TextDocReader, + ActivatedProjectService, + { provide: ActiveProjectIdService, useFactory: () => new TestActiveProjectIdService() }, + { provide: SFProjectService, useMock: mockedProjectService } + ] + })); + + beforeEach(() => { + env = new TestEnvironment(); + }); + + it('should unsubscribe text doc subscriptions when the active project changes', fakeAsync(() => { + const textDocId = new TextDocId(PROJECT_ID, 40, 1); + + env.activateProject(PROJECT_ID); + tick(); + + void env.reader.read(textDocId.toString()); + tick(); + + expect(env.numTextDocUnsubscribes).toBe(0); + + env.activateProject(NEXT_PROJECT_ID); + tick(); + + expect(env.numTextDocUnsubscribes).toBe(1); + })); +}); + +class TestEnvironment { + readonly reader: TextDocReader; + numTextDocUnsubscribes: number = 0; + + private readonly activeProjectIdService: TestActiveProjectIdService; + + constructor() { + this.activeProjectIdService = TestBed.inject(ActiveProjectIdService) as unknown as TestActiveProjectIdService; + this.reader = TestBed.inject(TextDocReader); + + when(mockedProjectService.getProfile(PROJECT_ID, anything())).thenResolve({ + id: PROJECT_ID + } as SFProjectProfileDoc); + when(mockedProjectService.getProfile(NEXT_PROJECT_ID, anything())).thenResolve({ + id: NEXT_PROJECT_ID + } as SFProjectProfileDoc); + when(mockedProjectService.getText(anything(), anything())).thenCall( + async (_textId: TextDocId | string, subscriber: DocSubscription) => { + subscriber.isUnsubscribed$ + .pipe( + filter(isUnsubscribed => isUnsubscribed === true), + take(1) + ) + .subscribe(() => this.numTextDocUnsubscribes++); + + return { + data: { ops: [] }, + adapter: { version: 1 } + } as unknown as TextDoc; + } + ); + } + + activateProject(projectId: string | undefined): void { + this.activeProjectIdService.projectId$.next(projectId); + } +} + +class TestActiveProjectIdService { + readonly projectId$ = new BehaviorSubject(undefined); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.spec.ts index ba8c9d9926f..d0eddec5cc5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.spec.ts @@ -37,15 +37,11 @@ import * as RichText from 'rich-text'; import { firstValueFrom } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserProfileDoc } from 'xforge-common/models/user-profile-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; -import { - ChildViewContainerComponent, - configureTestingModule, - getTestTranslocoModule, - matDialogCloseDelay -} from 'xforge-common/test-utils'; +import { ChildViewContainerComponent, configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; import { UserService } from 'xforge-common/user.service'; import { BiblicalTermDoc } from '../../../core/models/biblical-term-doc'; import { NoteThreadDoc } from '../../../core/models/note-thread-doc'; @@ -77,6 +73,7 @@ describe('NoteDialogComponent', () => { if (env.dialogContentArea != null) { env.closeDialog(); } + flush(); })); it('show selected text and toggle visibility of related segment', fakeAsync(() => { @@ -344,10 +341,10 @@ describe('NoteDialogComponent', () => { expect(env.saveButton).toBeNull(); })); - it('does not save if empty note added to an existing thread', fakeAsync(() => { + it('does not save if empty note added to an existing thread', fakeAsync(async () => { env = new TestEnvironment({ noteThread: TestEnvironment.getNoteThread() }); expect(env.noteInputElement).toBeTruthy(); - const noteThread: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThread.data!.notes.length).toEqual(5); env.submit(); expect(noteThread.data!.notes.length).toEqual(5); @@ -363,11 +360,11 @@ describe('NoteDialogComponent', () => { expect(env.dialogResult).toEqual({ noteContent: content, noteDataId: undefined }); })); - it('allows user to edit the last note in the thread', fakeAsync(() => { + it('allows user to edit the last note in the thread', fakeAsync(async () => { env = new TestEnvironment({ noteThread: TestEnvironment.getNoteThread(undefined, undefined, true) }); // note03 is marked as deleted expect(env.notes.length).toEqual(4); - const noteThread: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThread.data!.notes[4].content).toEqual('note05'); const noteNumbers = [1, 2, 3]; noteNumbers.forEach(n => expect(env.noteHasEditActions(n)).toBe(false)); @@ -383,11 +380,11 @@ describe('NoteDialogComponent', () => { expect(env.dialogResult).toEqual({ noteContent: content, noteDataId: 'note05' }); })); - it('allows user to resolve the last note in the thread', fakeAsync(() => { + it('allows user to resolve the last note in the thread', fakeAsync(async () => { env = new TestEnvironment({ noteThread: TestEnvironment.getNoteThread(undefined, undefined, true) }); // note03 is marked as deleted expect(env.notes.length).toEqual(4); - const noteThread: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThread.data!.notes[4].content).toEqual('note05'); const noteNumbers = [1, 2, 3]; noteNumbers.forEach(n => expect(env.noteHasEditActions(n)).toBe(false)); @@ -404,21 +401,21 @@ describe('NoteDialogComponent', () => { expect(env.dialogResult).toEqual({ noteContent: content, noteDataId: 'note05', status: NoteStatus.Resolved }); })); - it('does not allow user to edit the last note in the thread if it is not editable', fakeAsync(() => { + it('does not allow user to edit the last note in the thread if it is not editable', fakeAsync(async () => { env = new TestEnvironment({ noteThread: TestEnvironment.getNoteThread() }); // note03 is marked as deleted expect(env.notes.length).toEqual(4); - const noteThread: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThread.data!.notes[4].content).toEqual('note05'); const noteNumbers = [1, 2, 3, 4]; noteNumbers.forEach(n => expect(env.noteHasEditActions(n)).toBe(false)); })); - it('allows user to delete the last note in the thread', fakeAsync(() => { + it('allows user to delete the last note in the thread', fakeAsync(async () => { env = new TestEnvironment({ noteThread: TestEnvironment.getNoteThread(undefined, undefined, true) }); // note03 is marked as deleted expect(env.notes.length).toEqual(4); - const noteThread: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThread.data!.notes.length).toEqual(5); expect(env.noteHasEditActions(3)).toBe(false); expect(env.noteHasEditActions(4)).toBe(true); @@ -447,10 +444,10 @@ describe('NoteDialogComponent', () => { expect(env.notes.length).toEqual(4); })); - it('deletes the thread if the last note is deleted', fakeAsync(() => { + it('deletes the thread if the last note is deleted', fakeAsync(async () => { env = new TestEnvironment({ noteThread: TestEnvironment.defaultNoteThread }); expect(env.notes.length).toEqual(1); - const noteThread: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThread: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThread.data).toBeTruthy(); expect(env.noteHasEditActions(1)).toBe(true); env.clickDeleteNote(); @@ -459,7 +456,7 @@ describe('NoteDialogComponent', () => { expect(noteThread.data!.notes[0].deleted).toBe(true); })); - it('deletes the thread if the deleted note is the only active note', fakeAsync(() => { + it('deletes the thread if the deleted note is the only active note', fakeAsync(async () => { const noteThread: NoteThread = cloneDeep(TestEnvironment.defaultNoteThread); const note: Note = { dataId: 'note02', @@ -476,7 +473,7 @@ describe('NoteDialogComponent', () => { noteThread.notes.push(note); env = new TestEnvironment({ noteThread }); expect(env.notes.length).toEqual(1); - const threadDoc: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const threadDoc: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(threadDoc).toBeTruthy(); expect(env.noteHasEditActions(1)).toBe(true); env.clickDeleteNote(); @@ -587,7 +584,7 @@ describe('NoteDialogComponent', () => { expect(env.dialogResult).toEqual({ noteContent: content, noteDataId: undefined }); })); - it('allows adding a note to an existing biblical term note thread', fakeAsync(() => { + it('allows adding a note to an existing biblical term note thread', fakeAsync(async () => { const biblicalTerm = TestEnvironment.defaultBiblicalTerm; const noteThread = TestEnvironment.getNoteThread(); noteThread.biblicalTermId = biblicalTerm.dataId; @@ -599,7 +596,7 @@ describe('NoteDialogComponent', () => { }; env = new TestEnvironment({ noteThread, biblicalTerm }); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('dataid01'); + const noteThreadDoc: NoteThreadDoc = await env.getNoteThreadDoc('dataid01'); expect(noteThreadDoc.data!.notes.length).toEqual(5); expect(noteThreadDoc.data!.extraHeadingInfo).not.toBeNull(); expect(env.verseRef).toEqual('Biblical Term'); @@ -1036,8 +1033,8 @@ class TestEnvironment { when(mockedUserService.currentUserId).thenReturn(currentUserId); firstValueFrom(this.dialogRef.afterClosed()).then(result => (this.dialogResult = result)); - when(mockedUserService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(UserProfileDoc.COLLECTION, id) + when(mockedUserService.getProfile(anything(), anything())).thenCall( + async (id, subscriber) => await this.realtimeService.get(UserProfileDoc.COLLECTION, id, subscriber) ); when(mockedDialogService.confirm(anything(), anything())).thenResolve(true); @@ -1111,7 +1108,7 @@ class TestEnvironment { closeDialog(): void { this.overlayContainerElement.query(By.css('.close-button')).nativeElement.click(); - tick(matDialogCloseDelay); + flush(); } clickEditNote(): void { @@ -1132,14 +1129,18 @@ class TestEnvironment { this.fixture.detectChanges(); } - getNoteThreadDoc(threadDataId: string): NoteThreadDoc { + async getNoteThreadDoc(threadDataId: string): Promise { const id: string = getNoteThreadDocId(TestEnvironment.PROJECT01, threadDataId); - return this.realtimeService.get(NoteThreadDoc.COLLECTION, id); + return await this.realtimeService.get(NoteThreadDoc.COLLECTION, id, new DocSubscription('spec')); } - getProjectUserConfigDoc(projectId: string, userId: string): SFProjectUserConfigDoc { + async getProjectUserConfigDoc(projectId: string, userId: string): Promise { const id: string = getSFProjectUserConfigDocId(projectId, userId); - return this.realtimeService.get(SFProjectUserConfigDoc.COLLECTION, id); + return await this.realtimeService.get( + SFProjectUserConfigDoc.COLLECTION, + id, + new DocSubscription('spec') + ); } getNoteContent(noteNumber: number): string { @@ -1162,7 +1163,7 @@ class TestEnvironment { submit(): void { this.saveButton.nativeElement.click(); - tick(matDialogCloseDelay); + flush(); } toggleSegmentButton(): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.ts index 896968d6ee5..00c6329415d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/note-dialog/note-dialog.component.ts @@ -1,6 +1,6 @@ import { CdkScrollable } from '@angular/cdk/scrolling'; import { NgClass, NgStyle } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, DestroyRef, Inject, OnInit } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; @@ -35,6 +35,7 @@ import { isParatextRole } from 'realtime-server/lib/esm/scriptureforge/models/sf import { toVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import { DialogService } from 'xforge-common/dialog.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserProfileDoc } from 'xforge-common/models/user-profile-doc'; import { UserService } from 'xforge-common/user.service'; import { BiblicalTermDoc } from '../../../core/models/biblical-term-doc'; @@ -128,28 +129,45 @@ export class NoteDialogComponent implements OnInit { private readonly dialogService: DialogService, private readonly i18n: I18nService, private readonly projectService: SFProjectService, - private readonly userService: UserService + private readonly userService: UserService, + private readonly destroyRef: DestroyRef ) {} async ngOnInit(): Promise { // This can be refactored so the asynchronous calls are done in parallel if (this.threadDataId == null) { - this.textDoc = await this.projectService.getText(this.textDocId); + this.textDoc = await this.projectService.getText( + this.textDocId, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); } else { - this.threadDoc = await this.projectService.getNoteThread(this.projectId + ':' + this.threadDataId); - this.textDoc = await this.projectService.getText(this.textDocId); + this.threadDoc = await this.projectService.getNoteThread( + this.projectId + ':' + this.threadDataId, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); + this.textDoc = await this.projectService.getText( + this.textDocId, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); } if (this.biblicalTermId != null) { - this.biblicalTermDoc = await this.projectService.getBiblicalTerm(this.projectId + ':' + this.biblicalTermId); + this.biblicalTermDoc = await this.projectService.getBiblicalTerm( + this.projectId + ':' + this.biblicalTermId, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); } - this.projectProfileDoc = await this.projectService.getProfile(this.projectId); + this.projectProfileDoc = await this.projectService.getProfile( + this.projectId, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); this.userRole = this.projectProfileDoc?.data?.userRoles[this.userService.currentUserId]; if (this.userRole != null) { const projectDoc: SFProjectDoc | undefined = await this.projectService.tryGetForRole( this.projectId, - this.userRole + this.userRole, + new DocSubscription('NoteDialogComponent', this.destroyRef) ); if (this.threadDoc != null && projectDoc != null && projectDoc.data?.paratextUsers != null) { this.paratextProjectUsers = projectDoc.data.paratextUsers; @@ -486,7 +504,10 @@ export class NoteDialogComponent implements OnInit { */ private async getNoteUserNameAsync(note: Note): Promise { // Get the owner. This is often the project admin if the sync user is not in SF - const ownerDoc: UserProfileDoc = await this.userService.getProfile(note.ownerRef); + const ownerDoc: UserProfileDoc = await this.userService.getProfile( + note.ownerRef, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); // Get the sync user, if we have a syncUserRef for the note const syncUser: ParatextUserProfile | undefined = @@ -509,7 +530,12 @@ export class NoteDialogComponent implements OnInit { // The note was created in Paratext, so see if we have a profile for the sync user const syncUserProfile: UserProfileDoc | undefined = - syncUser.sfUserId == null ? undefined : await this.userService.getProfile(syncUser.sfUserId); + syncUser.sfUserId == null + ? undefined + : await this.userService.getProfile( + syncUser.sfUserId, + new DocSubscription('NoteDialogComponent', this.destroyRef) + ); return this.userService.currentUserId === syncUserProfile?.id ? this.i18n.translateStatic('checking.me') // "Me", i.e. the current user : (syncUserProfile?.data?.displayName ?? syncUser.username); // Another user, or fallback to the sync user diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.spec.ts index 5c652a8f28a..7b22c8b61db 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.spec.ts @@ -2,6 +2,7 @@ import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models import { of } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; +import { noopDestroyRef } from 'xforge-common/realtime.service'; import { SFProjectDoc } from '../../../core/models/sf-project-doc'; import { PermissionsService } from '../../../core/permissions.service'; import { SFProjectService } from '../../../core/sf-project.service'; @@ -25,7 +26,8 @@ describe('EditorTabAddRequestService', () => { instance(dialogService), instance(projectService), instance(permissionsService), - instance(tabStateService) + instance(tabStateService), + noopDestroyRef ); }); @@ -67,8 +69,8 @@ describe('EditorTabAddRequestService', () => { const projectDoc2 = createTestProjectDoc(2); when(tabStateService.tabs$).thenReturn(of([{ projectId: projectDoc1.id }, {}, { projectId: projectDoc2.id }])); - when(projectService.get(projectDoc1.id)).thenResolve(projectDoc1); - when(projectService.get(projectDoc2.id)).thenResolve(projectDoc2); + when(projectService.subscribe(projectDoc1.id, anything())).thenResolve(projectDoc1); + when(projectService.subscribe(projectDoc2.id, anything())).thenResolve(projectDoc2); when(permissionsService.isUserOnProject(anything())).thenResolve(true); service['getParatextIdsForOpenTabs']().subscribe(result => { @@ -82,13 +84,13 @@ describe('EditorTabAddRequestService', () => { const projectDoc2 = createTestProjectDoc(2); when(tabStateService.tabs$).thenReturn(of([{ projectId: projectDoc1.id }, {}, { projectId: projectDoc2.id }])); - when(projectService.get(projectDoc1.id)).thenResolve(projectDoc1); + when(projectService.subscribe(projectDoc1.id, anything())).thenResolve(projectDoc1); when(permissionsService.isUserOnProject(anything())).thenResolve(true); when(permissionsService.isUserOnProject(projectDoc2.id)).thenResolve(false); service['getParatextIdsForOpenTabs']().subscribe(result => { expect(result).toEqual([projectDoc1.data!.paratextId]); - verify(projectService.get(projectDoc2.id)).never(); + verify(projectService.subscribe(projectDoc2.id, anything())).never(); done(); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.ts index 588f3545fcb..fadfc05d891 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-request.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { EditorTabGroupType, EditorTabType } from 'realtime-server/lib/esm/scriptureforge/models/editor-tab'; import { map, Observable, of, switchMap, take } from 'rxjs'; import { DialogService } from 'xforge-common/dialog.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { SFProjectDoc } from '../../../core/models/sf-project-doc'; import { PermissionsService } from '../../../core/permissions.service'; @@ -21,7 +22,8 @@ export class EditorTabAddRequestService implements TabAddRequestService + private readonly tabState: TabStateService, + private readonly destroyRef: DestroyRef ) {} handleTabAddRequest(tabType: EditorTabType): Observable | never> { @@ -47,7 +49,10 @@ export class EditorTabAddRequestService implements TabAddRequestService tab.projectId != null && (await this.permissionsService.isUserOnProject(tab.projectId)) - ? this.projectService.get(tab.projectId) + ? this.projectService.subscribe( + tab.projectId, + new DocSubscription('EditorTabAddRequestService', this.destroyRef) + ) : undefined ) ) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.spec.ts index 6f080121964..a9c0ee7693d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.spec.ts @@ -252,7 +252,9 @@ class TestEnvironment { when(mockSFProjectService.onlineCreateResourceProject(this.paratextId)).thenCall(() => Promise.resolve(this.testProjectDoc.id) ); - when(mockSFProjectService.get(this.projectId)).thenCall(() => Promise.resolve(this.testProjectDoc)); + when(mockSFProjectService.subscribe(this.projectId, anything())).thenCall(() => + Promise.resolve(this.testProjectDoc) + ); when(mockSFProjectService.onlineSync(this.projectId)).thenResolve(); this.fixture = TestBed.createComponent(EditorTabAddResourceDialogComponent); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.ts index c3d58ce674d..833ac9f80f7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-add-resource-dialog/editor-tab-add-resource-dialog.component.ts @@ -14,6 +14,7 @@ import { MatError } from '@angular/material/form-field'; import { MatProgressBar } from '@angular/material/progress-bar'; import { TranslocoModule } from '@ngneat/transloco'; import { map, repeat, take, timer } from 'rxjs'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { ParatextProject } from '../../../../core/models/paratext-project'; @@ -115,14 +116,24 @@ export class EditorTabAddResourceDialogComponent implements OnInit { await this.projectService.onlineAddCurrentUser(project.projectId); } this.selectedProjectDoc = - project?.projectId != null ? await this.projectService.get(project.projectId) : undefined; + project?.projectId != null + ? await this.projectService.subscribe( + project.projectId, + new DocSubscription('EditorTabAddResourceDialogComponent', this.destroyRef) + ) + : undefined; } else { // Load the project or resource, creating it if it is not present const projectId: string | undefined = this.appOnline ? await this.projectService.onlineCreateResourceProject(paratextId) : undefined; this.selectedProjectDoc = - projectId != null && this.appOnline ? await this.projectService.get(projectId) : undefined; + projectId != null && this.appOnline + ? await this.projectService.subscribe( + projectId, + new DocSubscription('EditorTabAddResourceDialogComponent', this.destroyRef) + ) + : undefined; } if (this.selectedProjectDoc != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.spec.ts index 8dc9d99e4da..516082c2f10 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.spec.ts @@ -3,8 +3,9 @@ import { cloneDeep } from 'lodash-es'; import { EditorTabPersistData } from 'realtime-server/lib/esm/scriptureforge/models/editor-tab-persist-data'; import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config-test-data'; import { of, Subject } from 'rxjs'; -import { instance, mock, when } from 'ts-mockito'; +import { anything, instance, mock, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { noopDestroyRef } from 'xforge-common/realtime.service'; import { UserService } from 'xforge-common/user.service'; import { SFProjectUserConfigDoc } from '../../../core/models/sf-project-user-config-doc'; import { SFProjectService } from '../../../core/sf-project.service'; @@ -81,12 +82,13 @@ class TestEnvironment { when(this.mockActivatedProjectService.projectId$).thenReturn(of('project01')); when(this.mockUserService.currentUserId).thenReturn('user01'); - when(this.mockProjectService.getUserConfig('project01', 'user01')).thenResolve(this.pucDoc); + when(this.mockProjectService.getUserConfig('project01', 'user01', anything())).thenResolve(this.pucDoc); this.service = new EditorTabPersistenceService( instance(this.mockActivatedProjectService), instance(this.mockUserService), - instance(this.mockProjectService) + instance(this.mockProjectService), + noopDestroyRef ); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.ts index 36d323572e3..90967793779 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-persistence.service.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { isEqual, isUndefined, omitBy } from 'lodash-es'; import { editorTabTypes } from 'realtime-server/lib/esm/scriptureforge/models/editor-tab'; import { EditorTabPersistData } from 'realtime-server/lib/esm/scriptureforge/models/editor-tab-persist-data'; -import { Observable, Subject, Subscription, combineLatest, firstValueFrom, of, startWith, switchMap, tap } from 'rxjs'; +import { combineLatest, firstValueFrom, Observable, of, startWith, Subject, Subscription, switchMap, tap } from 'rxjs'; import { distinctUntilChanged, finalize, shareReplay } from 'rxjs/operators'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserService } from 'xforge-common/user.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { SFProjectUserConfigDoc } from '../../../core/models/sf-project-user-config-doc'; @@ -27,7 +28,8 @@ export class EditorTabPersistenceService { constructor( private readonly activatedProject: ActivatedProjectService, private readonly userService: UserService, - private readonly projectService: SFProjectService + private readonly projectService: SFProjectService, + private readonly destroyRef: DestroyRef ) {} /** @@ -56,7 +58,13 @@ export class EditorTabPersistenceService { this.activatedProject.projectId$.pipe(filterNullish()), pucChanged$.pipe(startWith(undefined)) ]).pipe( - switchMap(([projectId]) => this.projectService.getUserConfig(projectId, this.userService.currentUserId)), + switchMap(([projectId]) => + this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('EditorTabPersistenceService', this.destroyRef) + ) + ), tap(pucDoc => { pucChangesSub?.unsubscribe(); pucChangesSub = pucDoc.changes$.subscribe(() => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translate-metrics-session.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translate-metrics-session.spec.ts index 99d9986fccb..f8e77017cd3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translate-metrics-session.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translate-metrics-session.spec.ts @@ -10,6 +10,7 @@ import { firstValueFrom } from 'rxjs'; import { anything, deepEqual, instance, mock, objectContaining, resetCalls, verify, when } from 'ts-mockito'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -473,15 +474,15 @@ class TestEnvironment { }) }); - when(mockedSFProjectService.getText(anything())).thenCall(id => - this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString()) + when(mockedSFProjectService.getText(anything(), anything())).thenCall((id, subscriber) => + this.realtimeService.subscribe(TextDoc.COLLECTION, id.toString(), subscriber) ); - when(mockedSFProjectService.onlineAddTranslateMetrics('project01', anything())).thenResolve(); - when(mockedSFProjectService.getProfile(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id.toString()) + when(mockedSFProjectService.getProfile(anything(), anything())).thenCall( + async (id, subscriber) => + await this.realtimeService.get(SFProjectProfileDoc.COLLECTION, id.toString(), subscriber) ); when(mockedUserService.getCurrentUser()).thenCall(() => - this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01') + this.realtimeService.subscribe(UserDoc.COLLECTION, 'user01', new DocSubscription('spec')) ); this.sourceFixture = TestBed.createComponent(TextComponent); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translator-settings-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translator-settings-dialog.component.spec.ts index cad831e9676..6514eff25a9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translator-settings-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/translator-settings-dialog.component.spec.ts @@ -16,6 +16,7 @@ import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config-test-data'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; @@ -47,21 +48,21 @@ describe('TranslatorSettingsDialogComponent', () => { ] })); - it('update confidence threshold', fakeAsync(() => { + it('update confidence threshold', fakeAsync(async () => { const env = new TestEnvironment(); - env.openDialog(); + await env.openDialog(); expect(env.component!.confidenceThreshold$.value).toEqual(50); env.updateConfidenceThresholdSlider(60); expect(env.component!.confidenceThreshold$.value).toEqual(60); - const userConfigDoc = env.getProjectUserConfigDoc(); + const userConfigDoc = await env.getProjectUserConfigDoc(); expect(userConfigDoc.data!.confidenceThreshold).toEqual(0.6); env.closeDialog(); })); it('update suggestions enabled', fakeAsync(async () => { const env = new TestEnvironment(); - env.openDialog(); + await env.openDialog(); expect(env.component!['translationSuggestionsUserEnabled']).toBe(true); const suggestionsToggle = await env.getSuggestionsEnabledToggle(); @@ -72,33 +73,33 @@ describe('TranslatorSettingsDialogComponent', () => { expect(env.component!['translationSuggestionsUserEnabled']).toBe(false); expect(await env.isToggleChecked(suggestionsToggle!)).toBe(false); - const userConfigDoc = env.getProjectUserConfigDoc(); + const userConfigDoc = await env.getProjectUserConfigDoc(); expect(userConfigDoc.data!.translationSuggestionsEnabled).toBe(false); env.closeDialog(); })); - it('update num suggestions', fakeAsync(() => { + it('update num suggestions', fakeAsync(async () => { const env = new TestEnvironment(); - env.openDialog(); + await env.openDialog(); expect(env.component!.numSuggestions).toEqual('1'); env.changeSelectValue(env.numSuggestionsSelect, 2); expect(env.component!.numSuggestions).toEqual('2'); - const userConfigDoc = env.getProjectUserConfigDoc(); + const userConfigDoc = await env.getProjectUserConfigDoc(); expect(userConfigDoc.data!.numSuggestions).toEqual(2); env.closeDialog(); })); - it('shows correct confidence threshold even when suggestions disabled', fakeAsync(() => { + it('shows correct confidence threshold even when suggestions disabled', fakeAsync(async () => { const env = new TestEnvironment({ translationSuggestionsEnabled: false }); - env.openDialog(); + await env.openDialog(); expect(env.component?.confidenceThreshold$.value).toEqual(50); env.closeDialog(); })); - it('disables settings when offline', fakeAsync(() => { + it('disables settings when offline', fakeAsync(async () => { const env = new TestEnvironment(); - env.openDialog(); + await env.openDialog(); expect(env.offlineAppNotice == null).toBeTrue(); expect(env.suggestionsEnabledCheckbox.disabled).toBe(false); @@ -116,7 +117,7 @@ describe('TranslatorSettingsDialogComponent', () => { it('should hide translation suggestions section when project has translation suggestions disabled', fakeAsync(async () => { const env = new TestEnvironment(); - const projectDoc = env.getProjectProfileDoc(); + const projectDoc = await env.getProjectProfileDoc(); env.setupProject({ userConfig: { @@ -128,7 +129,7 @@ describe('TranslatorSettingsDialogComponent', () => { }); env.fixture.detectChanges(); - env.openDialog(); + await env.openDialog(); expect(env.component!.showSuggestionsSettings).toBe(false); expect(env.suggestionsSection == null).toBeTrue(); @@ -137,7 +138,7 @@ describe('TranslatorSettingsDialogComponent', () => { it('should show translation suggestions section when project has translation suggestions enabled', fakeAsync(async () => { const env = new TestEnvironment(); - env.openDialog(); + await env.openDialog(); expect(env.component!.showSuggestionsSettings).toBe(true); expect(env.suggestionsSection == null).toBeFalse(); @@ -147,7 +148,7 @@ describe('TranslatorSettingsDialogComponent', () => { it('the suggestions toggle is switched on when the dialog opens while offline', fakeAsync(async () => { const env = new TestEnvironment(); env.isOnline = false; - env.openDialog(); + await env.openDialog(); const suggestionsToggle = await env.getSuggestionsEnabledToggle(); expect(await env.isToggleDisabled(suggestionsToggle!)).toBe(true); @@ -156,7 +157,7 @@ describe('TranslatorSettingsDialogComponent', () => { })); describe('Lynx Settings', () => { - it('should show Lynx settings when both project features are enabled', fakeAsync(() => { + it('should show Lynx settings when both project features are enabled', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProject({ projectConfig: { @@ -168,7 +169,7 @@ describe('TranslatorSettingsDialogComponent', () => { } } }); - env.openDialog(); + await env.openDialog(); expect(env.lynxSettingsSection == null).toBeFalse(); expect(env.lynxMasterSwitch == null).toBeFalse(); @@ -177,7 +178,7 @@ describe('TranslatorSettingsDialogComponent', () => { env.closeDialog(); })); - it('should hide Lynx settings when project features are disabled', fakeAsync(() => { + it('should hide Lynx settings when project features are disabled', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProject({ projectConfig: { @@ -189,13 +190,13 @@ describe('TranslatorSettingsDialogComponent', () => { } } }); - env.openDialog(); + await env.openDialog(); expect(env.lynxSettingsSection == null).toBeTrue(); env.closeDialog(); })); - it('should show only assessments switch when only assessments is enabled in project', fakeAsync(() => { + it('should show only assessments switch when only assessments is enabled in project', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProject({ projectConfig: { @@ -207,7 +208,7 @@ describe('TranslatorSettingsDialogComponent', () => { } } }); - env.openDialog(); + await env.openDialog(); expect(env.lynxSettingsSection == null).toBeFalse(); expect(env.lynxMasterSwitch == null).toBeFalse(); @@ -216,7 +217,7 @@ describe('TranslatorSettingsDialogComponent', () => { env.closeDialog(); })); - it('should show only auto-corrections switch when only auto-corrections is enabled in project', fakeAsync(() => { + it('should show only auto-corrections switch when only auto-corrections is enabled in project', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProject({ projectConfig: { @@ -228,7 +229,7 @@ describe('TranslatorSettingsDialogComponent', () => { } } }); - env.openDialog(); + await env.openDialog(); expect(env.lynxSettingsSection == null).toBeFalse(); expect(env.lynxMasterSwitch == null).toBeFalse(); @@ -255,7 +256,7 @@ describe('TranslatorSettingsDialogComponent', () => { } } }); - env.openDialog(); + await env.openDialog(); const lynxMasterToggle = await env.getLynxMasterToggle(); expect(lynxMasterToggle).not.toBeNull(); @@ -266,7 +267,7 @@ describe('TranslatorSettingsDialogComponent', () => { expect(env.component!.lynxMasterSwitch.value).toBe(false); expect(await env.isToggleChecked(lynxMasterToggle!)).toBe(false); - const userConfigDoc = env.getProjectUserConfigDoc(); + const userConfigDoc = await env.getProjectUserConfigDoc(); expect(userConfigDoc.data!.lynxInsightState?.autoCorrectionsEnabled).toBe(false); expect(userConfigDoc.data!.lynxInsightState?.assessmentsEnabled).toBe(false); env.closeDialog(); @@ -285,7 +286,7 @@ describe('TranslatorSettingsDialogComponent', () => { } }); env.isOnline = false; - env.openDialog(); + await env.openDialog(); const lynxMasterToggle = await env.getLynxMasterToggle(); const lynxAssessmentsToggle = await env.getLynxAssessmentsToggle(); @@ -396,23 +397,21 @@ class TestEnvironment { tick(matDialogCloseDelay); } - openDialog(): void { - this.realtimeService - .subscribe( - SF_PROJECT_USER_CONFIGS_COLLECTION, - getSFProjectUserConfigDocId('project01', 'user01') - ) - .then(projectUserConfigDoc => { - const viewContainerRef = this.fixture.componentInstance.childViewContainer; - const projectDoc = this.getProjectProfileDoc(); - const config: MatDialogConfig = { - data: { projectDoc, projectUserConfigDoc }, - viewContainerRef - }; - const dialogRef = TestBed.inject(MatDialog).open(TranslatorSettingsDialogComponent, config); - this.component = dialogRef.componentInstance; - this.loader = TestbedHarnessEnvironment.documentRootLoader(this.fixture); - }); + async openDialog(): Promise { + const projectUserConfigDoc = await this.realtimeService.subscribe( + SF_PROJECT_USER_CONFIGS_COLLECTION, + getSFProjectUserConfigDocId('project01', 'user01'), + new DocSubscription('spec') + ); + const viewContainerRef = this.fixture.componentInstance.childViewContainer; + const projectDoc = await this.getProjectProfileDoc(); + const config: MatDialogConfig = { + data: { projectDoc, projectUserConfigDoc }, + viewContainerRef + }; + const dialogRef = TestBed.inject(MatDialog).open(TranslatorSettingsDialogComponent, config); + this.component = dialogRef.componentInstance; + this.loader = TestbedHarnessEnvironment.documentRootLoader(this.fixture); this.wait(); } @@ -476,14 +475,19 @@ class TestEnvironment { }); } - getProjectProfileDoc(): SFProjectProfileDoc { - return this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); + async getProjectProfileDoc(): Promise { + return await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); } - getProjectUserConfigDoc(): SFProjectUserConfigDoc { - return this.realtimeService.get( + async getProjectUserConfigDoc(): Promise { + return await this.realtimeService.get( SFProjectUserConfigDoc.COLLECTION, - getSFProjectUserConfigDocId('project01', 'user01') + getSFProjectUserConfigDocId('project01', 'user01'), + new DocSubscription('spec') ); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.ts index bbcf6355eab..386bfad707b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.ts @@ -9,6 +9,7 @@ import { filter, repeat, retry, tap } from 'rxjs/operators'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DonutChartComponent } from 'xforge-common/donut-chart/donut-chart.component'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { UserService } from 'xforge-common/user.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -63,7 +64,10 @@ export class TrainingProgressComponent extends DataLoadingComponent implements O if (this.projectDoc == null || projectId !== this._projectId) { this.loadingStarted(); try { - this.projectDoc = await this.projectService.getProfile(projectId); + this.projectDoc = await this.projectService.getProfile( + projectId, + new DocSubscription('TrainingProgressComponent', this.destroyRef) + ); this.setupTranslationEngine(); } finally { this.loadingFinished(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts index 20e8f74c6a8..96bc4ab4f1c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts @@ -18,6 +18,7 @@ import { defer, of, Subject } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { AuthService } from 'xforge-common/auth.service'; import { L10nPercentPipe } from 'xforge-common/l10n-percent.pipe'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -135,13 +136,13 @@ describe('TranslateOverviewComponent', () => { discardPeriodicTasks(); })); - it('should start training engine if not initially enabled', fakeAsync(() => { + it('should start training engine if not initially enabled', fakeAsync(async () => { const env = new TestEnvironment({ translationSuggestionsEnabled: false }); env.wait(); verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).never(); expect(env.retrainButton).toBeNull(); - env.simulateTranslateSuggestionsEnabled(); + await env.simulateTranslateSuggestionsEnabled(); verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); expect(env.retrainButton).toBeTruthy(); @@ -224,12 +225,12 @@ describe('TranslateOverviewComponent', () => { discardPeriodicTasks(); })); - it('should not create engine if no source text docs', fakeAsync(() => { + it('should not create engine if no source text docs', fakeAsync(async () => { const env = new TestEnvironment({ translationSuggestionsEnabled: false }); when(mockedTranslationEngineService.checkHasSourceBooks(anything())).thenReturn(false); verify(mockedTranslationEngineService.createTranslationEngine(anything())).never(); expect(env.translationSuggestionsInfoMessage).toBeFalsy(); - env.simulateTranslateSuggestionsEnabled(true); + await env.simulateTranslateSuggestionsEnabled(true); verify(mockedTranslationEngineService.createTranslationEngine(anything())).never(); expect(env.translationSuggestionsInfoMessage).toBeTruthy(); env.clickRetrainButton(); @@ -347,7 +348,9 @@ class TestEnvironment { setCurrentUser(userId: string = 'user01'): void { when(mockedUserService.currentUserId).thenReturn(userId); - when(mockedUserService.getCurrentUser()).thenCall(() => this.realtimeService.subscribe(UserDoc.COLLECTION, userId)); + when(mockedUserService.getCurrentUser()).thenCall( + async () => await this.realtimeService.subscribe(UserDoc.COLLECTION, userId, new DocSubscription('spec')) + ); } wait(): void { @@ -559,18 +562,26 @@ class TestEnvironment { this.fixture.detectChanges(); } - addVerse(bookNum: number, chapter: number): void { + async addVerse(bookNum: number, chapter: number): Promise { const delta = new Delta(); delta.insert(`chapter ${chapter}, verse 22.`, { segment: `verse_${chapter}_22` }); - const textDoc = this.realtimeService.get(TextDoc.COLLECTION, getTextDocId('project01', bookNum, chapter)); - textDoc.submit({ ops: delta.ops }); + const textDoc = await this.realtimeService.get( + TextDoc.COLLECTION, + getTextDocId('project01', bookNum, chapter), + new DocSubscription('spec') + ); + await textDoc.submit({ ops: delta.ops }); this.waitForProjectDocChanges(); } - simulateTranslateSuggestionsEnabled(enabled: boolean = true): void { - const projectDoc: SFProjectProfileDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, 'project01'); - projectDoc.submitJson0Op( + async simulateTranslateSuggestionsEnabled(enabled: boolean = true): Promise { + const projectDoc: SFProjectProfileDoc = await this.realtimeService.get( + SFProjectProfileDoc.COLLECTION, + 'project01', + new DocSubscription('spec') + ); + await projectDoc.submitJson0Op( op => op.set(p => p.translateConfig.translationSuggestionsEnabled, enabled), false ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.ts index 57e046ac7cc..8420ddb6981 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.ts @@ -31,6 +31,7 @@ import { DonutChartComponent } from 'xforge-common/donut-chart/donut-chart.compo import { I18nService } from 'xforge-common/i18n.service'; import { L10nNumberPipe } from 'xforge-common/l10n-number.pipe'; import { L10nPercentPipe } from 'xforge-common/l10n-percent.pipe'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { RouterLinkDirective } from 'xforge-common/router-link.directive'; @@ -155,7 +156,10 @@ export class TranslateOverviewComponent extends DataLoadingComponent implements quietTakeUntilDestroyed(this.destroyRef) ) .subscribe(async projectId => { - this.projectDoc = await this.projectService.getProfile(projectId); + this.projectDoc = await this.projectService.getProfile( + projectId, + new DocSubscription('TranslateOverviewComponent', this.destroyRef) + ); // Update the overview now if we are online, or when we are next online void this.onlineStatusService.online.then(async () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts index 8469c7341cf..9675b5e00d1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts @@ -18,6 +18,7 @@ import { AvatarComponent } from 'xforge-common/avatar/avatar.component'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { DialogService } from 'xforge-common/dialog.service'; import { NONE_ROLE, ProjectRoleInfo } from 'xforge-common/models/project-role-info'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { UserProfileDoc } from 'xforge-common/models/user-profile-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -348,7 +349,7 @@ describe('CollaboratorsComponent', () => { env.cleanup(); })); - it('should enable editing roles and permissions for non-admins', fakeAsync(() => { + it('should enable editing roles and permissions for non-admins', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData(); env.fixture.detectChanges(); @@ -358,10 +359,10 @@ describe('CollaboratorsComponent', () => { env.clickElement(env.userRowMoreMenuElement(1, UserType.Paratext)); expect(env.rolesAndPermissionsItem().nativeElement.disabled).toBe(false); - env.cleanup(); + await env.cleanup(); })); - it('should disable editing roles and permissions for admins', fakeAsync(() => { + it('should disable editing roles and permissions for admins', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData(); env.fixture.detectChanges(); @@ -371,10 +372,10 @@ describe('CollaboratorsComponent', () => { env.clickElement(env.userRowMoreMenuElement(0, UserType.Paratext)); expect(env.rolesAndPermissionsItem().nativeElement.disabled).toBe(true); - env.cleanup(); + await env.cleanup(); })); - it('should disable editing roles and permissions for pending invitees', fakeAsync(() => { + it('should disable editing roles and permissions for pending invitees', fakeAsync(async () => { const env = new TestEnvironment(); env.setupProjectData(); when(mockedProjectService.onlineInvitedUsers(env.project01Id)).thenResolve([ @@ -391,7 +392,7 @@ describe('CollaboratorsComponent', () => { env.clickElement(env.userRowMoreMenuElement(inviteeRow + 1, UserType.Guest)); expect(env.rolesAndPermissionsItem().nativeElement.disabled).toBe(true); - env.cleanup(); + await env.cleanup(); })); }); @@ -416,22 +417,26 @@ class TestEnvironment { when(mockedProjectService.onlineInvite(this.project01Id, anything(), anything(), anything())).thenResolve(); when(mockedProjectService.onlineInvitedUsers(this.project01Id)).thenResolve([]); when(mockedNoticeService.show(anything())).thenResolve(); - when(mockedUserService.getProfile(anything())).thenCall(userId => - this.realtimeService.subscribe(UserProfileDoc.COLLECTION, userId) + when(mockedUserService.getProfile(anything(), anything())).thenCall( + async (userId, subscription) => await this.realtimeService.get(UserProfileDoc.COLLECTION, userId, subscription) ); when(mockedUserService.currentUserId).thenReturn('user01'); - when(mockedProjectService.get(anything())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId) + when(mockedProjectService.subscribe(anything(), anything())).thenCall((projectId, subscription) => + this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId, subscription) ); - when(mockedProjectService.getProfile(anything())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId) + when(mockedProjectService.getProfile(anything(), anything())).thenCall((projectId, subscriber) => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, projectId, subscriber) ); when( mockedProjectService.onlineGetLinkSharingKey(this.project01Id, anything(), anything(), anything()) ).thenResolve('linkSharingKey01'); when(mockedProjectService.onlineSetUserProjectPermissions(this.project01Id, 'user02', anything())).thenCall( - (projectId: string, userId: string, permissions: string[]) => { - const projectDoc: SFProjectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, projectId); + async (projectId: string, userId: string, permissions: string[]) => { + const projectDoc: SFProjectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + projectId, + new DocSubscription('spec') + ); return projectDoc.submitJson0Op(op => op.set(p => p.userPermissions[userId], permissions)); } ); @@ -558,19 +563,22 @@ class TestEnvironment { this.setupThisProjectData(this.project01Id, this.createProject(userRoles)); } - updateCheckingProperties(config: CheckingConfig): Promise { - const projectDoc: SFProjectDoc = this.realtimeService.get(SFProjectDoc.COLLECTION, this.project01Id); + async updateCheckingProperties(config: CheckingConfig): Promise { + const projectDoc: SFProjectDoc = await this.realtimeService.get( + SFProjectDoc.COLLECTION, + this.project01Id, + new DocSubscription('spec') + ); return projectDoc.submitJson0Op(op => { op.set(p => p.checkingConfig, config); }); } - cleanup(): void { - this.loader.getAllHarnesses(MatMenuHarness).then(harnesses => { - for (const harness of harnesses) { - harness.close(); - } - }); + async cleanup(): Promise { + const harnesses = await this.loader.getAllHarnesses(MatMenuHarness); + for (const harness of harnesses) { + harness.close(); + } flush(); this.fixture.detectChanges(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts index ce40f4b6aff..22e78632903 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts @@ -18,6 +18,7 @@ import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { ExternalUrlService } from 'xforge-common/external-url.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; @@ -146,7 +147,10 @@ export class CollaboratorsComponent extends DataLoadingComponent implements OnIn quietTakeUntilDestroyed(this.destroyRef) ) .subscribe(async ([_, projectId]) => { - this.projectDoc = await this.projectService.get(projectId!); + this.projectDoc = await this.projectService.subscribe( + projectId!, + new DocSubscription('CollaboratorsComponent', this.destroyRef) + ); this.projectDoc.changes$.subscribe(() => this.loadUsers$.next()); this.loadUsers$.next(); }); @@ -208,7 +212,11 @@ export class CollaboratorsComponent extends DataLoadingComponent implements OnIn } const userIds = Object.keys(project.userRoles); - const userProfiles = await Promise.all(userIds.map(userId => this.userService.getProfile(userId))); + const userProfiles = await Promise.all( + userIds.map(userId => + this.userService.getProfile(userId, new DocSubscription('CollaboratorsComponent', this.destroyRef)) + ) + ); const userRows: Row[] = []; const otherParatextMemberRows: Row[] = []; const inviteeRows: Row[] = []; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.component.ts index abbd63a9b97..7304174ae3f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.component.ts @@ -21,6 +21,7 @@ import { Subscription } from 'rxjs'; import { AvatarComponent } from 'xforge-common/avatar/avatar.component'; import { ExternalUrlService } from 'xforge-common/external-url.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectDoc } from '../../core/models/sf-project-doc'; @@ -72,16 +73,19 @@ export class RolesAndPermissionsDialogComponent implements OnInit { @Inject(MAT_DIALOG_DATA) public readonly data: UserData, public readonly urls: ExternalUrlService, public readonly i18n: I18nService, - private readonly onlineService: OnlineStatusService, + private readonly onlineStatusService: OnlineStatusService, private readonly projectService: SFProjectService, private readonly destroyRef: DestroyRef ) {} async ngOnInit(): Promise { - this.projectDoc = await this.projectService.get(this.data.projectId); + this.projectDoc = await this.projectService.subscribe( + this.data.projectId, + new DocSubscription('RolesAndPermissionsDialogComponent', this.destroyRef) + ); this.onlineSubscription?.unsubscribe(); - this.onlineSubscription = this.onlineService.onlineStatus$ + this.onlineSubscription = this.onlineStatusService.onlineStatus$ .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(isOnline => this.updateFormEditability(isOnline)); @@ -121,7 +125,10 @@ export class RolesAndPermissionsDialogComponent implements OnInit { const selectedRole = this.roles.value; await this.projectService.onlineUpdateUserRole(this.data.projectId, this.data.userId, selectedRole); - this.projectDoc = await this.projectService.get(this.data.projectId); + this.projectDoc = await this.projectService.subscribe( + this.data.projectId, + new DocSubscription('RolesAndPermissionsDialogComponent', this.destroyRef) + ); const permissions = new Set((this.projectDoc?.data?.userPermissions ?? {})[this.data.userId] ?? []); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.spec.ts index 66cba45b438..77845286735 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/roles-and-permissions/roles-and-permissions-dialog.spec.ts @@ -21,6 +21,7 @@ import { BehaviorSubject } from 'rxjs'; import { anything, deepEqual, mock, verify, when } from 'ts-mockito'; import { ExternalUrlService } from 'xforge-common/external-url.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; @@ -71,9 +72,9 @@ describe('RolesAndPermissionsComponent', () => { env.closeDialog(); })); - it('updates the form when the network connection changes', fakeAsync(() => { + it('updates the form when the network connection changes', fakeAsync(async () => { env.setupProjectData(rolesByUser); - env.openDialog(); + await env.openDialog(); expect(env.component?.isParatextUser()).toBe(true); expect(env.component?.roles.disabled).toBe(true); @@ -88,7 +89,7 @@ describe('RolesAndPermissionsComponent', () => { expect(env.component?.roles.disabled).toBe(true); })); - it('initializes values from the project', fakeAsync(() => { + it('initializes values from the project', fakeAsync(async () => { env.setupProjectData(rolesByUser, { ptTranslator: [ SF_PROJECT_RIGHTS.joinRight(SFProjectDomain.Questions, Operation.Create), @@ -98,7 +99,7 @@ describe('RolesAndPermissionsComponent', () => { SF_PROJECT_RIGHTS.joinRight(SFProjectDomain.TextAudio, Operation.Delete) ] }); - env.openDialog(); + await env.openDialog(); expect(env.component?.roles.value).toBe(SFProjectRole.ParatextTranslator); //translator does not have these permissions by default @@ -106,48 +107,48 @@ describe('RolesAndPermissionsComponent', () => { expect(env.component?.canManageAudio.value).toBe(true); })); - it('reflects whether or not the user is from Paratext', fakeAsync(() => { + it('reflects whether or not the user is from Paratext', fakeAsync(async () => { env.setupProjectData(rolesByUser); - env.openDialog(); + await env.openDialog(); expect(env.component?.isParatextUser()).toBe(true); env.closeDialog(); - env.openDialog('communityChecker'); + await env.openDialog('communityChecker'); expect(env.component?.isParatextUser()).toBe(false); })); - it('reflects Paratext roles for Paratext users', fakeAsync(() => { + it('reflects Paratext roles for Paratext users', fakeAsync(async () => { env.setupProjectData(rolesByUser); - env.openDialog(); + await env.openDialog(); expect(env.component?.roleOptions.length).toBeGreaterThan(0); forEach(env.component?.roleOptions, r => expect(isParatextRole(r)).toBe(true)); expect(env.component?.roles.disabled).toBe(true); })); - it('reflects Scripture Forge roles for Scripture Forge users', fakeAsync(() => { + it('reflects Scripture Forge roles for Scripture Forge users', fakeAsync(async () => { env.setupProjectData(rolesByUser); - env.openDialog('communityChecker'); + await env.openDialog('communityChecker'); expect(env.component?.roleOptions.length).toBeGreaterThan(0); forEach(env.component?.roleOptions, r => expect(!isParatextRole(r))); expect(env.component?.roles.disabled).toBe(false); })); - it('doesnt save if the form is disabled', fakeAsync(() => { + it('doesnt save if the form is disabled', fakeAsync(async () => { env.setupProjectData(); - env.openDialog(); + await env.openDialog(); env.isOnline$.next(false); expect(env.component?.form.disabled).toBe(true); - env.component?.save(); + await env.component?.save(); verify(mockedProjectService.onlineSetUserProjectPermissions(anything(), anything(), anything())).never(); })); - it('saves correct permissions without changing unrelated ones', fakeAsync(() => { + it('saves correct permissions without changing unrelated ones', fakeAsync(async () => { let permissions = [ SF_PROJECT_RIGHTS.joinRight(SFProjectDomain.Questions, Operation.View), SF_PROJECT_RIGHTS.joinRight(SFProjectDomain.Questions, Operation.Delete) @@ -156,12 +157,16 @@ describe('RolesAndPermissionsComponent', () => { ptTranslator: [SF_PROJECT_RIGHTS.joinRight(SFProjectDomain.Questions, Operation.Delete)], observer: permissions }); - env.openDialog('ptTranslator'); + await env.openDialog('ptTranslator'); //prep for role change - when(mockedProjectService.onlineUpdateUserRole(anything(), anything(), anything())).thenCall((p, u, r) => { + when(mockedProjectService.onlineUpdateUserRole(anything(), anything(), anything())).thenCall(async (p, u, r) => { rolesByUser[u] = r; - const projectDoc: SFProjectDoc = env.realtimeService.get(SFProjectDoc.COLLECTION, p); + const projectDoc: SFProjectDoc = await env.realtimeService.get( + SFProjectDoc.COLLECTION, + p, + new DocSubscription('spec') + ); projectDoc.submitJson0Op(op => { op.set(p => p.userRoles, rolesByUser); op.set(p => p.userPermissions, { @@ -173,7 +178,7 @@ describe('RolesAndPermissionsComponent', () => { env.component?.canAddEditQuestions.setValue(true); env.component?.canManageAudio.setValue(true); env.component?.roles.setValue(SFProjectRole.Viewer); - env.component?.save(); + await env.component?.save(); tick(); verify(mockedProjectService.onlineUpdateUserRole('project01', 'ptTranslator', SFProjectRole.Viewer)).once(); @@ -211,8 +216,8 @@ class TestEnvironment { constructor() { when(mockedOnlineStatusService.onlineStatus$).thenReturn(this.isOnline$.asObservable()); - when(mockedProjectService.get(anything())).thenCall(projectId => - this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId) + when(mockedProjectService.subscribe(anything(), anything())).thenCall((projectId, subscription) => + this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId, subscription) ); this.fixture = TestBed.createComponent(ChildViewContainerComponent); @@ -235,26 +240,24 @@ class TestEnvironment { tick(matDialogCloseDelay); } - openDialog(userId: string = 'ptTranslator'): void { - this.realtimeService - .subscribe( - SF_PROJECT_USER_CONFIGS_COLLECTION, - getSFProjectUserConfigDocId('project01', userId) - ) - .then(() => { - const config: MatDialogConfig = { - data: { - projectId: 'project01', - userId, - userProfile: { - displayName: 'User', - avatarUrl: '' - } - } - }; - const dialogRef = TestBed.inject(MatDialog).open(RolesAndPermissionsDialogComponent, config); - this.component = dialogRef.componentInstance; - }); + async openDialog(userId: string = 'ptTranslator'): Promise { + await this.realtimeService.subscribe( + SF_PROJECT_USER_CONFIGS_COLLECTION, + getSFProjectUserConfigDocId('project01', userId), + new DocSubscription('spec') + ); + const config: MatDialogConfig = { + data: { + projectId: 'project01', + userId, + userProfile: { + displayName: 'User', + avatarUrl: '' + } + } + }; + const dialogRef = TestBed.inject(MatDialog).open(RolesAndPermissionsDialogComponent, config); + this.component = dialogRef.componentInstance; this.wait(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/main.ts b/src/SIL.XForge.Scripture/ClientApp/src/main.ts index 099b5868e4c..d8564df9fcf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/main.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/main.ts @@ -36,6 +36,7 @@ import { provideCustomIcons } from './app/shared/custom-icons'; import { provideSFTabs } from './app/shared/sf-tab-group'; import { provideQuillRegistrations } from './app/shared/text/quill-editor-registration/quill-providers'; import { preloadEnglishTranslations } from './app/shared/utils'; +import { CacheService } from './app/shared/cache-service/cache.service'; import { provideLynxInsights } from './app/translate/editor/lynx/insights/lynx-insights-providers'; import { environment } from './environments/environment'; @@ -49,6 +50,11 @@ if (environment.production || environment.pwaTest) { ExceptionHandlingService.initBugsnag(); +/** Initialization function for any services that need to be run but are not depended on by any component. */ +function initializeGlobalServicesFactory(_cacheService: CacheService): () => Promise { + return () => Promise.resolve(); +} + bootstrapApplication(AppComponent, { providers: [ { provide: 'BASE_URL', useFactory: getBaseUrl, deps: [] as any[] }, @@ -86,6 +92,10 @@ bootstrapApplication(AppComponent, { { provide: ErrorHandler, useClass: ExceptionHandlingService }, { provide: OverlayContainer, useClass: InAppRootOverlayContainer }, provideHttpClient(withInterceptorsFromDi()), + provideAppInitializer(() => { + const initializerFn = initializeGlobalServicesFactory(inject(CacheService)); + return initializerFn(); + }), provideAppInitializer(() => { const initializerFn = preloadEnglishTranslations(inject(TranslocoService)); return initializerFn(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts index 37603443091..e099a97e010 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts @@ -2,7 +2,7 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config-test-data'; import { BehaviorSubject, Subject } from 'rxjs'; -import { mock, when } from 'ts-mockito'; +import { anything, mock, when } from 'ts-mockito'; import { configureTestingModule } from 'xforge-common/test-utils'; import { SFProjectUserConfigDoc } from '../app/core/models/sf-project-user-config-doc'; import { SFProjectService } from '../app/core/sf-project.service'; @@ -73,7 +73,7 @@ describe('ActivatedProjectUserConfigService', () => { it('should emit project user config when project becomes active', fakeAsync(() => { const { doc, config } = createTestDoc(PROJECT_ID); - when(mockedProjectService.getUserConfig(PROJECT_ID, USER_ID)).thenResolve(doc); + when(mockedProjectService.getUserConfig(PROJECT_ID, USER_ID, anything())).thenResolve(doc); let emittedDoc: SFProjectUserConfigDoc | undefined; let emittedConfig: SFProjectUserConfig | undefined; @@ -89,7 +89,7 @@ describe('ActivatedProjectUserConfigService', () => { it('should emit updated project user config when config changes', fakeAsync(() => { const { doc, changesSubject } = createTestDoc(PROJECT_ID); - when(mockedProjectService.getUserConfig(PROJECT_ID, USER_ID)).thenResolve(doc); + when(mockedProjectService.getUserConfig(PROJECT_ID, USER_ID, anything())).thenResolve(doc); projectIdSubject.next(PROJECT_ID); tick(); @@ -115,8 +115,8 @@ describe('ActivatedProjectUserConfigService', () => { const project1 = createTestDoc(PROJECT_ID); const project2 = createTestDoc(PROJECT_ID_2); - when(mockedProjectService.getUserConfig(PROJECT_ID, USER_ID)).thenResolve(project1.doc); - when(mockedProjectService.getUserConfig(PROJECT_ID_2, USER_ID)).thenResolve(project2.doc); + when(mockedProjectService.getUserConfig(PROJECT_ID, USER_ID, anything())).thenResolve(project1.doc); + when(mockedProjectService.getUserConfig(PROJECT_ID_2, USER_ID, anything())).thenResolve(project2.doc); let currentDoc: SFProjectUserConfigDoc | undefined; service.projectUserConfigDoc$.subscribe(doc => (currentDoc = doc)); @@ -131,7 +131,7 @@ describe('ActivatedProjectUserConfigService', () => { })); it('should handle case where project user config is not available', fakeAsync(() => { - when(mockedProjectService.getUserConfig('missing-project', USER_ID)).thenResolve(undefined as any); + when(mockedProjectService.getUserConfig('missing-project', USER_ID, anything())).thenResolve(undefined as any); let doc: SFProjectUserConfigDoc | undefined; service.projectUserConfigDoc$.subscribe(d => (doc = d)); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts index 368da533a2b..9bf13877da1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts @@ -1,29 +1,58 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; -import { Observable, map, of, shareReplay, startWith, switchMap } from 'rxjs'; +import { concat, finalize, from, map, Observable, of, shareReplay, switchMap } from 'rxjs'; import { SFProjectUserConfigDoc } from '../app/core/models/sf-project-user-config-doc'; import { SFProjectService } from '../app/core/sf-project.service'; import { ActivatedProjectService } from './activated-project.service'; +import { DocSubscription } from './models/realtime-doc'; import { UserService } from './user.service'; +import { quietTakeUntilDestroyed } from './util/rxjs-util'; @Injectable({ providedIn: 'root' }) export class ActivatedProjectUserConfigService { + private currentDocSubscription?: DocSubscription; + readonly projectUserConfigDoc$: Observable = this.activatedProject.projectId$.pipe( - switchMap(projectId => - projectId != null ? this.projectService.getUserConfig(projectId, this.userService.currentUserId) : of(undefined) - ), - switchMap( - projectUserConfigDoc => - projectUserConfigDoc?.changes$.pipe( - map(() => projectUserConfigDoc), - - startWith(projectUserConfigDoc) - ) ?? of(undefined) - ), - shareReplay(1) + switchMap(projectId => { + if (projectId == null) { + this.currentDocSubscription?.unsubscribe(); + this.currentDocSubscription = undefined; + return of(undefined); + } + + const previousDocSubscription: DocSubscription | undefined = this.currentDocSubscription; + const newDocSubscription = new DocSubscription('ActivatedProjectUserConfigService'); + + return from( + this.projectService.getUserConfig(projectId, this.userService.currentUserId, newDocSubscription) + ).pipe( + switchMap(projectUserConfigDoc => { + this.currentDocSubscription = newDocSubscription; + + return concat( + of(projectUserConfigDoc).pipe( + // Unsubscribe from previous doc after new doc is emitted. + finalize(() => previousDocSubscription?.unsubscribe()) + ), + projectUserConfigDoc?.changes$.pipe(map(() => projectUserConfigDoc)) ?? of(undefined) + ); + }), + + finalize(() => { + // If this inner stream is canceled before the new doc becomes current + // (for example, rapid project switching), release that pending subscription. + + if (this.currentDocSubscription !== newDocSubscription) { + newDocSubscription.unsubscribe(); + } + }) + ); + }), + shareReplay(1), + quietTakeUntilDestroyed(this.destroyRef) ); readonly projectUserConfig$: Observable = this.projectUserConfigDoc$.pipe( @@ -33,6 +62,9 @@ export class ActivatedProjectUserConfigService { constructor( private readonly activatedProject: ActivatedProjectService, private readonly projectService: SFProjectService, - private readonly userService: UserService - ) {} + private readonly userService: UserService, + private readonly destroyRef: DestroyRef + ) { + this.destroyRef.onDestroy(() => this.currentDocSubscription?.unsubscribe()); + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.spec.ts index 73f73d8d60e..dbaadfbe6bb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.spec.ts @@ -2,9 +2,19 @@ import { Component } from '@angular/core'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { provideRouter, Route, Router } from '@angular/router'; import { Subscription } from 'rxjs'; -import { ActiveProjectIdService } from './activated-project.service'; +import { anything, mock, when } from 'ts-mockito'; +import { SFProjectProfileDoc } from '../app/core/models/sf-project-profile-doc'; +import { SFProjectService } from '../app/core/sf-project.service'; +import { + ActivatedProjectService, + ActiveProjectIdService, + TestActiveProjectIdService +} from './activated-project.service'; +import { DocSubscription } from './models/realtime-doc'; import { configureTestingModule } from './test-utils'; +const mockedSFProjectService = mock(SFProjectService); + @Component({ template: '
' }) @@ -57,3 +67,170 @@ class TestEnvironment { this.subscription.unsubscribe(); } } + +describe('ActivatedProjectService', () => { + let env: ActivatedProjectTestEnvironment; + + configureTestingModule(() => ({ + providers: [ + ActivatedProjectService, + { provide: ActiveProjectIdService, useFactory: () => new TestActiveProjectIdService() }, + { provide: SFProjectService, useMock: mockedSFProjectService } + ] + })); + + beforeEach(() => (env = new ActivatedProjectTestEnvironment())); + it('switched$ emits only for defined project switches different from last defined project', fakeAsync(() => { + let emittedCount = 0; + + env.activateProject('projectA'); + tick(); + env.service.switched$.subscribe(() => emittedCount++); + + expect(emittedCount).toBe(0); + + env.activateProject('projectB'); + tick(); + // We switched from A to B. + expect(emittedCount).toBe(1); + + env.activateProject(undefined); + tick(); + // Not a switch. + expect(emittedCount).toBe(1); + + env.activateProject('projectB'); + tick(); + // Not a switch. We were last at B. + expect(emittedCount).toBe(1); + + env.activateProject(undefined); + tick(); + // Not a switch. + expect(emittedCount).toBe(1); + + env.activateProject('projectA'); + tick(); + // Now we switched, from B to A. + expect(emittedCount).toBe(2); + })); + + it('keeps the previous project subscription until the next project doc is emitted', fakeAsync(() => { + let firstDocSubscription: DocSubscription | undefined; + let secondDocSubscription: DocSubscription | undefined; + let resolveSecondProject!: (value: SFProjectProfileDoc) => void; + let getProfileCallCount = 0; + + // Configure the first profile request to resolve immediately and the second to stay pending. + when(mockedSFProjectService.getProfile(anything(), anything())).thenCall( + (projectId: string, docSubscription: DocSubscription) => { + getProfileCallCount++; + if (getProfileCallCount === 1) { + firstDocSubscription = docSubscription; + return Promise.resolve({ id: projectId } as SFProjectProfileDoc); + } + + secondDocSubscription = docSubscription; + return new Promise(resolve => { + resolveSecondProject = resolve; + }); + } + ); + + // Step 1: Activate projectA and let its profile load complete. + env.activateProject('projectA'); + tick(); + + // Step 2: Activate projectB; its profile request starts but is still pending. + env.activateProject('projectB'); + tick(); + + // Step 3: While projectB is pending, both subscriptions should remain active. + expect(firstDocSubscription?.isUnsubscribed$.getValue()).toBeFalse(); + expect(secondDocSubscription?.isUnsubscribed$.getValue()).toBeFalse(); + + // Step 4: Resolve projectB so it can become the active project document. + resolveSecondProject({ id: 'projectB' } as SFProjectProfileDoc); + tick(); + + // Step 5: After projectB emits, projectA is safely unsubscribed. + expect(firstDocSubscription?.isUnsubscribed$.getValue()).toBeTrue(); + expect(env.service.projectDoc?.id).toBe('projectB'); + })); + + it('unsubscribes a pending project fetch when a newer project selection starts', fakeAsync(() => { + let secondDocSubscription: DocSubscription | undefined; + let thirdDocSubscription: DocSubscription | undefined; + let resolveSecondProject!: (value: SFProjectProfileDoc) => void; + let resolveThirdProject!: (value: SFProjectProfileDoc) => void; + let getProfileCallCount = 0; + + // Configure projectA to resolve immediately, then keep projectB and projectC pending. + when(mockedSFProjectService.getProfile(anything(), anything())).thenCall( + (projectId: string, docSubscription: DocSubscription) => { + getProfileCallCount++; + if (getProfileCallCount === 1) { + return Promise.resolve({ id: projectId } as SFProjectProfileDoc); + } + if (getProfileCallCount === 2) { + secondDocSubscription = docSubscription; + return new Promise(resolve => { + resolveSecondProject = resolve; + }); + } + + thirdDocSubscription = docSubscription; + return new Promise(resolve => { + resolveThirdProject = resolve; + }); + } + ); + + // Step 1: Activate projectA so there is a current project doc. + env.activateProject('projectA'); + tick(); + + // Step 2: Activate projectB, leaving its profile request pending. + env.activateProject('projectB'); + tick(); + + // Step 3: Activate projectC before projectB resolves. + // projectB becomes stale and should be unsubscribed. + env.activateProject('projectC'); + tick(); + + // Step 4: Confirm stale projectB was unsubscribed, while projectC is still active. + expect(secondDocSubscription?.isUnsubscribed$.getValue()).toBeTrue(); + expect(thirdDocSubscription?.isUnsubscribed$.getValue()).toBeFalse(); + + // Step 5: Resolve stale projectB and verify it is ignored. + resolveSecondProject({ id: 'projectB' } as SFProjectProfileDoc); + tick(); + expect(env.service.projectDoc?.id).toBe('projectA'); + + // Step 6: Resolve projectC and verify it becomes the current project doc. + resolveThirdProject({ id: 'projectC' } as SFProjectProfileDoc); + tick(); + expect(env.service.projectDoc?.id).toBe('projectC'); + })); +}); + +/** Test environment for ActivatedProjectService tests. */ +class ActivatedProjectTestEnvironment { + readonly service: ActivatedProjectService; + + private readonly activeProjectIdService: TestActiveProjectIdService; + + constructor() { + this.activeProjectIdService = TestBed.inject(ActiveProjectIdService) as unknown as TestActiveProjectIdService; + this.service = TestBed.inject(ActivatedProjectService); + + when(mockedSFProjectService.getProfile(anything(), anything())).thenCall( + async (projectId: string) => ({ id: projectId }) as SFProjectProfileDoc + ); + } + + activateProject(projectId: string | undefined): void { + this.activeProjectIdService.projectId$.next(projectId); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.ts index e244173e4e6..a01642bc0d9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.ts @@ -3,12 +3,11 @@ import { TestBed } from '@angular/core/testing'; import { ActivationEnd, Router } from '@angular/router'; import ObjectID from 'bson-objectid'; import { BehaviorSubject, Observable, of } from 'rxjs'; -import { filter, map, startWith, switchMap } from 'rxjs/operators'; -import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { distinctUntilChanged, filter, map, skip, startWith, switchMap } from 'rxjs/operators'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../app/core/models/sf-project-profile-doc'; -import { PermissionsService } from '../app/core/permissions.service'; import { SFProjectService } from '../app/core/sf-project.service'; -import { CacheService } from '../app/shared/cache-service/cache.service'; import { noopDestroyRef } from './realtime.service'; interface IActiveProjectIdService { /** SF project id */ @@ -53,16 +52,22 @@ export class ActiveProjectIdService implements IActiveProjectIdService { export class ActivatedProjectService { private _projectId$ = new BehaviorSubject(undefined); private _projectDoc$ = new BehaviorSubject(undefined); + private currentProjectDocSubscription?: DocSubscription; + private pendingProjectDocSubscription?: DocSubscription; constructor( private readonly projectService: SFProjectService, - private readonly cacheService: CacheService, @Inject(ActiveProjectIdService) activeProjectIdService: IActiveProjectIdService, private destroyRef: DestroyRef ) { activeProjectIdService.projectId$ .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(projectId => this.selectProject(projectId)); + + this.destroyRef.onDestroy(() => { + this.pendingProjectDocSubscription?.unsubscribe(); + this.currentProjectDocSubscription?.unsubscribe(); + }); } /** SF project id */ @@ -80,6 +85,7 @@ export class ActivatedProjectService { return this._projectId$; } + /** Note that ActivatedProjectService is managing when to unsubscribe from the returned project document. */ get projectDoc(): SFProjectProfileDoc | undefined { return this._projectDoc$.getValue(); } @@ -87,9 +93,6 @@ export class ActivatedProjectService { private set projectDoc(projectDoc: SFProjectProfileDoc | undefined) { if (this.projectDoc !== projectDoc) { this._projectDoc$.next(projectDoc); - if (this.projectDoc !== undefined) { - void this.cacheService.cache(this.projectDoc); - } } } @@ -105,17 +108,69 @@ export class ActivatedProjectService { ); } - private async selectProject(projectId: string | undefined): Promise { + /** Emits when a different project is activated than the last project that was activated. */ + get switched$(): Observable { + return this.projectDoc$.pipe( + filterNullish(), + // Avoid comparing references and compare project IDs. + map(projectDoc => projectDoc.id), + distinctUntilChanged(), + skip(1), + map((): void => undefined) + ); + } + + /** Emits a different project that is activated than the last project that was activated. */ + get switchedDoc$(): Observable { + return this.projectDoc$.pipe( + filterNullish(), + distinctUntilChanged((prev: SFProjectProfileDoc, curr: SFProjectProfileDoc) => prev.id === curr.id), + skip(1) + ); + } + + /** Emits the active project, and each newly activated project that is different than the prior. */ + get differentDefinedDoc$(): Observable { + return this.projectDoc$.pipe( + filterNullish(), + distinctUntilChanged((prev: SFProjectProfileDoc, curr: SFProjectProfileDoc) => prev.id === curr.id) + ); + } + + protected async selectProject(projectId: string | undefined): Promise { if (projectId == null) { + this.pendingProjectDocSubscription?.unsubscribe(); + this.pendingProjectDocSubscription = undefined; this.projectId = undefined; this.projectDoc = undefined; return; } + this.projectId = projectId; - const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(projectId); - // Make sure the project ID is still the same before updating the project document - if (this.projectId === projectId) { - this.projectDoc = projectDoc; + + this.pendingProjectDocSubscription?.unsubscribe(); + + const previousDocSubscription: DocSubscription | undefined = this.currentProjectDocSubscription; + const newDocSubscription = new DocSubscription('ActivatedProjectService'); + this.pendingProjectDocSubscription = newDocSubscription; + + try { + const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(projectId, newDocSubscription); + + // Before updating the project document, make sure our method run is not getting stepped on by concurrent calls to selectProject. + if (this.projectId === projectId && this.pendingProjectDocSubscription === newDocSubscription) { + this.pendingProjectDocSubscription = undefined; + this.currentProjectDocSubscription = newDocSubscription; + this.projectDoc = projectDoc; + previousDocSubscription?.unsubscribe(); + } + } finally { + if (this.currentProjectDocSubscription !== newDocSubscription) { + if (this.pendingProjectDocSubscription === newDocSubscription) { + this.pendingProjectDocSubscription = undefined; + } + newDocSubscription.unsubscribe(); + } } } } @@ -129,19 +184,18 @@ export class TestActiveProjectIdService implements IActiveProjectIdService { export class TestActivatedProjectService extends ActivatedProjectService { constructor( projectService: SFProjectService, - cacheService: CacheService, @Inject(ActiveProjectIdService) activeProjectIdService: IActiveProjectIdService ) { - super(projectService, cacheService, activeProjectIdService, noopDestroyRef); + super(projectService, activeProjectIdService, noopDestroyRef); } - static withProjectId(projectId: string): TestActivatedProjectService { + static withProjectId(projectId?: string): TestActivatedProjectService { const projectService = TestBed.inject(SFProjectService); - const permissionsService = TestBed.inject(PermissionsService); - return new TestActivatedProjectService( - projectService, - new CacheService(projectService, permissionsService), - new TestActiveProjectIdService(projectId) - ); + return new TestActivatedProjectService(projectService, new TestActiveProjectIdService(projectId)); + } + + /** Simulate active project changing. */ + async setProject(projectId: string | undefined): Promise { + await this.selectProject(projectId); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts index f5b1474289a..8d03055503c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts @@ -142,7 +142,7 @@ describe('AuthService', () => { verify(mockedWebAuth.loginWithRedirect(anything())).once(); })); - it('should log out and clear data', fakeAsync(() => { + it('should log out and clear data', fakeAsync(async () => { const env = new TestEnvironment({ isOnline: true, isLoggedIn: true @@ -157,7 +157,7 @@ describe('AuthService', () => { .withContext('logged in expiresAt') .toBeGreaterThan(env.auth0Response!.token.expires_in!); - env.logOut(); + await env.logOut(); tick(); expect(env.service.idToken).withContext('logged out idToken').toBeUndefined(); expect(env.service.currentUserRoles.length).withContext('logged out currentUserRoles').toBe(0); @@ -313,11 +313,11 @@ describe('AuthService', () => { env.discardTokenExpiryTimer(); })); - it('should attempt login via auth0', fakeAsync(() => { + it('should attempt login via auth0', fakeAsync(async () => { const env = new TestEnvironment(); const returnUrl = 'test-returnUrl'; - env.service.logIn({ returnUrl }); + await env.service.logIn({ returnUrl }); verify(mockedWebAuth.loginWithRedirect(anything())).once(); const authOptions: RedirectLoginOptions | undefined = capture( @@ -332,12 +332,12 @@ describe('AuthService', () => { } })); - it('should login without signup', fakeAsync(() => { + it('should login without signup', fakeAsync(async () => { const env = new TestEnvironment(); const returnUrl = 'test-returnUrl'; const signUp = false; - env.service.logIn({ returnUrl, signUp }); + await env.service.logIn({ returnUrl, signUp }); verify(mockedWebAuth.loginWithRedirect(anything())).once(); const authOptions: RedirectLoginOptions | undefined = capture( @@ -351,12 +351,12 @@ describe('AuthService', () => { } })); - it('should login with signup', fakeAsync(() => { + it('should login with signup', fakeAsync(async () => { const env = new TestEnvironment(); const returnUrl = 'test-returnUrl'; const signUp = true; - env.service.logIn({ returnUrl, signUp }); + await env.service.logIn({ returnUrl, signUp }); verify(mockedWebAuth.loginWithRedirect(anything())).once(); const authOptions: RedirectLoginOptions | undefined = capture( @@ -370,14 +370,14 @@ describe('AuthService', () => { } })); - it('should login with signup and locale', fakeAsync(() => { + it('should login with signup and locale', fakeAsync(async () => { const env = new TestEnvironment(); const returnUrl = 'test-returnUrl'; const signUp = true; const locale = 'es'; expect(locale).withContext('setup').not.toEqual(env.language); - env.service.logIn({ returnUrl, signUp, locale }); + await env.service.logIn({ returnUrl, signUp, locale }); verify(mockedWebAuth.loginWithRedirect(anything())).once(); const authOptions: RedirectLoginOptions | undefined = capture( @@ -391,12 +391,12 @@ describe('AuthService', () => { } })); - it('should prompt for basic login', fakeAsync(() => { + it('should prompt for basic login', fakeAsync(async () => { const env = new TestEnvironment(); const returnUrl = 'test-returnUrl'; when(mockedLocationService.pathname).thenReturn('/join/sharekey'); - env.service.logIn({ returnUrl }); + await env.service.logIn({ returnUrl }); verify(mockedWebAuth.loginWithRedirect(anything())).once(); const authOptions: RedirectLoginOptions | undefined = capture( @@ -408,12 +408,12 @@ describe('AuthService', () => { } })); - it('should login with appropriate logo', fakeAsync(() => { + it('should login with appropriate logo', fakeAsync(async () => { const env = new TestEnvironment(); const sfLogoUrl = 'https://auth0.languagetechnology.org/assets/sf.svg'; when(mockedLocationService.hostname).thenReturn('scriptureforge.org'); - env.service.logIn({ returnUrl: 'test-returnUrl' }); + await env.service.logIn({ returnUrl: 'test-returnUrl' }); verify(mockedWebAuth.loginWithRedirect(anything())).once(); let authOptions: RedirectLoginOptions | undefined = capture( @@ -423,7 +423,7 @@ describe('AuthService', () => { // a different domain when(mockedLocationService.hostname).thenReturn('other.domain.org'); - env.service.logIn({ returnUrl: 'test-returnUrl' }); + await env.service.logIn({ returnUrl: 'test-returnUrl' }); authOptions = capture(mockedWebAuth.loginWithRedirect).last()[0]; expect(authOptions!.authorizationParams!.logo).not.toEqual(sfLogoUrl); })); @@ -621,11 +621,11 @@ describe('AuthService', () => { env.discardTokenExpiryTimer(); })); - it('prompt on log out if transparent authentication cookie is set', fakeAsync(() => { + it('prompt on log out if transparent authentication cookie is set', fakeAsync(async () => { const env = new TestEnvironment({ isOnline: true, isLoggedIn: true, setTransparentAuthenticationCookie: true }); expect(env.isAuthenticated).toBe(true); expect(env.isLoggedInUserAnonymous).toBe(true); - env.service.logOut(); + await env.service.logOut(); tick(); verify(mockedDialogService.confirm(anything(), anything(), anything())).once(); env.discardTokenExpiryTimer(); @@ -914,8 +914,8 @@ class TestEnvironment { discardPeriodicTasks(); } - logOut(): void { - this.service.logOut(); + async logOut(): Promise { + await this.service.logOut(); this.setLoginRequiredResponse(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth0.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth0.service.spec.ts index 9f0cb2b3b30..b8b5e5b72b7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth0.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth0.service.spec.ts @@ -34,10 +34,10 @@ describe('Auth0Service', () => { tick(); })); - it('should generate a new change password request', fakeAsync(() => { + it('should generate a new change password request', fakeAsync(async () => { const env = new TestEnvironment(); const email = 'test@example.com'; - env.service.changePassword(email); + await env.service.changePassword(email); const httpOptions = capture(mockedHttpClient.post).last(); expect(httpOptions[0].includes('dbconnections/change_password')).toBe(true); expect(httpOptions[1]).toEqual({ @@ -47,7 +47,7 @@ describe('Auth0Service', () => { }); })); - it('should authenticate transparently with a cookie', fakeAsync(() => { + it('should authenticate transparently with a cookie', fakeAsync(async () => { const env = new TestEnvironment(); env.setupAuthenticationCookie(); const expectedResponse: GetTokenSilentlyVerboseResponse = { @@ -67,36 +67,33 @@ describe('Auth0Service', () => { }) ) ).thenReturn(of(JSON.stringify(expectedResponse))); - env.service.tryTransparentAuthentication().then(response => { - expect(response).toEqual(expectedResponse); - verify(mockedReportingService.silentError(anything(), anything())).never(); - }); + const response = await env.service.tryTransparentAuthentication(); + expect(response).toEqual(expectedResponse); + verify(mockedReportingService.silentError(anything(), anything())).never(); tick(); })); - it('should not try and authenticate transparently if no cookie is set', fakeAsync(() => { + it('should not try and authenticate transparently if no cookie is set', fakeAsync(async () => { const env = new TestEnvironment(); - env.service.tryTransparentAuthentication().then(response => { - verify(mockedCookieService.check(TransparentAuthenticationCookie)).once(); - verify(mockedCookieService.get(TransparentAuthenticationCookie)).never(); - expect(response).toBeUndefined(); - resetCalls(mockedCookieService); - }); + const response = await env.service.tryTransparentAuthentication(); + verify(mockedCookieService.check(TransparentAuthenticationCookie)).once(); + verify(mockedCookieService.get(TransparentAuthenticationCookie)).never(); + expect(response).toBeUndefined(); + resetCalls(mockedCookieService); tick(); })); - it('should silently return when authentication fails', fakeAsync(() => { + it('should silently return when authentication fails', fakeAsync(async () => { const env = new TestEnvironment(); env.setupAuthenticationCookie(); when(mockedHttpClient.post(anything(), anything(), anything())).thenThrow( new GenericError('login_required', 'Not logged in') ); - env.service.tryTransparentAuthentication().then(response => { - verify(mockedCookieService.check(TransparentAuthenticationCookie)).once(); - verify(mockedCookieService.get(TransparentAuthenticationCookie)).once(); - verify(mockedReportingService.silentError(anything(), anything())).once(); - expect(response).toBeUndefined(); - }); + const response = await env.service.tryTransparentAuthentication(); + verify(mockedCookieService.check(TransparentAuthenticationCookie)).once(); + verify(mockedCookieService.get(TransparentAuthenticationCookie)).once(); + verify(mockedReportingService.silentError(anything(), anything())).once(); + expect(response).toBeUndefined(); tick(); })); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/csv-service.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/csv-service.service.spec.ts index 952e8a2e244..cacddfddd06 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/csv-service.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/csv-service.service.spec.ts @@ -28,7 +28,7 @@ describe('FileService', () => { expect(result).toEqual(expectedOutput); }); - it('should fail when no body returned', fakeAsync(() => { + it('should fail when no body returned', fakeAsync(async () => { const env = new TestEnvironment(); const excelFile = new File(['EXCEL DATA GOES HERE'], 'test.xls', { type: 'application/vnd.ms-excel' }); when(mockedHttpClient.post>(anything(), anything(), anything())).thenReturn( @@ -36,13 +36,11 @@ describe('FileService', () => { ); // SUT - expect(() => { - env.service.convert(excelFile); - tick(); - }).toThrowError(); + await expectAsync(env.service.convert(excelFile)).toBeRejected(); + tick(); })); - it('should fail when the API call fails', fakeAsync(() => { + it('should fail when the API call fails', fakeAsync(async () => { const env = new TestEnvironment(); const excelFile = new File(['EXCEL DATA GOES HERE'], 'test.xls', { type: 'application/vnd.ms-excel' }); when(mockedHttpClient.post>(anything(), anything(), anything())).thenReturn( @@ -50,10 +48,8 @@ describe('FileService', () => { ); // SUT - expect(() => { - env.service.convert(excelFile); - tick(); - }).toThrowError(); + await expectAsync(env.service.convert(excelFile)).toBeRejected(); + tick(); })); it('should parse a CSV file correctly', async () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.spec.ts index 460b67e7ff9..ab57dcfa115 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.spec.ts @@ -12,6 +12,7 @@ import { DialogService } from './dialog.service'; import { FileService, formatFileSource } from './file.service'; import { createDeletionFileData, createStorageFileData, FileOfflineData, FileType } from './models/file-offline-data'; import { ProjectDataDoc } from './models/project-data-doc'; +import { DocSubscription } from './models/realtime-doc'; import { OnlineStatusService } from './online-status.service'; import { provideTestOnlineStatus } from './test-online-status-providers'; import { TestOnlineStatusService } from './test-online-status.service'; @@ -351,7 +352,9 @@ class TestEnvironment { id: this.dataId, data: { dataId: this.dataId, projectRef: this.projectId, ownerRef: this.userId } }); - this.realtimeService.subscribe(TestDataDoc.COLLECTION, this.dataId).then(d => (this.doc = d)); + this.realtimeService + .subscribe(TestDataDoc.COLLECTION, this.dataId, new DocSubscription('spec')) + .then(d => (this.doc = d)); tick(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.ts index f053b2bcc9c..47b8c969279 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.ts @@ -15,6 +15,7 @@ import { FileType } from './models/file-offline-data'; import { ProjectDataDoc } from './models/project-data-doc'; +import { DocSubscription } from './models/realtime-doc'; import { OfflineStore } from './offline-store'; import { OnlineStatusService } from './online-status.service'; import { RealtimeService } from './realtime.service'; @@ -294,7 +295,8 @@ export class FileService { // The file has not been uploaded to the server const doc = await this.realtimeService.onlineFetch( fileData.dataCollection, - fileData.realtimeDocRef! + fileData.realtimeDocRef!, + new DocSubscription('FileService', this.destroyRef) ); if (doc.isLoaded) { const url = await doc.uploadFile(fileType, fileData.id, fileData.blob!, fileData.filename!); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.spec.ts new file mode 100644 index 00000000000..f4b2a4483c4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.spec.ts @@ -0,0 +1,132 @@ +import { RealtimeDocLifecycleMonitorService } from './realtime-doc-lifecycle-monitor'; + +describe('RealtimeDocLifecycleMonitorService', () => { + let service: RealtimeDocLifecycleMonitorService; + + beforeEach(() => { + service = new RealtimeDocLifecycleMonitorService(); + jasmine.clock().install(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('should not record events when monitoring is disabled', () => { + service.setMonitoringEnabled(false); + service.docCreated('doc1', 'userA'); + service.docDestroyed('doc1'); + expect(service.createdTimestamps['doc1']).toBeUndefined(); + expect(service.destroyedTimestamps['doc1']).toBeUndefined(); + }); + + it('should record created and destroyed timestamps when monitoring is enabled', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docCreated('doc1', 'userA'); + jasmine.clock().mockDate(new Date(2000)); + service.docDestroyed('doc1'); + expect(service.createdTimestamps['doc1'][0].timestamp).toBe(1000); + expect(service.createdTimestamps['doc1'][0].creatorName).toBe('userA'); + expect(service.destroyedTimestamps['doc1'][0]).toBe(2000); + }); + + it('should match created and destroyed events in recreates', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docCreated('doc1', 'userA'); + jasmine.clock().mockDate(new Date(2000)); + service.docDestroyed('doc1'); + jasmine.clock().mockDate(new Date(3000)); + service.docCreated('doc1', 'userB'); + jasmine.clock().mockDate(new Date(4000)); + service.docDestroyed('doc1'); + jasmine.clock().mockDate(new Date(5000)); + service.docCreated('doc1', 'userC'); + const recreates = service.getDocumentRecreates('doc1'); + expect(recreates.length).toBe(2); + expect(recreates[0]).toEqual({ destroyed: 2000, created: 3000, creatorName: 'userB' }); + expect(recreates[1]).toEqual({ destroyed: 4000, created: 5000, creatorName: 'userC' }); + }); + + it('should detect thrashing documents', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docCreated('doc1', 'userA'); + jasmine.clock().mockDate(new Date(2000)); + service.docDestroyed('doc1'); + jasmine.clock().mockDate(new Date(2100)); + service.docCreated('doc1', 'userB'); + jasmine.clock().mockDate(new Date(2200)); + service.docDestroyed('doc1'); + jasmine.clock().mockDate(new Date(2250)); + service.docCreated('doc1', 'userC'); + jasmine.clock().mockDate(new Date(2300)); + service.docDestroyed('doc1'); + // Thrashing threshold: 200ms, must thrash at least twice + const thrashing = service.getThrashingDocuments(200, 2); + expect(thrashing['doc1'].length).toBeGreaterThanOrEqual(2); + expect(thrashing['doc1'][0].creatorName).toBe('userB'); + expect(thrashing['doc1'][1].creatorName).toBe('userC'); + expect(thrashing['doc1'][0].recreateDuration).toBe(100); + expect(thrashing['doc1'][1].recreateDuration).toBe(50); + }); + + it('should not detect thrashing if recreate durations exceed threshold', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docCreated('doc2', 'userA'); + jasmine.clock().mockDate(new Date(2000)); + service.docDestroyed('doc2'); + jasmine.clock().mockDate(new Date(3000)); + service.docCreated('doc2', 'userB'); + jasmine.clock().mockDate(new Date(4000)); + service.docDestroyed('doc2'); + const thrashing = service.getThrashingDocuments(500, 2); + expect(thrashing['doc2']).toBeUndefined(); + }); + + it('should handle destroyed events without matching created events', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docDestroyed('doc3'); + jasmine.clock().mockDate(new Date(2000)); + service.docCreated('doc3', 'userA'); + const recreates = service.getDocumentRecreates('doc3'); + expect(recreates.length).toBe(1); + expect(recreates[0].destroyed).toBe(1000); + expect(recreates[0].created).toBe(2000); + expect(recreates[0].creatorName).toBe('userA'); + }); + + it('should return all recreates for all documents', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docCreated('docA', 'userA'); + jasmine.clock().mockDate(new Date(2000)); + service.docDestroyed('docA'); + jasmine.clock().mockDate(new Date(3000)); + service.docCreated('docB', 'userB'); + jasmine.clock().mockDate(new Date(4000)); + service.docDestroyed('docB'); + jasmine.clock().mockDate(new Date(5000)); + service.docCreated('docA', 'userC'); + const all = service.getAllDocumentRecreates(); + expect(Object.keys(all)).toContain('docA'); + expect(Object.keys(all).length).toBe(2); + expect(all['docA'][0].creatorName).toBe('userC'); + expect(all['docB'].length).toBe(0); + }); + + it('should not match destroyed events after created events', () => { + service.setMonitoringEnabled(true); + jasmine.clock().mockDate(new Date(1000)); + service.docCreated('docX', 'userA'); + jasmine.clock().mockDate(new Date(2000)); + service.docCreated('docX', 'userB'); + jasmine.clock().mockDate(new Date(3000)); + service.docDestroyed('docX'); + const recreates = service.getDocumentRecreates('docX'); + expect(recreates.length).toBe(0); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.ts new file mode 100644 index 00000000000..98665ec6e4e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@angular/core'; + +/** + * This class helps monitor the lifecyle of realtime documents and detect situations where documents are rapidly + * destroyed and then recreated. + */ +@Injectable({ providedIn: 'root' }) +export class RealtimeDocLifecycleMonitorService { + monitoringEnabled: boolean = false; + + setMonitoringEnabled(enabled: boolean): void { + this.monitoringEnabled = enabled; + } + + createdTimestamps: { + [id: string]: { + timestamp: number; + creatorName: string; + }[]; + } = {}; + + destroyedTimestamps: { + [id: string]: number[]; + } = {}; + + docCreated(id: string, creatorName: string): void { + if (this.monitoringEnabled) { + this.createdTimestamps[id] ??= []; + this.createdTimestamps[id].push({ timestamp: Date.now(), creatorName }); + } + } + + docDestroyed(id: string): void { + if (this.monitoringEnabled) { + this.destroyedTimestamps[id] ??= []; + this.destroyedTimestamps[id].push(Date.now()); + } + } + + /** + * Finds instances where a document is destroyed and then recreated within a short time period. + * @param timeThreshold The maximum time in milliseconds between destruction and recreation to consider it thrashing. + * @param times The minimum number of times the document must be recreated within the time threshold to be considered + * thrashing (the recreates do not all have to occur within the time threshold, but each recreate must be within the + * threshold of the previous destroy to be counted). + * @return An object where the keys are document IDs and the values are arrays of the time differences between + * destruction and recreation. + */ + getThrashingDocuments( + timeThreshold: number, + times: number + ): { + [id: string]: { + creatorName: string; + recreateDuration: number; + }[]; + } { + const thrashingDocs: { [id: string]: { creatorName: string; recreateDuration: number }[] } = {}; + for (const id of Object.keys(this.createdTimestamps)) { + const recreates = this.getDocumentRecreates(id); + const recreateTimes: { creatorName: string; recreateDuration: number }[] = []; + for (const recreate of recreates) { + const recreateDuration = recreate.created - recreate.destroyed; + if (recreateDuration <= timeThreshold) { + recreateTimes.push({ creatorName: recreate.creatorName, recreateDuration }); + } + } + if (recreateTimes.length >= times) { + thrashingDocs[id] = recreateTimes; + } + } + return thrashingDocs; + } + + getAllDocumentRecreates(): { [id: string]: { created: number; destroyed: number; creatorName: string }[] } { + const recreates: { [id: string]: { created: number; destroyed: number; creatorName: string }[] } = {}; + for (const id of Object.keys(this.createdTimestamps)) { + recreates[id] = this.getDocumentRecreates(id); + } + return recreates; + } + + getDocumentRecreates(id: string): { destroyed: number; created: number; creatorName: string }[] { + const created = this.createdTimestamps[id] ?? []; + const destroyed = this.destroyedTimestamps[id] ?? []; + const recreates: { created: number; destroyed: number; creatorName: string }[] = []; + for (let i = 0; i < created.length; i++) { + const createdTime = created[i].timestamp; + // Since monitoring is not always enabled, the timestamp for the prior destruction may not be at the same index + // or exist at all + const destroyedTime = destroyed.filter(timestamp => timestamp <= createdTime).pop(); + if (destroyedTime != null) { + recreates.push({ created: createdTime, destroyed: destroyedTime, creatorName: created[i].creatorName }); + } + } + return recreates; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.spec.ts new file mode 100644 index 00000000000..3a492e6d336 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.spec.ts @@ -0,0 +1,42 @@ +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { MemoryOfflineStore } from '../memory-offline-store'; +import { MemoryRealtimeDocAdapter } from '../memory-realtime-remote-store'; +import { RealtimeService } from '../realtime.service'; +import { RealtimeDoc } from './realtime-doc'; + +const mockedRealtimeService = mock(RealtimeService); +const mockedOfflineStore = mock(MemoryOfflineStore); + +describe('RealtimeDoc', () => { + it('does not delete offline data during dispose', async () => { + const env = new TestEnvironment(); + when(mockedRealtimeService.offlineStore).thenReturn(instance(mockedOfflineStore)); + when(mockedRealtimeService.onDocDisposeStarted(anything())).thenReturn(); + when(mockedRealtimeService.onDocDisposeFinished(anything())).thenReturn(); + + await env.doc.dispose(); + + verify(mockedRealtimeService.onDocDisposeStarted(env.doc)).once(); + verify(mockedOfflineStore.delete(anything(), anything())).never(); + verify(mockedRealtimeService.onDocDisposeFinished(env.doc)).once(); + expect(env.adapterDestroySpy).toHaveBeenCalledTimes(1); + }); +}); + +/** Concrete test-only realtime doc for exercising base class behavior. */ +class TestRealtimeDoc extends RealtimeDoc {} + +/** Test environment for RealtimeDoc disposal tests. */ +class TestEnvironment { + readonly realtimeService: RealtimeService; + readonly doc: TestRealtimeDoc; + readonly adapterDestroySpy: jasmine.Spy; + + constructor() { + this.realtimeService = instance(mockedRealtimeService); + + const adapter = new MemoryRealtimeDocAdapter('projects', 'doc1'); + this.adapterDestroySpy = spyOn(adapter, 'destroy').and.callThrough(); + this.doc = new TestRealtimeDoc(this.realtimeService, adapter); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts index 2e92104f3fc..c2749a87feb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts @@ -1,8 +1,10 @@ -import { merge, Observable, Subject, Subscription } from 'rxjs'; +import { DestroyRef } from '@angular/core'; +import { BehaviorSubject, filter, merge, Observable, Subject, Subscription, take } from 'rxjs'; import { Presence } from 'sharedb/lib/sharedb'; import { RealtimeService } from 'xforge-common/realtime.service'; import { PresenceData } from '../../app/shared/text/text.component'; import { RealtimeDocAdapter } from '../realtime-remote-store'; +import { isNG0911Error } from '../util/rxjs-util'; import { RealtimeOfflineData } from './realtime-offline-data'; import { Snapshot } from './snapshot'; @@ -15,6 +17,63 @@ export interface RealtimeDocConstructor { new (realtimeService: RealtimeService, adapter: RealtimeDocAdapter): RealtimeDoc; } +/** + * Represents information about the subscriber to a realtime document. + * + * This includes: + * - The context in which the subscription was created (e.g. component name). This is used for debugging purposes. + * - A flag indicating whether the subscriber has unsubscribed. + * + * In the future this class may be changed to contain a DestroyRef, callback, or some other way of signaling that the + * subscriber has unsubscribed. + */ +export class DocSubscription { + isUnsubscribed$ = new BehaviorSubject(false); + + /** + * Creates a new DocSubscription. + * @param callerContext A description of the context in which the subscription was created (e.g. component name). + */ + constructor( + readonly callerContext: string, + destroyRef?: DestroyRef | Observable + ) { + if (destroyRef == null) return; + try { + if ('onDestroy' in destroyRef) destroyRef.onDestroy(() => this.complete()); + else destroyRef.pipe(take(1)).subscribe(() => this.complete()); + } catch (error) { + if (isNG0911Error(error)) { + // destroyRef has already been destroyed + this.complete(); + } else { + throw error; + } + } + } + + private complete(): void { + this.isUnsubscribed$.next(true); + this.isUnsubscribed$.complete(); + } + + /** + * Marks the subscriber as no longer needing the document(s) subscribed to. This is an alternative to providing a + * DestroyRef or Observable to the constructor. + */ + unsubscribe(): void { + this.complete(); + } +} + +/** + * Represents the lifecycle owner of one or more realtime queries. + * + * This extends DocSubscription, but another option could be to rename `DocSubscription` to `DocOrQuerySubscription` or + * just reuse `DocSubscription` for queries. + */ +export class QuerySubscription extends DocSubscription {} + /** * This is the base class for all real-time data models. This class manages the interaction between offline storage of * the data and access to the real-time backend. @@ -34,6 +93,8 @@ export abstract class RealtimeDoc { private subscribeQueryCount: number = 0; private loadOfflineDataPromise?: Promise; + docSubscriptions = new Set(); + constructor( protected readonly realtimeService: RealtimeService, public readonly adapter: RealtimeDocAdapter @@ -176,6 +237,7 @@ export abstract class RealtimeDoc { * @returns {Promise} Resolves when the data has been successfully disposed. */ async dispose(): Promise { + this.realtimeService.onDocDisposeStarted(this); if (this.subscribePromise != null) { await this.subscribePromise; } @@ -183,7 +245,29 @@ export abstract class RealtimeDoc { this.onDeleteSub.unsubscribe(); await this.adapter.destroy(); this.subscribedState = false; - await this.realtimeService.onLocalDocDispose(this); + this.realtimeService.onDocDisposeFinished(this); + } + + addSubscriber(docSubscription: DocSubscription): void { + this.docSubscriptions.add(docSubscription); + + docSubscription.isUnsubscribed$.pipe(filter(isUnsubscribed => isUnsubscribed === true)).subscribe(() => { + this.docSubscriptions.delete(docSubscription); + + if (this.activeDocSubscriptionsCount === 0) { + void this.dispose(); + } + }); + } + + /** Number of doc subscriptions, whether they are still subscribed or not. */ + get docSubscriptionsCount(): number { + return this.docSubscriptions.size; + } + + /** Number of doc subscriptions that are still subscribed. */ + get activeDocSubscriptionsCount(): number { + return Array.from(this.docSubscriptions).filter(ds => !ds.isUnsubscribed$.getValue()).length; } protected prepareDataForStore(data: T): any { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.spec.ts new file mode 100644 index 00000000000..868842ce9d8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.spec.ts @@ -0,0 +1,41 @@ +import { Subject } from 'rxjs'; +import { anything, capture, instance, mock, when } from 'ts-mockito'; +import { RealtimeQueryAdapter } from '../realtime-remote-store'; +import { RealtimeService } from '../realtime.service'; +import { DocSubscription, RealtimeDoc } from './realtime-doc'; +import { RealtimeQuery } from './realtime-query'; + +describe('RealtimeQuery', () => { + it('passes on caller context description', async () => { + const env = new TestEnvironment({ queryName: 'my-context' }); + + await env.query['onInsert'](0, ['q1']); + + const [, , docSubscription] = capture(env.realtimeServiceMock.get as any).last(); + expect((docSubscription as DocSubscription).callerContext).toBe('RealtimeQuery/my-context'); + }); +}); + +/** Test environment for RealtimeQuery. */ +class TestEnvironment { + readonly realtimeServiceMock = mock(RealtimeService); + readonly queryAdapterMock = mock(); + readonly query: RealtimeQuery; + + constructor({ queryName }: { queryName: string }) { + when(this.queryAdapterMock.collection).thenReturn('questions'); + when(this.queryAdapterMock.ready$).thenReturn(new Subject()); + when(this.queryAdapterMock.remoteChanges$).thenReturn(new Subject()); + + const doc = { + id: 'q1', + remoteChanges$: new Subject(), + onAddedToSubscribeQuery: async () => {}, + onRemovedFromSubscribeQuery: () => undefined + } as unknown as RealtimeDoc; + + when(this.realtimeServiceMock.get(anything(), anything(), anything())).thenResolve(doc as any); + + this.query = new RealtimeQuery(instance(this.realtimeServiceMock), instance(this.queryAdapterMock), queryName); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.ts index a8c90c0c8e3..89d4099a09d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.ts @@ -1,6 +1,7 @@ import arrayDiff, { InsertDiff, MoveDiff, RemoveDiff } from 'arraydiff'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQueryAdapter } from '../realtime-remote-store'; import { RealtimeService } from '../realtime.service'; import { RealtimeDoc } from './realtime-doc'; @@ -11,7 +12,7 @@ import { RealtimeDoc } from './realtime-doc'; */ export class RealtimeQuery { private _docs: T[] = []; - private unsubscribe$ = new Subject(); + private beingDisposed = new Subject(); private _count: number = 0; private _unpagedCount: number = 0; private isDisposed = false; @@ -22,13 +23,18 @@ export class RealtimeQuery { private readonly _remoteDocChanges$ = new Subject(); private readonly _docs$ = new BehaviorSubject([]); + /** + * @param {string} name Descriptive text of the context in which this query was created, for investigating disposal + * issues. + */ constructor( private readonly realtimeService: RealtimeService, - public readonly adapter: RealtimeQueryAdapter + public readonly adapter: RealtimeQueryAdapter, + public readonly name: string ) { - this.adapter.ready$.pipe(takeUntil(this.unsubscribe$)).subscribe(() => this.onReady()); + this.adapter.ready$.pipe(takeUntil(this.beingDisposed)).subscribe(() => this.onReady()); this.adapter.remoteChanges$ - .pipe(takeUntil(this.unsubscribe$)) + .pipe(takeUntil(this.beingDisposed)) .subscribe(() => this.onChange(true, this.adapter.docIds, this.adapter.count, this.adapter.unpagedCount)); } @@ -100,14 +106,16 @@ export class RealtimeQuery { return; } - this.unsubscribe$.next(); - this.unsubscribe$.complete(); if (this.subscribed) { if (this.adapter.ready) { for (const doc of this._docs) { doc.onRemovedFromSubscribeQuery(); } } + } + this.beingDisposed.next(); + this.beingDisposed.complete(); + if (this.subscribed) { this.realtimeService.onQueryUnsubscribe(this); } for (const sub of this.docSubscriptions.values()) { @@ -147,7 +155,15 @@ export class RealtimeQuery { await this.onChange(true, this.adapter.docIds, this.adapter.count, this.adapter.unpagedCount); this._ready$.next(true); } else { - this._docs = this.adapter.docIds.map(id => this.realtimeService.get(this.collection, id)); + this._docs = await Promise.all( + this.adapter.docIds.map(id => + this.realtimeService.get( + this.collection, + id, + new DocSubscription(`RealtimeQuery/${this.name}`, this.beingDisposed) + ) + ) + ); this._count = this.adapter.count; this._unpagedCount = this.adapter.unpagedCount; } @@ -204,7 +220,11 @@ export class RealtimeQuery { const newDocs: T[] = []; const promises: Promise[] = []; for (const docId of docIds) { - const newDoc = this.realtimeService.get(this.collection, docId); + const newDoc = await this.realtimeService.get( + this.collection, + docId, + new DocSubscription(`RealtimeQuery/${this.name}`, this.beingDisposed) + ); promises.push(newDoc.onAddedToSubscribeQuery()); newDocs.push(newDoc); const docSubscription = newDoc.remoteChanges$.subscribe(() => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/owner/owner.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/owner/owner.component.ts index 30f42cdaac7..12ca198fd26 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/owner/owner.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/owner/owner.component.ts @@ -1,7 +1,8 @@ import { NgClass } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, Input, OnInit } from '@angular/core'; import { TranslocoService } from '@ngneat/transloco'; import { UserProfile } from 'realtime-server/lib/esm/common/models/user'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { AvatarComponent } from '../avatar/avatar.component'; import { I18nService } from '../i18n.service'; import { UserProfileDoc } from '../models/user-profile-doc'; @@ -24,7 +25,8 @@ export class OwnerComponent implements OnInit { constructor( private readonly userService: UserService, readonly i18n: I18nService, - private readonly translocoService: TranslocoService + private readonly translocoService: TranslocoService, + private readonly destroyRef: DestroyRef ) {} get date(): Date { @@ -46,7 +48,10 @@ export class OwnerComponent implements OnInit { async ngOnInit(): Promise { if (this.ownerRef != null) { - this.ownerDoc = await this.userService.getProfile(this.ownerRef); + this.ownerDoc = await this.userService.getProfile( + this.ownerRef, + new DocSubscription('OwnerComponent', this.destroyRef) + ); } } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/project.service.ts index 2962824bbe0..032833f1a24 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/project.service.ts @@ -1,3 +1,4 @@ +import { DestroyRef } from '@angular/core'; import { escapeRegExp, merge } from 'lodash-es'; import { Project } from 'realtime-server/lib/esm/common/models/project'; import { ProjectRole } from 'realtime-server/lib/esm/common/models/project-role'; @@ -6,6 +7,7 @@ import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; import { CommandService } from './command.service'; import { ProjectDoc } from './models/project-doc'; import { NONE_ROLE, ProjectRoleInfo } from './models/project-role-info'; +import { DocSubscription } from './models/realtime-doc'; import { RealtimeQuery } from './models/realtime-query'; import { QueryFilter, QueryParameters } from './query-parameters'; import { RealtimeService } from './realtime.service'; @@ -22,7 +24,8 @@ export abstract class ProjectService< protected readonly realtimeService: RealtimeService, protected readonly commandService: CommandService, protected readonly retryingRequestService: RetryingRequestService, - roles: ProjectRoleInfo[] + roles: ProjectRoleInfo[], + protected readonly destroyRef: DestroyRef ) { this.roles = new Map(); this.roles.set(NONE_ROLE.role, NONE_ROLE); @@ -33,8 +36,8 @@ export abstract class ProjectService< protected abstract get collection(): string; - get(id: string): Promise { - return this.realtimeService.subscribe(this.collection, id); + subscribe(id: string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe(this.collection, id, subscriber); } onlineQuery( @@ -53,17 +56,28 @@ export abstract class ProjectService< $or: termMatchProperties.map(prop => ({ [prop]: { $regex: term, $options: 'i' } })) }; } - return this.realtimeService.onlineQuery(this.collection, merge(filters, queryParameters)); + return this.realtimeService.onlineQuery( + this.collection, + 'query_projects_a', + merge(filters, queryParameters) + ); }) ); } - async onlineGetMany(projectIds: string[]): Promise { + async onlineGetMany(projectIds: string[], subscriber: DocSubscription): Promise { if (projectIds.length === 0) { return []; } - const results = await this.realtimeService.onlineQuery(this.collection, { _id: { $in: projectIds } }); - return results.docs as TDoc[]; + const results = await this.realtimeService.onlineQuery(this.collection, 'query_projects_b', { + _id: { $in: projectIds } + }); + const docs: TDoc[] = results.docs as TDoc[]; + for (const doc of docs) { + doc.addSubscriber(subscriber); + } + results.dispose(); + return docs; } onlineAddCurrentUser(id: string, projectRole?: string): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/pwa.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/pwa.service.spec.ts index 25b85f11524..7bd364a684b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/pwa.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/pwa.service.spec.ts @@ -51,7 +51,7 @@ describe('PwaService', () => { env.dispose(); })); - it('can install', fakeAsync(() => { + it('can install', fakeAsync(async () => { const env = new TestEnvironment(); let canInstall = false; env.pwaService.canInstall$.subscribe((_install: boolean) => { @@ -60,7 +60,7 @@ describe('PwaService', () => { mockedWindow.dispatchEvent(new Event('beforeinstallprompt')); TestEnvironment.isRunningInstalledApp$.next(true); expect(canInstall).toEqual(true); - env.pwaService.install(); + await env.pwaService.install(); mockedWindow.dispatchEvent(new Event('appinstalled')); tick(); expect(canInstall).toEqual(false); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.spec.ts index e85836b8ce3..e3c2c0eede1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.spec.ts @@ -3,13 +3,15 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { BehaviorSubject } from 'rxjs'; import { instance, mock, verify, when } from 'ts-mockito'; import { SF_TYPE_REGISTRY } from '../app/core/models/sf-type-registry'; -import { RealtimeDoc } from './models/realtime-doc'; +import { QuerySubscription, RealtimeDoc } from './models/realtime-doc'; import { RealtimeQuery } from './models/realtime-query'; import { RealtimeService } from './realtime.service'; import { provideTestRealtime } from './test-realtime-providers'; import { configureTestingModule } from './test-utils'; describe('RealtimeService', () => { + const QUERY_DISPOSE_TIMEOUT_MS = 5000; + configureTestingModule(() => ({ providers: [provideTestRealtime(SF_TYPE_REGISTRY)] })); @@ -46,7 +48,7 @@ describe('RealtimeService', () => { service['manageQuery'](queryPromise, destroyRef); onDestroyCallback(); - tick(5000); + tick(QUERY_DISPOSE_TIMEOUT_MS); verify(mockQuery.dispose()).once(); })); @@ -82,9 +84,47 @@ describe('RealtimeService', () => { service['manageQuery'](queryPromise, destroyRef); onDestroyCallback(); - tick(5000); + tick(QUERY_DISPOSE_TIMEOUT_MS); + + verify(mockQuery.dispose()).once(); + })); + + it('should dispose query when QuerySubscription is unsubscribed and query is ready', fakeAsync(() => { + const queryPromise = Promise.resolve(queryInstance); + const querySubscription = new QuerySubscription('realtime.service.spec'); + + service['manageQuery'](queryPromise, querySubscription); + + querySubscription.unsubscribe(); + ready$.next(true); + tick(); verify(mockQuery.dispose()).once(); })); + + it('should dispose query after timeout when QuerySubscription is unsubscribed and query is not ready', fakeAsync(() => { + const queryPromise = Promise.resolve(queryInstance); + const querySubscription = new QuerySubscription('realtime.service.spec'); + + service['manageQuery'](queryPromise, querySubscription); + querySubscription.unsubscribe(); + + tick(QUERY_DISPOSE_TIMEOUT_MS - 1); + verify(mockQuery.dispose()).never(); + + tick(1); + + verify(mockQuery.dispose()).once(); + })); + + it('should not dispose query before QuerySubscription is unsubscribed', fakeAsync(() => { + const queryPromise = Promise.resolve(queryInstance); + const querySubscription = new QuerySubscription('realtime.service.spec'); + + service['manageQuery'](queryPromise, querySubscription); + tick(QUERY_DISPOSE_TIMEOUT_MS); + + verify(mockQuery.dispose()).never(); + })); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts index 2d29c1737c5..dbdec822c37 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts @@ -1,18 +1,26 @@ import { DestroyRef, Injectable, Optional } from '@angular/core'; -import { filter, race, take, timer } from 'rxjs'; +import { filter, lastValueFrom, race, Subject, take, timer } from 'rxjs'; import { AppError } from 'xforge-common/exception-handling.service'; import { FileService } from './file.service'; -import { RealtimeDoc } from './models/realtime-doc'; +import { DocSubscription, QuerySubscription, RealtimeDoc } from './models/realtime-doc'; +import { RealtimeDocLifecycleMonitorService } from './models/realtime-doc-lifecycle-monitor'; import { RealtimeQuery } from './models/realtime-query'; import { OfflineStore } from './offline-store'; import { QueryParameters } from './query-parameters'; import { RealtimeRemoteStore } from './realtime-remote-store'; import { TypeRegistry } from './type-registry'; +const QUERY_DISPOSE_TIMEOUT_MS = 5000; + function getDocKey(collection: string, id: string): string { return `${collection}:${id}`; } +export function getCollectionFromId(id: string): string { + if (!id.includes(':')) throw new Error('id is missing an expected colon'); + return id.split(':')[0]; +} + /** * A no-op DestroyRef that is not associated with any component. * This may be useful to satisfy a subscribe query in testing or if a query is not associated with a component. @@ -32,10 +40,13 @@ export const noopDestroyRef: DestroyRef = { }) export class RealtimeService { protected readonly docs = new Map(); + /** Collection-qualified doc keys of documents being disposed and notice of their disposal completion. */ + protected readonly docsBeingDisposed = new Map>(); protected readonly subscribeQueries = new Map>(); constructor( private readonly typeRegistry: TypeRegistry, + public readonly docLifecycleMonitor: RealtimeDocLifecycleMonitorService, public readonly remoteStore: RealtimeRemoteStore, public readonly offlineStore: OfflineStore, @Optional() public readonly fileService?: FileService @@ -56,25 +67,60 @@ export class RealtimeService { return this.docs.size; } - get docsCountByCollection(): { [key: string]: { docs: number; subscribers: number; queries: number } } { - const countsByCollection: { [key: string]: { docs: number; subscribers: number; queries: number } } = {}; + get queryCountByCollection(): { [key: string]: number } { + const queriesByCollection: { [key: string]: number } = {}; + for (const [collection, queries] of this.subscribeQueries.entries()) { + queriesByCollection[collection] = queries.size; + } + return queriesByCollection; + } + + get docsCountByCollection(): { + [key: string]: { docs: number; subscribers: number; activeDocSubscriptionsCount: number }; + } { + const countsByCollection: { + [key: string]: { docs: number; subscribers: number; activeDocSubscriptionsCount: number }; + } = {}; for (const [id, doc] of this.docs.entries()) { - const collection = id.split(':')[0]; - countsByCollection[collection] ??= { docs: 0, subscribers: 0, queries: 0 }; + const collection = getCollectionFromId(id); + countsByCollection[collection] ??= { docs: 0, subscribers: 0, activeDocSubscriptionsCount: 0 }; countsByCollection[collection].docs++; - countsByCollection[collection].subscribers += doc.subscriberCount; - } - for (const [collection, queries] of this.subscribeQueries.entries()) { - countsByCollection[collection] ??= { docs: 0, subscribers: 0, queries: 0 }; - countsByCollection[collection].queries += queries.size; + countsByCollection[collection].subscribers += doc.docSubscriptionsCount; + countsByCollection[collection].activeDocSubscriptionsCount += doc.activeDocSubscriptionsCount; } return countsByCollection; } - get(collection: string, id: string): T { + get subscriberCountsByContext(): { [key: string]: { [key: string]: { all: number; active: number } } } { + const countsByContext: { [key: string]: { [key: string]: { all: number; active: number } } } = {}; + for (const [id, doc] of this.docs.entries()) { + const collection = getCollectionFromId(id); + countsByContext[collection] ??= {}; + for (const subscriber of doc.docSubscriptions) { + countsByContext[collection][subscriber.callerContext] ??= { all: 0, active: 0 }; + countsByContext[collection][subscriber.callerContext].all++; + if (!subscriber.isUnsubscribed$.getValue()) { + countsByContext[collection][subscriber.callerContext].active++; + } + } + } + return countsByContext; + } + + async get(collection: string, id: string, subscriber: DocSubscription): Promise { const key = getDocKey(collection, id); let doc = this.docs.get(key); - if (doc == null) { + if (doc != null) { + // Handle documents that currently exist but are in the process of being disposed. + const docDisposal$: Subject | undefined = this.docsBeingDisposed.get(key); + if (docDisposal$ != null) { + // Wait for document to be disposed before recreating it. + await lastValueFrom(docDisposal$); + // Recursively call this method so if multiple callers are waiting for the same document to be disposed, they will + // all get the same instance. + return await this.get(collection, id, subscriber); + } + } else { const RealtimeDocType = this.typeRegistry.getDocType(collection); if (RealtimeDocType == null) { throw new Error('The collection is unknown.'); @@ -87,12 +133,15 @@ export class RealtimeService { }); } this.docs.set(key, doc); + this.docLifecycleMonitor.docCreated(getDocKey(collection, id), subscriber.callerContext); } + doc.addSubscriber(subscriber); + return doc as T; } - createQuery(collection: string, parameters: QueryParameters): RealtimeQuery { - return new RealtimeQuery(this, this.remoteStore.createQueryAdapter(collection, parameters)); + createQuery(collection: string, name: string, parameters: QueryParameters): RealtimeQuery { + return new RealtimeQuery(this, this.remoteStore.createQueryAdapter(collection, parameters), name); } isSet(collection: string, id: string): boolean { @@ -104,16 +153,17 @@ export class RealtimeService { * * @param {string} collection The collection name. * @param {string} id The id. + * @param {DocSubscription} subscriber Caller's subscription to the doc. * @returns {Promise} The real-time doc. */ - async subscribe(collection: string, id: string): Promise { - const doc = this.get(collection, id); + async subscribe(collection: string, id: string, subscriber: DocSubscription): Promise { + const doc = await this.get(collection, id, subscriber); await doc.subscribe(); return doc; } - async onlineFetch(collection: string, id: string): Promise { - const doc = this.get(collection, id); + async onlineFetch(collection: string, id: string, subscriber: DocSubscription): Promise { + const doc = await this.get(collection, id, subscriber); await doc.onlineFetch(); return doc; } @@ -124,10 +174,17 @@ export class RealtimeService { * @param {string} collection The collection name. * @param {string} id The id. * @param {*} data The initial data. + * @param {DocSubscription} subscriber Caller's subscription to the doc. * @returns {Promise} The newly created real-time doc. */ - async create(collection: string, id: string, data: any, type?: string): Promise { - const doc = this.get(collection, id); + async create( + collection: string, + id: string, + data: any, + subscriber: DocSubscription, + type?: string + ): Promise { + const doc = await this.get(collection, id, subscriber); await doc.create(data, type); return doc; } @@ -137,20 +194,24 @@ export class RealtimeService { * and the individual docs. * * @param {string} collection The collection name. - * @param {QueryParameters} parameters The query parameters. - * See https://github.com/share/sharedb-mongo#queries. + * @param {string} name Descriptive text of the context in which this query was created, for investigating disposal + * issues. + * @param {QueryParameters} parameters The query parameters. See https://github.com/share/sharedb-mongo#queries. * @param {DestroyRef} destroyRef The reference to destroy the query when the component gets destroyed. * @returns {Promise>} A promise for the query. */ async subscribeQuery( collection: string, + name: string, parameters: QueryParameters, - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { - const query = this.createQuery(collection, parameters); + const queryName = + lifetimeIndicator instanceof QuerySubscription ? `${lifetimeIndicator.callerContext}/${name}` : name; + const query = this.createQuery(collection, queryName, parameters); return this.manageQuery( query.subscribe().then(() => query), - destroyRef + lifetimeIndicator ); } @@ -158,12 +219,17 @@ export class RealtimeService { * Performs a pessimistic query on the specified collection. The returned query is not notified of any changes. * * @param {string} collection The collection name. - * @param {QueryParameters} parameters The query parameters. - * See https://github.com/share/sharedb-mongo#queries. + * @param {string} name Descriptive text of the context in which this query was created, for investigating disposal + * issues. + * @param {QueryParameters} parameters The query parameters. See https://github.com/share/sharedb-mongo#queries. * @returns {Promise>} The query. */ - async onlineQuery(collection: string, parameters: QueryParameters): Promise> { - const query = this.createQuery(collection, parameters); + async onlineQuery( + collection: string, + name: string, + parameters: QueryParameters + ): Promise> { + const query = this.createQuery(collection, name, parameters); await query.fetch(); return query; } @@ -195,39 +261,49 @@ export class RealtimeService { } } - async onLocalDocDispose(doc: RealtimeDoc): Promise { - if (this.isSet(doc.collection, doc.id)) { - await this.offlineStore.delete(doc.collection, doc.id); - this.docs.delete(getDocKey(doc.collection, doc.id)); + onDocDisposeStarted(doc: RealtimeDoc): void { + const key = getDocKey(doc.collection, doc.id); + this.docsBeingDisposed.set(key, new Subject()); + this.docLifecycleMonitor.docDestroyed(key); + this.docs.delete(key); + } + + onDocDisposeFinished(doc: RealtimeDoc): void { + const key = getDocKey(doc.collection, doc.id); + const docDisposal$: Subject | undefined = this.docsBeingDisposed.get(key); + if (docDisposal$ != null) { + docDisposal$.next(); + docDisposal$.complete(); + this.docsBeingDisposed.delete(key); } } /** - * Ensures query is disposed when the component associated with DestroyRef is destroyed. - * This will handle the case where the component is destroyed before `queryPromise` resolves. + * Ensures query is disposed when the associated lifecycle owner signals unsubscribe/destroy. This will handle the + * case where lifecycle signal happens (such as the component being destroyed) before `queryPromise` resolves. * @param queryPromise The Promise for the RealtimeQuery. - * @param destroyRef The DestroyRef associated with the component. + * @param lifetimeIndicator The lifecycle owner of the query. * @returns The passed in `queryPromise`. */ private manageQuery( queryPromise: Promise>, - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { + if (lifetimeIndicator instanceof QuerySubscription) { + lifetimeIndicator.isUnsubscribed$ + .pipe( + filter(isUnsubscribed => isUnsubscribed), + take(1) + ) + .subscribe(() => { + this.disposeQueryWhenReadyOrTimeout(queryPromise); + }); + + return queryPromise; + } + try { - destroyRef.onDestroy(() => - queryPromise.then(query => { - // Call dispose when the query is ready or after 5 seconds (query will not emit 'ready' when offline) - race([ - query.ready$.pipe( - filter(ready => ready), - take(1) - ), - timer(5000) - ]) - .pipe(take(1)) - .subscribe(() => query.dispose()); - }) - ); + lifetimeIndicator.onDestroy(() => this.disposeQueryWhenReadyOrTimeout(queryPromise)); } catch { // If 'onDestroy' callback registration fails (view already destroyed), dispose immediately void queryPromise.then(query => query.dispose()); @@ -235,4 +311,19 @@ export class RealtimeService { return queryPromise; } + + private disposeQueryWhenReadyOrTimeout(queryPromise: Promise>): void { + void queryPromise.then(query => { + // Call dispose when the query is ready or after timeout (query will not emit 'ready' when offline) + race([ + query.ready$.pipe( + filter(ready => ready), + take(1) + ), + timer(QUERY_DISPOSE_TIMEOUT_MS) + ]) + .pipe(take(1)) + .subscribe(() => query.dispose()); + }); + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-projects.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-projects.component.spec.ts index 057cb9772da..425480da75b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-projects.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-projects.component.spec.ts @@ -246,6 +246,7 @@ class TestEnvironment { return from( this.realtimeService.onlineQuery( TestProjectDoc.COLLECTION, + 'spec', merge(filters, queryParameters) ) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.spec.ts index b3be39c319f..5e5d7adbf71 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.spec.ts @@ -171,13 +171,17 @@ class TestEnvironment { const filters: QueryFilter = { [obj().pathStr(u => u.name)]: { $regex: `.*${escapeRegExp(term)}.*`, $options: 'i' } }; - return from(this.realtimeService.onlineQuery(UserDoc.COLLECTION, merge(filters, queryParameters))); + return from( + this.realtimeService.onlineQuery(UserDoc.COLLECTION, 'spec', merge(filters, queryParameters)) + ); }) ) ); - when(mockedProjectService.onlineGetMany(anything())).thenCall(async () => { - const query = await this.realtimeService.onlineQuery(TestProjectDoc.COLLECTION, {}); - return query.docs; + when(mockedProjectService.onlineGetMany(anything(), anything())).thenCall(async () => { + const query = await this.realtimeService.onlineQuery(TestProjectDoc.COLLECTION, 'spec', {}); + const docs: TestProjectDoc[] = query.docs as TestProjectDoc[]; + query.dispose(); + return docs; }); when(mockedUserService.currentUserId).thenReturn('user01'); this.fixture = TestBed.createComponent(SaUsersComponent); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.ts index e1e210c94b9..5e5403da55f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.ts @@ -10,6 +10,7 @@ import { Project } from 'realtime-server/lib/esm/common/models/project'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { obj } from 'realtime-server/lib/esm/common/utils/obj-path'; import { BehaviorSubject } from 'rxjs'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { UserDoc } from 'xforge-common/models/user-doc'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -165,7 +166,10 @@ export class SaUsersComponent extends DataLoadingComponent implements OnInit { } } - const projectDocs = await this.projectService.onlineGetMany(projectDocsLookup); + const projectDocs = await this.projectService.onlineGetMany( + projectDocsLookup, + new DocSubscription('SaUsersComponent', this.destroyRef) + ); const projectIdMap = new Map(); for (const projectDoc of projectDocs) { projectIdMap.set(projectDoc.id, projectDoc); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user-projects.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user-projects.service.ts index d77a66e08a2..05e711d854b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user-projects.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user-projects.service.ts @@ -6,6 +6,7 @@ import { SFProjectService } from '../app/core/sf-project.service'; import { compareProjectsForSorting } from '../app/shared/utils'; import { environment } from '../environments/environment'; import { AuthService, LoginResult } from './auth.service'; +import { DocSubscription } from './models/realtime-doc'; import { UserDoc } from './models/user-doc'; import { UserService } from './user.service'; @@ -67,7 +68,9 @@ export class SFUserProjectsService { const docFetchPromises: Promise[] = []; for (const id of currentProjectIds) { if (!this._projectDocs.has(id)) { - docFetchPromises.push(this.projectService.getProfile(id)); + docFetchPromises.push( + this.projectService.getProfile(id, new DocSubscription('SFUserProjectsService', this.destroyRef)) + ); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts index 6dee3052722..c12b1217cbd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { MatDialogRef } from '@angular/material/dialog'; import { translate } from '@ngneat/transloco'; import { escapeRegExp, merge } from 'lodash-es'; @@ -12,6 +12,7 @@ import { CommandService } from './command.service'; import { DialogService } from './dialog.service'; import { EditNameDialogComponent, EditNameDialogResult } from './edit-name-dialog/edit-name-dialog.component'; import { LocalSettingsService } from './local-settings.service'; +import { DocSubscription } from './models/realtime-doc'; import { RealtimeQuery } from './models/realtime-query'; import { UserDoc } from './models/user-doc'; import { UserProfileDoc } from './models/user-profile-doc'; @@ -31,6 +32,7 @@ export const CURRENT_PROJECT_ID_SETTING = 'current_project_id'; export class UserService { // TODO: if/when we enable another xForge site, remove this and get the component to provide the site info private siteId: string = environment.siteId; + private userDocSubscription = new DocSubscription('UserService', this.destroyRef); constructor( private readonly realtimeService: RealtimeService, @@ -38,7 +40,8 @@ export class UserService { private readonly commandService: CommandService, private readonly localSettings: LocalSettingsService, private readonly dialogService: DialogService, - private readonly noticeService: NoticeService + private readonly noticeService: NoticeService, + private readonly destroyRef: DestroyRef ) {} get currentUserId(): string { @@ -65,15 +68,15 @@ export class UserService { /** Get currently-logged in user. */ getCurrentUser(): Promise { - return this.get(this.currentUserId); + return this.get(this.currentUserId, this.userDocSubscription); } - get(id: string): Promise { - return this.realtimeService.subscribe(UserDoc.COLLECTION, id); + get(id: string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe(UserDoc.COLLECTION, id, subscriber); } - getProfile(id: string): Promise { - return this.realtimeService.subscribe(UserProfileDoc.COLLECTION, id); + getProfile(id: string, subscriber: DocSubscription): Promise { + return this.realtimeService.subscribe(UserProfileDoc.COLLECTION, id, subscriber); } async onlineDelete(id: string): Promise { @@ -100,7 +103,9 @@ export class UserService { ] }; } - return from(this.realtimeService.onlineQuery(UserDoc.COLLECTION, merge(filters, queryParameters))); + return from( + this.realtimeService.onlineQuery(UserDoc.COLLECTION, 'query_users', merge(filters, queryParameters)) + ); }) ); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/rxjs-util.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/rxjs-util.ts index 2cff70ed3b8..b090f617153 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/rxjs-util.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/rxjs-util.ts @@ -13,6 +13,10 @@ export function filterNullish(): OperatorFunction { return filter((value): value is T => value != null); } +export function isNG0911Error(error: unknown): boolean { + return hasStringProp(error, 'message') && error.message.includes('NG0911'); +} + /** * Like `takeUntilDestroyed`, but catches and logs NG0911 errors (unless `options.logWarnings` is false). */ @@ -26,11 +30,10 @@ export function quietTakeUntilDestroyed( try { return destroyRef.onDestroy(callback); } catch (error) { - const isNG0911 = hasStringProp(error, 'message') && error.message.includes('NG0911'); - if (isNG0911 && options.logWarnings) { + if (isNG0911Error(error) && options.logWarnings) { console.warn('NG0911 error caught and ignored. Original stack: ', stack); } - if (!isNG0911) throw error; + if (!isNG0911Error(error)) throw error; callback(); return () => {}; } diff --git a/src/SIL.XForge.Scripture/Startup.cs b/src/SIL.XForge.Scripture/Startup.cs index a750686fe03..e89fc9d6d3f 100644 --- a/src/SIL.XForge.Scripture/Startup.cs +++ b/src/SIL.XForge.Scripture/Startup.cs @@ -79,6 +79,7 @@ public class Startup "join", "serval-administration", "system-administration", + "blank-page", // Asset and build files "assets", "polyfills",