From ba12e56531d27000bf36dcc68669547a89ee59fe Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Wed, 6 Aug 2025 16:06:18 -0400 Subject: [PATCH 01/27] SF-3532 Dispose realtime docs when no longer in use --- .../e2e/workflows/community-checking.ts | 1 + .../ClientApp/src/app/app.component.spec.ts | 186 +++--- .../ClientApp/src/app/app.component.ts | 7 +- .../ClientApp/src/app/app.routes.ts | 2 + .../chapter-audio-dialog.component.spec.ts | 12 +- .../checking-overview.component.spec.ts | 244 ++++--- .../checking-overview.component.ts | 12 +- .../checking-comments.stories.ts | 6 +- .../checking/checking-questions.service.ts | 25 +- .../checking-text.component.spec.ts | 13 +- .../checking/checking.component.spec.ts | 614 ++++++++++-------- .../checking/checking/checking.component.ts | 9 +- .../checking/checking/resume-base.service.ts | 7 +- .../checking/resume-checking.service.spec.ts | 6 +- .../checking/resume-translate.service.spec.ts | 6 +- .../import-questions-dialog.component.spec.ts | 12 +- .../import-questions-dialog.component.ts | 9 +- .../question-dialog.component.spec.ts | 258 ++++---- .../question-dialog.service.spec.ts | 73 ++- .../question-dialog.service.ts | 18 +- .../connect-project.component.spec.ts | 50 +- .../connect-project.component.ts | 6 +- .../app/core/models/note-thread-doc.spec.ts | 3 +- .../app/core/models/sf-project-base-doc.ts | 35 +- .../src/app/core/permissions.service.spec.ts | 4 +- .../src/app/core/permissions.service.ts | 28 +- .../src/app/core/sf-project.service.ts | 71 +- .../src/app/core/text-doc.service.spec.ts | 46 +- .../src/app/core/text-doc.service.ts | 50 +- .../app/core/translation-engine.service.ts | 11 +- .../event-metrics-auth.guard.spec.ts | 4 +- .../event-metrics/event-metrics-auth.guard.ts | 7 +- .../my-projects/my-projects.component.spec.ts | 4 +- .../src/app/navigation/navigation.stories.ts | 4 +- .../src/app/project/project.component.spec.ts | 10 +- .../src/app/project/project.component.ts | 9 +- .../draft-jobs.component.spec.ts | 2 +- .../draft-jobs.component.ts | 11 +- .../onboarding-requests.component.ts | 80 ++- .../serval-administration.service.ts | 17 +- .../serval-projects.component.spec.ts | 1 + .../app/settings/settings.component.spec.ts | 8 +- .../src/app/settings/settings.component.ts | 5 +- .../blank-page/blank-page.component.html | 1 + .../blank-page/blank-page.component.scss | 0 .../shared/blank-page/blank-page.component.ts | 8 + .../cache-service/cache.service.spec.ts | 415 +++++++++--- .../app/shared/cache-service/cache.service.ts | 72 +- .../diagnostic-overlay.component.html | 180 +++-- .../diagnostic-overlay.component.scss | 44 +- .../diagnostic-overlay.component.ts | 30 +- .../src/app/shared/project-router.guard.ts | 44 +- .../share/share-control.component.spec.ts | 13 +- .../shared/share/share-control.component.ts | 3 +- .../share/share-dialog.component.spec.ts | 33 +- .../shared/share/share-dialog.component.ts | 3 +- .../app/shared/text/text.component.spec.ts | 90 ++- .../src/app/shared/text/text.component.ts | 12 +- .../sync-progress.component.spec.ts | 103 +-- .../sync-progress/sync-progress.component.ts | 6 +- .../src/app/sync/sync.component.spec.ts | 58 +- .../ClientApp/src/app/sync/sync.component.ts | 6 +- .../text-chooser-dialog.component.spec.ts | 3 +- .../biblical-term-dialog.component.spec.ts | 38 +- .../biblical-terms.component.spec.ts | 93 ++- .../biblical-terms.component.ts | 29 +- .../configure-sources.component.spec.ts | 192 +++--- .../draft-generation-steps.component.spec.ts | 2 +- .../draft-history-entry.component.spec.ts | 18 +- .../draft-history-entry.component.ts | 12 +- .../draft-import-wizard.component.spec.ts | 12 +- .../draft-import-wizard.component.ts | 36 +- .../draft-sources.service.spec.ts | 10 +- .../draft-generation/draft-sources.service.ts | 14 +- .../draft-usfm-format.component.spec.ts | 5 +- .../draft-usfm-format.component.ts | 8 +- .../training-data.service.spec.ts | 7 +- .../training-data/training-data.service.ts | 18 +- .../editor-draft/editor-draft.component.ts | 5 +- .../editor-history.component.spec.ts | 6 +- .../editor-history.component.ts | 8 +- .../history-chooser.component.spec.ts | 4 +- .../history-chooser.component.ts | 8 +- .../editor-resource.component.spec.ts | 16 +- .../editor-resource.component.ts | 6 +- .../translate/editor/editor.component.spec.ts | 450 +++++++------ .../app/translate/editor/editor.component.ts | 39 +- .../lynx-insights-panel.component.spec.ts | 2 +- .../lynx-insights-panel.component.ts | 39 +- .../insights/lynx-workspace.service.spec.ts | 239 +++---- .../lynx/insights/lynx-workspace.service.ts | 13 +- .../note-dialog/note-dialog.component.spec.ts | 61 +- .../note-dialog/note-dialog.component.ts | 46 +- .../editor-tab-add-request.service.spec.ts | 12 +- .../tabs/editor-tab-add-request.service.ts | 11 +- ...-tab-add-resource-dialog.component.spec.ts | 4 +- ...ditor-tab-add-resource-dialog.component.ts | 15 +- .../editor-tab-persistence.service.spec.ts | 8 +- .../tabs/editor-tab-persistence.service.ts | 16 +- .../editor/translate-metrics-session.spec.ts | 13 +- ...anslator-settings-dialog.component.spec.ts | 102 +-- .../training-progress.component.ts | 6 +- .../translate-overview.component.spec.ts | 33 +- .../translate-overview.component.ts | 6 +- .../collaborators.component.spec.ts | 52 +- .../collaborators/collaborators.component.ts | 12 +- .../roles-and-permissions-dialog.component.ts | 15 +- .../roles-and-permissions-dialog.spec.ts | 85 +-- .../ClientApp/src/main.ts | 10 + ...ivated-project-user-config.service.spec.ts | 12 +- .../activated-project-user-config.service.ts | 16 +- .../activated-project.service.ts | 31 +- .../src/xforge-common/auth.service.spec.ts | 38 +- .../src/xforge-common/auth0.service.spec.ts | 39 +- .../xforge-common/csv-service.service.spec.ts | 16 +- .../src/xforge-common/file.service.spec.ts | 5 +- .../src/xforge-common/file.service.ts | 4 +- .../realtime-doc-lifecycle-monitor.spec.ts | 132 ++++ .../models/realtime-doc-lifecycle-monitor.ts | 98 +++ .../src/xforge-common/models/realtime-doc.ts | 79 ++- .../xforge-common/models/realtime-query.ts | 34 +- .../xforge-common/owner/owner.component.ts | 11 +- .../src/xforge-common/project.service.ts | 19 +- .../src/xforge-common/pwa.service.spec.ts | 4 +- .../src/xforge-common/realtime.service.ts | 134 +++- .../sa-projects.component.spec.ts | 1 + .../sa-users.component.spec.ts | 6 +- .../xforge-common/user-projects.service.ts | 5 +- .../src/xforge-common/user.service.ts | 21 +- .../src/xforge-common/util/rxjs-util.ts | 9 +- src/SIL.XForge.Scripture/Startup.cs | 1 + 131 files changed, 3645 insertions(+), 2016 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/blank-page/blank-page.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc-lifecycle-monitor.ts 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.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts index 4a7c104f643..7dc3217babd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts @@ -36,6 +36,7 @@ import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { LocationService } from 'xforge-common/location.service'; import { Breakpoint, MediaBreakpointService } from 'xforge-common/media-breakpoints/media-breakpoint.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'; @@ -332,7 +333,11 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest } this.projectFont = this.fontService.getFontFamilyFromProject(this._selectedProjectDoc.data); void this.userService.setCurrentProjectId(this.currentUserDoc!, this._selectedProjectDoc.id); - + this.projectUserConfigDoc = await this.projectService.getUserConfig( + this._selectedProjectDoc.id, + this.currentUserDoc!.id, + new DocSubscription('AppComponent', this.destroyRef) + ); if (this.selectedProjectDeleteSub != null) { this.selectedProjectDeleteSub.unsubscribe(); } 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..edaa84f0825 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(); @@ -196,8 +197,9 @@ describe('CheckingOverviewComponent', () => { expect(env.component.getQuestionDocs(new TextDocId('project01', 42, 1)).length).toEqual(numQuestions + 1); })); + it('should show new answer count after remote change', fakeAsync(async () => { - const env = new TestEnvironment(); + const env = await new TestEnvironment.create(); env.waitForQuestions(); const dateNow = new Date(); @@ -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..3c1c08de5dd 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 @@ -28,6 +28,7 @@ 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 } 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'; @@ -217,7 +218,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,7 +231,11 @@ 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.projectUserConfigDoc = await this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('CheckingOverviewComponent', this.destroyRef) + ); this.questionsQuery?.dispose(); this.questionsQuery = await this.checkingQuestionsService.queryQuestions( projectId, 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..1556a8a23e8 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 } 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'; @@ -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, + destroyRef + ); } /** @@ -144,7 +150,12 @@ 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, + destroyRef + ); } async queryFirstUnansweredQuestion( @@ -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, + destroyRef + ); } 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..76f42c746f5 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 @@ -39,6 +39,7 @@ import { DonutChartComponent } from 'xforge-common/donut-chart/donut-chart.compo 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 } 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'; @@ -552,7 +553,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 +575,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 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..dff63e7d348 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 @@ -159,7 +159,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,7 +250,7 @@ class TestEnvironment { constructor(readonly checkingEnabled = true) { this.service = TestBed.inject(PermissionsService); - when(mockedProjectService.getProfile(anything())).thenCall(id => + when(mockedProjectService.getProfile(anything(), anything())).thenCall(id => this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) ); 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..feba71aacd7 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,17 @@ 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..1a825e95c59 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); @@ -183,6 +184,29 @@ describe('TextDocService', () => { }); }); + describe('createTextDoc', () => { + it('should throw error if text doc already exists', fakeAsync(() => { + const env = new TestEnvironment(); + expect(() => { + env.textDocService.createTextDoc(env.textDocId, new DocSubscription('spec'), getTextDoc(env.textDocId)); + tick(); + }).toThrowError(); + })); + + it('creates the text doc if it does not already exist', fakeAsync(async () => { + const env = new TestEnvironment(); + const textDocId = new TextDocId('project01', 40, 2); + const textDoc = await env.textDocService.createTextDoc( + textDocId, + new DocSubscription('spec'), + getTextDoc(textDocId) + ); + tick(); + + expect(textDoc.data).toBeDefined(); + })); + }); + describe('isDataInSync', () => { it('should return true if the project is undefined', () => { const env = new TestEnvironment(); @@ -453,13 +477,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..9c38bbc66d8 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 @@ -6,7 +6,10 @@ import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scri import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; 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 { type } from 'rich-text'; import { Observable, Subject } from 'rxjs'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; +import { RealtimeService } from 'xforge-common/realtime.service'; import { UserService } from 'xforge-common/user.service'; import { TextDoc, TextDocId, TextDocSource } from './models/text-doc'; import { SFProjectService } from './sf-project.service'; @@ -19,7 +22,8 @@ export class TextDocService { constructor( private readonly projectService: SFProjectService, - private readonly userService: UserService + private readonly userService: UserService, + private readonly realtimeService: RealtimeService ) {} /** @@ -29,20 +33,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(); + } } /** @@ -78,6 +87,25 @@ export class TextDocService { ); } + async createTextDoc(textDocId: TextDocId, subscriber: DocSubscription, data?: TextData): Promise { + const docSubscription = new DocSubscription('TextDocService.createTextDoc'); + try { + const textDoc: TextDoc = await this.projectService.getText(textDocId, docSubscription); + if (textDoc?.data != null) throw new Error(`Text Doc already exists for ${textDocId}`); + } finally { + docSubscription.unsubscribe(); + } + + data ??= { ops: [] }; + return await this.realtimeService.create( + TextDoc.COLLECTION, + textDocId.toString(), + data, + subscriber, + type.uri + ); + } + /** * Determines if the data is in sync for the project. * 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-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..2f3c3af1fca 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,5 +1,5 @@ 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'; @@ -20,9 +20,10 @@ export class ServalAdministrationService extends ProjectService { - const query = await this.realtimeService.onlineQuery(SFProjectProfileDoc.COLLECTION, { - paratextId - }); + const query = await this.realtimeService.onlineQuery( + SFProjectProfileDoc.COLLECTION, + 'ServalAdministrationService.getByParatextId', + { + paratextId + } + ); return query.docs.length > 0 ? query.docs[0] : undefined; } } 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..5d0237a80bc 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,385 @@ -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..4cd05c554d4 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 @@ -6,6 +6,7 @@ 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'; @@ -28,26 +29,51 @@ const diagnosticOverlayCollapsedKey = 'DIAGNOSTIC_OVERLAY_COLLAPSED'; 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.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts index e7e8eb82115..3a493eff0e2 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'; @@ -9,6 +9,7 @@ import { from, Observable, of } from 'rxjs'; import { map, 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 { @@ -29,7 +31,9 @@ export abstract class RouterGuard { return this.authGuard.allowTransition().pipe( switchMap(isLoggedIn => { if (isLoggedIn) { - return from(this.projectService.getProfile(projectId)).pipe(map(projectDoc => this.check(projectDoc))); + return from( + this.projectService.getProfile(projectId, new DocSubscription('ProjectRouterGuard', this.destroyRef)) + ).pipe(map(projectDoc => this.check(projectDoc))); } return of(false); }) @@ -46,9 +50,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 +71,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 +93,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 +127,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 +155,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 +178,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 +201,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..8b0e126520e 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,8 @@ 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 { RealtimeQuery } from 'xforge-common/models/realtime-query'; 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 +96,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 +128,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 +153,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 +162,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 +193,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 +202,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 +230,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 +239,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 +257,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 +281,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 +298,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 +307,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 +319,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 +337,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 +345,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 +388,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 +602,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 +615,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(); @@ -648,48 +639,66 @@ class TestEnvironment { 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 +777,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 +799,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,7 +810,7 @@ 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([]); @@ -811,13 +820,28 @@ class TestEnvironment { this.fixture.detectChanges(); tick(); - if (args.projectLoadSuccessful !== false) { + if (projectLoadSuccessful !== false) { this.loadingFinished(); } tick(); this.fixture.detectChanges(); } + get component(): DraftSourcesComponent { + 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..11ada9b29de 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'; @@ -117,7 +118,9 @@ 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 => { + void this.userService + .getProfile(this._entry.additionalInfo.requestedByUserId, new DocSubscription('DraftHistoryEntry', this.destroyRef)) + .then(user => { if (user.data != null) { this._buildRequestedByUserName = user.data.displayName; } @@ -388,14 +391,17 @@ 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..5cc892abc82 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 @@ -1,9 +1,10 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; 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,14 +18,16 @@ 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( private readonly activatedProject: ActivatedProjectService, private readonly projectService: SFProjectService, - private readonly userService: UserService + private readonly userService: UserService, + private readonly destroyRef: DestroyRef ) {} /** @@ -73,6 +76,7 @@ export class DraftSourcesService { source: TranslateSource | SFProjectProfile, currentProjectDoc: SFProjectProfileDoc ): Promise { + const docSubscription = new DocSubscription('DraftSources', this.destroyRef); const currentUser = await this.userService.getCurrentUser(); const projectId = hasStringProp(source, 'projectRef') ? source.projectRef : currentProjectDoc.id; @@ -81,7 +85,7 @@ 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; + project = (await this.projectService.getProfile(projectId, docSubscription)).data; } 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..22952082300 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 @@ -2,6 +2,8 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { getTrainingDataId, TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; import { anything, deepEqual, mock, verify, when } from 'ts-mockito'; import { CommandService } from 'xforge-common/command.service'; +import { DocSubscription } from 'xforge-common/models/realtime-doc'; +import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { Snapshot } from 'xforge-common/models/snapshot'; import { noopDestroyRef } from 'xforge-common/realtime.service'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; @@ -87,9 +89,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..9d802b8aad8 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..6f1daf8d071 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..66bf88de338 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 @@ -13,6 +13,7 @@ import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/act 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,12 +61,14 @@ 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 { @@ -147,20 +150,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 +178,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 +189,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 +213,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 +274,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 +355,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 +385,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 +413,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 +442,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 +461,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 +474,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 +488,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 +511,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 +525,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 +536,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 +566,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 +609,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 +632,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 +662,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 +691,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 +728,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 +756,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 +788,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 +809,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 +817,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 +832,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 +848,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 +856,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 +888,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 +896,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 +907,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 +923,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 +944,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 +976,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 +996,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 +1010,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 +1019,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 +1033,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 +1104,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 +1190,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 +1280,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 +1346,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 +1406,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..c23ec51c78b 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'; @@ -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,17 @@ 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 destroyRef: DestroyRef + ) {} 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.destroyRef)); if (textDoc.data == null) return undefined; return { format: 'scripture-delta', 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..36cb8f60ec6 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,9 +1,10 @@ -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 { map, Observable, of, shareReplay, startWith, 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'; @Injectable({ @@ -13,7 +14,13 @@ export class ActivatedProjectUserConfigService { readonly projectUserConfigDoc$: Observable = this.activatedProject.projectId$.pipe( switchMap(projectId => - projectId != null ? this.projectService.getUserConfig(projectId, this.userService.currentUserId) : of(undefined) + projectId != null + ? this.projectService.getUserConfig( + projectId, + this.userService.currentUserId, + new DocSubscription('ActivatedProjectUserConfigService', this.destroyRef) + ) + : of(undefined) ), switchMap( projectUserConfigDoc => @@ -33,6 +40,7 @@ export class ActivatedProjectUserConfigService { constructor( private readonly activatedProject: ActivatedProjectService, private readonly projectService: SFProjectService, - private readonly userService: UserService + private readonly userService: UserService, + private readonly destroyRef: DestroyRef ) {} } 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..30391b43995 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 @@ -4,11 +4,10 @@ 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 { DocSubscription } from 'xforge-common/models/realtime-doc'; import { 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 */ @@ -56,7 +55,6 @@ export class ActivatedProjectService { constructor( private readonly projectService: SFProjectService, - private readonly cacheService: CacheService, @Inject(ActiveProjectIdService) activeProjectIdService: IActiveProjectIdService, private destroyRef: DestroyRef ) { @@ -87,9 +85,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,14 +100,17 @@ export class ActivatedProjectService { ); } - private async selectProject(projectId: string | undefined): Promise { + protected async selectProject(projectId: string | undefined): Promise { if (projectId == null) { this.projectId = undefined; this.projectDoc = undefined; return; } this.projectId = projectId; - const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(projectId); + const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile( + projectId, + new DocSubscription('ActivatedProjectService', this.destroyRef) + ); // Make sure the project ID is still the same before updating the project document if (this.projectId === projectId) { this.projectDoc = projectDoc; @@ -129,19 +127,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.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts index 2e92104f3fc..140278acb7c 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 } 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,55 @@ 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.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(); + } +} + /** * 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 +85,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 +229,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; } @@ -184,6 +238,29 @@ export abstract class RealtimeDoc { 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) { + 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.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.ts index a8c90c0c8e3..c671467eb47 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,8 +106,8 @@ export class RealtimeQuery { return; } - this.unsubscribe$.next(); - this.unsubscribe$.complete(); + this.beingDisposed.next(); + this.beingDisposed.complete(); if (this.subscribed) { if (this.adapter.ready) { for (const doc of this._docs) { @@ -147,7 +153,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 +218,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..15b50f55301 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,7 +56,11 @@ 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) + ); }) ); } @@ -62,7 +69,9 @@ export abstract class ProjectService< if (projectIds.length === 0) { return []; } - const results = await this.realtimeService.onlineQuery(this.collection, { _id: { $in: projectIds } }); + const results = await this.realtimeService.onlineQuery(this.collection, 'query_projects_b', { + _id: { $in: projectIds } + }); return results.docs as TDoc[]; } 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.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts index 2d29c1737c5..c9d952a5115 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,8 +1,9 @@ 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, 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'; @@ -13,6 +14,11 @@ 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 +38,13 @@ export const noopDestroyRef: DestroyRef = { }) export class RealtimeService { protected readonly docs = new Map(); + /** Doc ids of documents that are 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 +65,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(doc.id); + 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 +131,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 +151,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 +172,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,17 +192,19 @@ 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 ): Promise> { - const query = this.createQuery(collection, parameters); + const query = this.createQuery(collection, name, parameters); return this.manageQuery( query.subscribe().then(() => query), destroyRef @@ -158,12 +215,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,10 +257,24 @@ export class RealtimeService { } } + onDocDisposeStarted(doc: RealtimeDoc): void { + this.docsBeingDisposed.set(doc.id, new Subject()); + this.docLifecycleMonitor.docDestroyed(getDocKey(doc.collection, doc.id)); + this.docs.delete(getDocKey(doc.collection, doc.id)); + } + 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)); + } + } + + onDocDisposeFinished(doc: RealtimeDoc): void { + const docDisposal$: Subject | undefined = this.docsBeingDisposed.get(doc.id); + if (docDisposal$ != null) { + docDisposal$.next(); + docDisposal$.complete(); + this.docsBeingDisposed.delete(doc.id); } } 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..33f87d22954 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,12 +171,14 @@ 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, {}); + const query = await this.realtimeService.onlineQuery(TestProjectDoc.COLLECTION, 'spec', {}); return query.docs; }); when(mockedUserService.currentUserId).thenReturn('user01'); 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", From 28be50204c25f348c83215c6857a20915832617e Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 17 Apr 2026 10:48:01 -0600 Subject: [PATCH 02/27] SF-3532 Clean up rebase artifacts --- .../src/app/core/text-doc.service.spec.ts | 23 ------------------ .../src/app/core/text-doc.service.ts | 24 +------------------ .../history-chooser.component.ts | 6 ++--- 3 files changed, 4 insertions(+), 49 deletions(-) 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 1a825e95c59..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 @@ -184,29 +184,6 @@ describe('TextDocService', () => { }); }); - describe('createTextDoc', () => { - it('should throw error if text doc already exists', fakeAsync(() => { - const env = new TestEnvironment(); - expect(() => { - env.textDocService.createTextDoc(env.textDocId, new DocSubscription('spec'), getTextDoc(env.textDocId)); - tick(); - }).toThrowError(); - })); - - it('creates the text doc if it does not already exist', fakeAsync(async () => { - const env = new TestEnvironment(); - const textDocId = new TextDocId('project01', 40, 2); - const textDoc = await env.textDocService.createTextDoc( - textDocId, - new DocSubscription('spec'), - getTextDoc(textDocId) - ); - tick(); - - expect(textDoc.data).toBeDefined(); - })); - }); - describe('isDataInSync', () => { it('should return true if the project is undefined', () => { const env = new TestEnvironment(); 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 9c38bbc66d8..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 @@ -6,10 +6,8 @@ import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scri import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; 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 { type } from 'rich-text'; import { Observable, Subject } from 'rxjs'; import { DocSubscription } from 'xforge-common/models/realtime-doc'; -import { RealtimeService } from 'xforge-common/realtime.service'; import { UserService } from 'xforge-common/user.service'; import { TextDoc, TextDocId, TextDocSource } from './models/text-doc'; import { SFProjectService } from './sf-project.service'; @@ -22,8 +20,7 @@ export class TextDocService { constructor( private readonly projectService: SFProjectService, - private readonly userService: UserService, - private readonly realtimeService: RealtimeService + private readonly userService: UserService ) {} /** @@ -87,25 +84,6 @@ export class TextDocService { ); } - async createTextDoc(textDocId: TextDocId, subscriber: DocSubscription, data?: TextData): Promise { - const docSubscription = new DocSubscription('TextDocService.createTextDoc'); - try { - const textDoc: TextDoc = await this.projectService.getText(textDocId, docSubscription); - if (textDoc?.data != null) throw new Error(`Text Doc already exists for ${textDocId}`); - } finally { - docSubscription.unsubscribe(); - } - - data ??= { ops: [] }; - return await this.realtimeService.create( - TextDoc.COLLECTION, - textDocId.toString(), - data, - subscriber, - type.uri - ); - } - /** * Determines if the data is in sync for the project. * 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 9d802b8aad8..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 @@ -198,9 +198,9 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { this.loading$.next(true); try { this.projectDoc = await this.projectService.getProfile( - this.projectId, - new DocSubscription('HistoryChooserComponent', this.destroyRef) - ); + this.projectId, + new DocSubscription('HistoryChooserComponent', this.destroyRef) + ); if (this.historyRevisions.length === 0) { this.historyRevisions = (await this.paratextService.getRevisions(this.projectId, this.bookId, this.chapter)) ?? []; From 2eda9d3027e4de143f2b1545f7d55416f49219b5 Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 17 Apr 2026 14:41:19 -0600 Subject: [PATCH 03/27] SF-3532 Dispose queries and add DocSubscription to remaining service methods - getByParatextId: Accept DocSubscription, add subscriber to returned doc, dispose query after extracting result - onlineGetMany: Accept DocSubscription, add subscriber to each returned doc, dispose query after extracting results - onboarding-request-detail: Pass DocSubscription tied to DestroyRef for long-lived docs; use manual unsubscribe for short-lived downloadProject fetch - sa-users: Pass DocSubscription tied to DestroyRef for project docs - diagnostic-overlay: Add missing FormsModule import for ngModel binding --- .../onboarding-request-detail.component.ts | 25 +++++++++++++------ .../serval-administration.service.ts | 11 ++++++-- .../diagnostic-overlay.component.ts | 3 ++- .../src/xforge-common/project.service.ts | 9 +++++-- .../sa-users.component.spec.ts | 6 +++-- .../sa-users.component.ts | 6 ++++- 6 files changed, 45 insertions(+), 15 deletions(-) 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..16379483042 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,11 @@ 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'); + const projectDoc = await this.servalAdministrationService.subscribe(id, projectDocSubscription); + const shortName: string | undefined = projectDoc?.data?.shortName; + projectDocSubscription.unsubscribe(); + if (shortName == null) { this.noticeService.showError('Unable to retrieve project information.'); this.loadingFinished(); return; @@ -247,7 +258,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/serval-administration.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-administration.service.ts index 2f3c3af1fca..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 @@ -3,6 +3,7 @@ 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'; @@ -49,9 +50,10 @@ export class ServalAdministrationService extends ProjectService { + async getByParatextId(paratextId: string, subscriber: DocSubscription): Promise { const query = await this.realtimeService.onlineQuery( SFProjectProfileDoc.COLLECTION, 'ServalAdministrationService.getByParatextId', @@ -59,6 +61,11 @@ export class ServalAdministrationService extends ProjectService 0 ? query.docs[0] : undefined; + 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/shared/diagnostic-overlay/diagnostic-overlay.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/diagnostic-overlay/diagnostic-overlay.component.ts index 4cd05c554d4..bdeed666778 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,6 +1,7 @@ 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'; @@ -24,7 +25,7 @@ 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] }) export class DiagnosticOverlayComponent { isExpanded: boolean = true; 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 15b50f55301..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 @@ -65,14 +65,19 @@ export abstract class ProjectService< ); } - 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, 'query_projects_b', { _id: { $in: projectIds } }); - return results.docs as TDoc[]; + 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/system-administration/sa-users.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/system-administration/sa-users.component.spec.ts index 33f87d22954..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 @@ -177,9 +177,11 @@ class TestEnvironment { }) ) ); - when(mockedProjectService.onlineGetMany(anything())).thenCall(async () => { + when(mockedProjectService.onlineGetMany(anything(), anything())).thenCall(async () => { const query = await this.realtimeService.onlineQuery(TestProjectDoc.COLLECTION, 'spec', {}); - return query.docs; + 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); From a7588ad57576e06ed8456afb17e329200c485633 Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 21 Apr 2026 16:06:03 -0600 Subject: [PATCH 04/27] diagnostics: fix blank-page link and checking-overview test --- .../checking-overview/checking-overview.component.spec.ts | 8 ++++---- .../diagnostic-overlay/diagnostic-overlay.component.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) 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 edaa84f0825..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 @@ -197,9 +197,8 @@ describe('CheckingOverviewComponent', () => { expect(env.component.getQuestionDocs(new TextDocId('project01', 42, 1)).length).toEqual(numQuestions + 1); })); - it('should show new answer count after remote change', fakeAsync(async () => { - const env = await new TestEnvironment.create(); + const env = await TestEnvironment.create(); env.waitForQuestions(); const dateNow = new Date(); @@ -216,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); 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 bdeed666778..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 @@ -10,6 +10,7 @@ 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; @@ -25,7 +26,7 @@ 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, FormsModule] + imports: [OverlayModule, KeyValuePipe, MatIconButton, MatIcon, L10nNumberPipe, FormsModule, RouterLinkDirective] }) export class DiagnosticOverlayComponent { isExpanded: boolean = true; From e6f52f14f7a36cd178cdeeb488773e53d9428ee4 Mon Sep 17 00:00:00 2001 From: MarkS Date: Wed, 22 Apr 2026 14:47:00 -0600 Subject: [PATCH 05/27] Dispose TextDocReader subscriptions on project switch --- .../lynx/insights/lynx-workspace.service.ts | 7 +- .../lynx/insights/text-doc-reader.spec.ts | 93 +++++++++++++++++++ .../activated-project.service.spec.ts | 79 +++++++++++++++- .../activated-project.service.ts | 16 +++- .../src/xforge-common/models/realtime-doc.ts | 4 +- 5 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/text-doc-reader.spec.ts 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 c23ec51c78b..032c0e12f42 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 @@ -541,7 +541,7 @@ export class TextDocReader implements DocumentReader { constructor( private readonly projectService: SFProjectService, - private readonly destroyRef: DestroyRef + private readonly activatedProjectService: ActivatedProjectService ) {} keys(): Promise { @@ -549,7 +549,10 @@ export class TextDocReader implements DocumentReader { } async read(uri: string): Promise | undefined> { - const textDoc = await this.projectService.getText(uri, new DocSubscription('TextDocReader', this.destroyRef)); + 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..18f1a336cd2 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/text-doc-reader.spec.ts @@ -0,0 +1,93 @@ +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/xforge-common/activated-project.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project.service.spec.ts index 73f73d8d60e..ccefca01ee8 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,18 @@ 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 { configureTestingModule } from './test-utils'; +const mockedSFProjectService = mock(SFProjectService); + @Component({ template: '
' }) @@ -57,3 +66,71 @@ 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); + })); +}); + +/** 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 30391b43995..c035882c63f 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,9 +3,9 @@ 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 { distinctUntilChanged, filter, map, skip, startWith, switchMap } from 'rxjs/operators'; import { DocSubscription } from 'xforge-common/models/realtime-doc'; -import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../app/core/models/sf-project-profile-doc'; import { SFProjectService } from '../app/core/sf-project.service'; import { noopDestroyRef } from './realtime.service'; @@ -100,6 +100,18 @@ export class ActivatedProjectService { ); } + /** 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) + ); + } + protected async selectProject(projectId: string | undefined): Promise { if (projectId == null) { this.projectId = undefined; 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 140278acb7c..004836aaa25 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,5 +1,5 @@ import { DestroyRef } from '@angular/core'; -import { BehaviorSubject, filter, merge, Observable, Subject, Subscription } from 'rxjs'; +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'; @@ -41,7 +41,7 @@ export class DocSubscription { if (destroyRef == null) return; try { if ('onDestroy' in destroyRef) destroyRef.onDestroy(() => this.complete()); - else destroyRef.subscribe(() => this.complete()); + else destroyRef.pipe(take(1)).subscribe(() => this.complete()); } catch (error) { if (isNG0911Error(error)) { // destroyRef has already been destroyed From 10f013dc6fd6edb54f481325547184c99912cb12 Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Apr 2026 13:08:48 -0600 Subject: [PATCH 06/27] Adjust syntax --- .../ClientApp/src/xforge-common/models/realtime-doc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 004836aaa25..23baf07583e 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 @@ -248,7 +248,7 @@ export abstract class RealtimeDoc { this.docSubscriptions.delete(docSubscription); if (this.activeDocSubscriptionsCount === 0) { - this.dispose(); + void this.dispose(); } }); } From 5e58994547aeedd56dba991bd178b9aabe38c134 Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Apr 2026 13:48:45 -0600 Subject: [PATCH 07/27] Do not re-create lots of TextDocReader when going back and forth from project to My projects page --- .../insights/lynx-workspace.service.spec.ts | 5 ++++- .../lynx/insights/lynx-workspace.service.ts | 2 +- .../xforge-common/activated-project.service.ts | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) 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 66bf88de338..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,7 +7,7 @@ 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'; @@ -75,6 +75,9 @@ describe('LynxWorkspaceService', () => { 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()); 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 032c0e12f42..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 @@ -86,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$ 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 c035882c63f..05c47b50750 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 @@ -112,6 +112,23 @@ export class ActivatedProjectService { ); } + /** 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 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.projectId = undefined; From e55e0f5ff32a04ceebc666d36c7bf333bc61c3af Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Apr 2026 14:10:25 -0600 Subject: [PATCH 08/27] Prevent building up ActivatedProjectService's subs on project profiles --- .../src/xforge-common/activated-project.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 05c47b50750..9807fe534c5 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 @@ -2,7 +2,7 @@ import { DestroyRef, Inject, Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivationEnd, Router } from '@angular/router'; import ObjectID from 'bson-objectid'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; 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'; @@ -52,6 +52,7 @@ export class ActiveProjectIdService implements IActiveProjectIdService { export class ActivatedProjectService { private _projectId$ = new BehaviorSubject(undefined); private _projectDoc$ = new BehaviorSubject(undefined); + private doneWithSelectedProject$ = new Subject(); constructor( private readonly projectService: SFProjectService, @@ -136,9 +137,10 @@ export class ActivatedProjectService { return; } this.projectId = projectId; + this.doneWithSelectedProject$.next(); const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile( projectId, - new DocSubscription('ActivatedProjectService', this.destroyRef) + new DocSubscription('ActivatedProjectService', this.doneWithSelectedProject$) ); // Make sure the project ID is still the same before updating the project document if (this.projectId === projectId) { From c8fa4440fa89857edc7ea9e05fad25879359dc3a Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Apr 2026 14:22:25 -0600 Subject: [PATCH 09/27] dispose of ProjectRouterGuard's subs --- .../ClientApp/src/app/shared/project-router.guard.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 3a493eff0e2..9f221563781 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 @@ -31,9 +31,14 @@ export abstract class RouterGuard { return this.authGuard.allowTransition().pipe( switchMap(isLoggedIn => { if (isLoggedIn) { - return from( - this.projectService.getProfile(projectId, new DocSubscription('ProjectRouterGuard', this.destroyRef)) - ).pipe(map(projectDoc => this.check(projectDoc))); + const docSubscription = new DocSubscription('ProjectRouterGuard'); + return from(this.projectService.getProfile(projectId, docSubscription)).pipe( + map(projectDoc => { + const may: boolean = this.check(projectDoc); + docSubscription.unsubscribe(); + return may; + }) + ); } return of(false); }) From e4cc2a377cdcd65b3cf4e50d0d1ee46d72dbc31d Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Apr 2026 14:38:22 -0600 Subject: [PATCH 10/27] Comments --- .../ClientApp/src/xforge-common/activated-project.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 9807fe534c5..31f3bf7a391 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 @@ -79,6 +79,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(); } @@ -122,7 +123,7 @@ export class ActivatedProjectService { ); } - /** Emits the active project, and each activated project that is different than the prior. */ + /** Emits the active project, and each newly activated project that is different than the prior. */ get differentDefinedDoc$(): Observable { return this.projectDoc$.pipe( filterNullish(), @@ -134,6 +135,8 @@ export class ActivatedProjectService { if (projectId == null) { this.projectId = undefined; this.projectDoc = undefined; + // We do not call this.doneWithSelectedProject$.next() here because consumers of differentDefinedDoc$ or + // switchedDoc$ could be using it. return; } this.projectId = projectId; From 165e6887733e8204e9da0fc0906860f5a9195abb Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Apr 2026 15:54:41 -0600 Subject: [PATCH 11/27] dispose of ActivatedProjectUserConfigService's subs --- .../activated-project-user-config.service.ts | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) 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 36cb8f60ec6..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,36 +1,58 @@ import { DestroyRef, Injectable } from '@angular/core'; import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; -import { map, Observable, 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, - new DocSubscription('ActivatedProjectUserConfigService', this.destroyRef) - ) - : 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( @@ -42,5 +64,7 @@ export class ActivatedProjectUserConfigService { private readonly projectService: SFProjectService, private readonly userService: UserService, private readonly destroyRef: DestroyRef - ) {} + ) { + this.destroyRef.onDestroy(() => this.currentDocSubscription?.unsubscribe()); + } } From 71c2aaada6280902da96ed1314285db5532f120b Mon Sep 17 00:00:00 2001 From: MarkS Date: Mon, 27 Apr 2026 10:40:29 -0600 Subject: [PATCH 12/27] activated-project: delay old doc unsubscribe until new emit --- .../activated-project.service.spec.ts | 100 ++++++++++++++++++ .../activated-project.service.ts | 47 +++++--- 2 files changed, 135 insertions(+), 12 deletions(-) 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 ccefca01ee8..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 @@ -10,6 +10,7 @@ import { ActiveProjectIdService, TestActiveProjectIdService } from './activated-project.service'; +import { DocSubscription } from './models/realtime-doc'; import { configureTestingModule } from './test-utils'; const mockedSFProjectService = mock(SFProjectService); @@ -113,6 +114,105 @@ describe('ActivatedProjectService', () => { // 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. */ 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 31f3bf7a391..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 @@ -2,7 +2,7 @@ import { DestroyRef, Inject, Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivationEnd, Router } from '@angular/router'; import ObjectID from 'bson-objectid'; -import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; 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'; @@ -52,7 +52,8 @@ export class ActiveProjectIdService implements IActiveProjectIdService { export class ActivatedProjectService { private _projectId$ = new BehaviorSubject(undefined); private _projectDoc$ = new BehaviorSubject(undefined); - private doneWithSelectedProject$ = new Subject(); + private currentProjectDocSubscription?: DocSubscription; + private pendingProjectDocSubscription?: DocSubscription; constructor( private readonly projectService: SFProjectService, @@ -62,6 +63,11 @@ export class ActivatedProjectService { activeProjectIdService.projectId$ .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(projectId => this.selectProject(projectId)); + + this.destroyRef.onDestroy(() => { + this.pendingProjectDocSubscription?.unsubscribe(); + this.currentProjectDocSubscription?.unsubscribe(); + }); } /** SF project id */ @@ -133,21 +139,38 @@ export class ActivatedProjectService { protected async selectProject(projectId: string | undefined): Promise { if (projectId == null) { + this.pendingProjectDocSubscription?.unsubscribe(); + this.pendingProjectDocSubscription = undefined; this.projectId = undefined; this.projectDoc = undefined; - // We do not call this.doneWithSelectedProject$.next() here because consumers of differentDefinedDoc$ or - // switchedDoc$ could be using it. return; } + this.projectId = projectId; - this.doneWithSelectedProject$.next(); - const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile( - projectId, - new DocSubscription('ActivatedProjectService', this.doneWithSelectedProject$) - ); - // 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(); + } } } } From a1ecc01436f5d8933e3eaa5b029e270e0b9b3526 Mon Sep 17 00:00:00 2001 From: MarkS Date: Mon, 27 Apr 2026 11:49:05 -0600 Subject: [PATCH 13/27] Finish a rearranging that had left offlineStore.delete never happening --- .../xforge-common/models/realtime-doc.spec.ts | 51 +++++++++++++++++++ .../src/xforge-common/models/realtime-doc.ts | 2 +- .../src/xforge-common/realtime.service.ts | 6 --- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.spec.ts 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..63e8b1a505c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.spec.ts @@ -0,0 +1,51 @@ +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('deletes offline data during dispose even after onDocDisposeStarted removes the doc from realtime.service docs', async () => { + const env = new TestEnvironment(); + when(mockedRealtimeService.offlineStore).thenReturn(instance(mockedOfflineStore)); + when(mockedRealtimeService.onDocDisposeStarted(anything())).thenCall(() => { + docStillInCache = false; + }); + when(mockedRealtimeService.onDocDisposeFinished(anything())).thenReturn(); + when(mockedOfflineStore.delete(anything(), anything())).thenCall(async () => { + expect(docStillInCache).toBeFalse(); + }); + let docStillInCache: boolean = true; + + await env.doc.dispose(); + + verify(mockedRealtimeService.onDocDisposeStarted(env.doc)).once(); + verify(mockedOfflineStore.delete('projects', 'doc1')).once(); + verify(mockedRealtimeService.onDocDisposeStarted(env.doc)).calledBefore( + mockedOfflineStore.delete('projects', 'doc1') + ); + 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 23baf07583e..a02edd9be5c 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 @@ -237,7 +237,7 @@ export abstract class RealtimeDoc { this.onDeleteSub.unsubscribe(); await this.adapter.destroy(); this.subscribedState = false; - await this.realtimeService.onLocalDocDispose(this); + await this.realtimeService.offlineStore.delete(this.collection, this.id); this.realtimeService.onDocDisposeFinished(this); } 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 c9d952a5115..65bbea1c81e 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 @@ -263,12 +263,6 @@ export class RealtimeService { this.docs.delete(getDocKey(doc.collection, doc.id)); } - async onLocalDocDispose(doc: RealtimeDoc): Promise { - if (this.isSet(doc.collection, doc.id)) { - await this.offlineStore.delete(doc.collection, doc.id); - } - } - onDocDisposeFinished(doc: RealtimeDoc): void { const docDisposal$: Subject | undefined = this.docsBeingDisposed.get(doc.id); if (docDisposal$ != null) { From 3a7d45d62e96a4dd65853934f1d09e35b4f749ad Mon Sep 17 00:00:00 2001 From: MarkS Date: Mon, 27 Apr 2026 15:25:03 -0600 Subject: [PATCH 14/27] Use non-colliding key --- .../src/xforge-common/realtime.service.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 65bbea1c81e..4be90f3aae2 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 @@ -38,7 +38,7 @@ export const noopDestroyRef: DestroyRef = { }) export class RealtimeService { protected readonly docs = new Map(); - /** Doc ids of documents that are being disposed and notice of their disposal completion. */ + /** Collection-qualified doc keys of documents being disposed and notice of their disposal completion. */ protected readonly docsBeingDisposed = new Map>(); protected readonly subscribeQueries = new Map>(); @@ -110,7 +110,7 @@ export class RealtimeService { let doc = this.docs.get(key); if (doc != null) { // Handle documents that currently exist but are in the process of being disposed. - const docDisposal$: Subject | undefined = this.docsBeingDisposed.get(doc.id); + const docDisposal$: Subject | undefined = this.docsBeingDisposed.get(key); if (docDisposal$ != null) { // Wait for document to be disposed before recreating it. await lastValueFrom(docDisposal$); @@ -258,17 +258,19 @@ export class RealtimeService { } onDocDisposeStarted(doc: RealtimeDoc): void { - this.docsBeingDisposed.set(doc.id, new Subject()); - this.docLifecycleMonitor.docDestroyed(getDocKey(doc.collection, doc.id)); - this.docs.delete(getDocKey(doc.collection, doc.id)); + 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 docDisposal$: Subject | undefined = this.docsBeingDisposed.get(doc.id); + 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(doc.id); + this.docsBeingDisposed.delete(key); } } From b906e2da4d4426560efe565659e5c5f0ce4ecacf Mon Sep 17 00:00:00 2001 From: MarkS Date: Mon, 27 Apr 2026 18:57:24 -0600 Subject: [PATCH 15/27] realtime-query: reorder dispose notifications --- .../ClientApp/src/xforge-common/models/realtime-query.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 c671467eb47..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 @@ -106,14 +106,16 @@ export class RealtimeQuery { return; } - this.beingDisposed.next(); - this.beingDisposed.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()) { From c4884ed9afde5124ae4b9c3d2b902345c9a1bf0a Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 28 Apr 2026 13:31:13 -0600 Subject: [PATCH 16/27] SF-3532 Align doc subscription cleanup after rebase --- .../src/app/core/permissions.service.spec.ts | 11 ++-- .../onboarding-request-detail.component.ts | 10 +++- .../app/shared/project-router.guard.spec.ts | 57 ++++++++++++++++++- .../src/app/shared/project-router.guard.ts | 26 +++++---- 4 files changed, 82 insertions(+), 22 deletions(-) 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 dff63e7d348..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'; @@ -251,11 +252,7 @@ class TestEnvironment { this.service = TestBed.inject(PermissionsService); when(mockedProjectService.getProfile(anything(), anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) - ); - - when(mockedProjectService.get(anything())).thenCall(id => - this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, 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/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 16379483042..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 @@ -227,9 +227,13 @@ export class OnboardingRequestDetailComponent extends DataLoadingComponent imple // Get the project to retrieve its shortName const projectDocSubscription = new DocSubscription('OnboardingRequestDetailComponent.downloadProject'); - const projectDoc = await this.servalAdministrationService.subscribe(id, projectDocSubscription); - const shortName: string | undefined = projectDoc?.data?.shortName; - projectDocSubscription.unsubscribe(); + 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(); 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 9f221563781..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 @@ -6,7 +6,7 @@ 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'; @@ -30,21 +30,25 @@ export abstract class RouterGuard { allowTransition(projectId: string): Observable { return this.authGuard.allowTransition().pipe( switchMap(isLoggedIn => { - if (isLoggedIn) { - const docSubscription = new DocSubscription('ProjectRouterGuard'); - return from(this.projectService.getProfile(projectId, docSubscription)).pipe( - map(projectDoc => { - const may: boolean = this.check(projectDoc); - docSubscription.unsubscribe(); - return may; - }) - ); + 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; } From ef47ae325d2d20f4b814e7a0a94dc2d86e67eea2 Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 2 Jun 2026 14:37:06 -0600 Subject: [PATCH 17/27] docs: add class comment to DraftHistoryEntryComponent --- .../draft-history-entry.component.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) 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 11ada9b29de..468544a5826 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 @@ -102,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 the build's status, date, + * scripture range, training configuration, fault details, 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(); @@ -119,12 +124,15 @@ export class DraftHistoryEntryComponent { this._buildRequestedByUserName = undefined; if (this._entry?.additionalInfo?.requestedByUserId != null) { void this.userService - .getProfile(this._entry.additionalInfo.requestedByUserId, new DocSubscription('DraftHistoryEntry', this.destroyRef)) + .getProfile( + this._entry.additionalInfo.requestedByUserId, + new DocSubscription('DraftHistoryEntry', this.destroyRef) + ) .then(user => { - if (user.data != null) { - this._buildRequestedByUserName = user.data.displayName; - } - }); + if (user.data != null) { + this._buildRequestedByUserName = user.data.displayName; + } + }); } // Clear the data for the table @@ -391,7 +399,10 @@ export class DraftHistoryEntryComponent { let target: SFProjectProfileDoc | undefined = undefined; let draftSources: DraftSourcesAsTranslateSourceArrays | undefined; if (targetId != null) { - target = await this.projectService.getProfile(targetId, new DocSubscription('DraftHistoryEntry', this.destroyRef)); + target = await this.projectService.getProfile( + targetId, + new DocSubscription('DraftHistoryEntry', this.destroyRef) + ); if (target?.data != null) { draftSources = projectToDraftSources(target.data); } From 96e78016848140e0844d79f975f0337f8e898321 Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 2 Jun 2026 14:44:47 -0600 Subject: [PATCH 18/27] Add class comment --- .../draft-history-entry/draft-history-entry.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 468544a5826..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 @@ -103,8 +103,8 @@ interface SourceInfo { styleUrl: './draft-history-entry.component.scss' }) /** - * Displays a single draft generation build entry within the draft history list. Shows the build's status, date, - * scripture range, training configuration, fault details, and provides actions like downloading or importing the draft. + * 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 { From f09f542fc318c39fddd9d21e87938f121ace1784 Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 2 Jun 2026 15:24:08 -0600 Subject: [PATCH 19/27] SF-3532 Retain offline doc snapshots on dispose --- .../src/xforge-common/models/realtime-doc.spec.ts | 15 +++------------ .../src/xforge-common/models/realtime-doc.ts | 1 - 2 files changed, 3 insertions(+), 13 deletions(-) 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 index 63e8b1a505c..3a492e6d336 100644 --- 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 @@ -8,25 +8,16 @@ const mockedRealtimeService = mock(RealtimeService); const mockedOfflineStore = mock(MemoryOfflineStore); describe('RealtimeDoc', () => { - it('deletes offline data during dispose even after onDocDisposeStarted removes the doc from realtime.service docs', async () => { + it('does not delete offline data during dispose', async () => { const env = new TestEnvironment(); when(mockedRealtimeService.offlineStore).thenReturn(instance(mockedOfflineStore)); - when(mockedRealtimeService.onDocDisposeStarted(anything())).thenCall(() => { - docStillInCache = false; - }); + when(mockedRealtimeService.onDocDisposeStarted(anything())).thenReturn(); when(mockedRealtimeService.onDocDisposeFinished(anything())).thenReturn(); - when(mockedOfflineStore.delete(anything(), anything())).thenCall(async () => { - expect(docStillInCache).toBeFalse(); - }); - let docStillInCache: boolean = true; await env.doc.dispose(); verify(mockedRealtimeService.onDocDisposeStarted(env.doc)).once(); - verify(mockedOfflineStore.delete('projects', 'doc1')).once(); - verify(mockedRealtimeService.onDocDisposeStarted(env.doc)).calledBefore( - mockedOfflineStore.delete('projects', 'doc1') - ); + verify(mockedOfflineStore.delete(anything(), anything())).never(); verify(mockedRealtimeService.onDocDisposeFinished(env.doc)).once(); expect(env.adapterDestroySpy).toHaveBeenCalledTimes(1); }); 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 a02edd9be5c..2a8275fd23f 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 @@ -237,7 +237,6 @@ export abstract class RealtimeDoc { this.onDeleteSub.unsubscribe(); await this.adapter.destroy(); this.subscribedState = false; - await this.realtimeService.offlineStore.delete(this.collection, this.id); this.realtimeService.onDocDisposeFinished(this); } From 86eee3b828d17f0a82d41f16afcf2f350e51785d Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 2 Jun 2026 16:23:13 -0600 Subject: [PATCH 20/27] Clean up app lifetime disposal in draft sources --- .../translate/draft-generation/draft-sources.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 5cc892abc82..11091cbd2b4 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 @@ -76,7 +76,6 @@ export class DraftSourcesService { source: TranslateSource | SFProjectProfile, currentProjectDoc: SFProjectProfileDoc ): Promise { - const docSubscription = new DocSubscription('DraftSources', this.destroyRef); const currentUser = await this.userService.getCurrentUser(); const projectId = hasStringProp(source, 'projectRef') ? source.projectRef : currentProjectDoc.id; @@ -85,7 +84,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, docSubscription)).data; + const docSubscription = new DocSubscription('DraftSourcesService.mapDraftSourceToFullSource'); + try { + project = (await this.projectService.getProfile(projectId, docSubscription)).data; + } finally { + docSubscription.unsubscribe(); + } } if (project != null) { From 94a46745f8903fb6d840d5d6f17639393df8972b Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 23 Jun 2026 15:44:01 -0600 Subject: [PATCH 21/27] rebase: apply doc disposal strategy to master changes --- .../ClientApp/src/app/app.component.ts | 2 +- ...nboarding-request-detail.component.spec.ts | 8 +++-- ...onboarding-requests-export.service.spec.ts | 6 ++-- .../onboarding-requests-export.service.ts | 9 +++-- .../serval-builds.component.spec.ts | 2 +- .../serval-builds.component.ts | 5 ++- .../configure-sources.component.spec.ts | 15 ++++---- .../draft-generation/draft-sources.service.ts | 5 ++- .../training-data.service.spec.ts | 34 +++++++++++++++---- .../training-data/training-data.service.ts | 20 ++++++----- .../src/xforge-common/models/realtime-doc.ts | 2 +- 11 files changed, 71 insertions(+), 37 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts index 7dc3217babd..5982f1f1e8c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts @@ -333,7 +333,7 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest } this.projectFont = this.fontService.getFontFamilyFromProject(this._selectedProjectDoc.data); void this.userService.setCurrentProjectId(this.currentUserDoc!, this._selectedProjectDoc.id); - this.projectUserConfigDoc = await this.projectService.getUserConfig( + await this.projectService.getUserConfig( this._selectedProjectDoc.id, this.currentUserDoc!.id, new DocSubscription('AppComponent', this.destroyRef) 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-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..1275ee9bfd0 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,7 +92,9 @@ export class OnboardingRequestsExportService { assigneeNames: Map ): Promise { const sfProjectId = request.submission.projectId; - const projectDoc = await this.servalAdministrationService.get(sfProjectId); + const projectDocSubscription = new DocSubscription('OnboardingRequestsExportService.createSpreadsheetRow'); + const projectDoc = await this.servalAdministrationService.subscribe(sfProjectId, projectDocSubscription); + projectDocSubscription.unsubscribe(); const projectShortName = projectDoc?.data?.shortName ?? sfProjectId; return { @@ -112,7 +115,9 @@ 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); + const docSubscription = new DocSubscription('OnboardingRequestsExportService.lookupAssigneeNames'); + const userDoc = await this.userService.get(assigneeId, docSubscription); + docSubscription.unsubscribe(); return [assigneeId, userDoc?.data?.displayName ?? ''] as const; }) ); 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/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 8b0e126520e..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 @@ -19,7 +19,6 @@ 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 { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -631,10 +630,10 @@ 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; @@ -815,8 +814,8 @@ class TestEnvironment { 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(); @@ -827,12 +826,12 @@ class TestEnvironment { this.fixture.detectChanges(); } - get component(): DraftSourcesComponent { + get component(): ConfigureSourcesComponent { if (this._component == null) throw new Error('Uninitialized'); return this._component; } - get fixture(): ComponentFixture { + get fixture(): ComponentFixture { if (this._fixture == null) throw new Error('Uninitialized'); return this._fixture; } 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 11091cbd2b4..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 @@ -1,4 +1,4 @@ -import { DestroyRef, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { asyncScheduler, combineLatest, defer, from, Observable } from 'rxjs'; @@ -26,8 +26,7 @@ export class DraftSourcesService { constructor( private readonly activatedProject: ActivatedProjectService, private readonly projectService: SFProjectService, - private readonly userService: UserService, - private readonly destroyRef: DestroyRef + private readonly userService: UserService ) {} /** 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 22952082300..dc91f77d317 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 @@ -2,8 +2,6 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { getTrainingDataId, TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; import { anything, deepEqual, mock, verify, when } from 'ts-mockito'; import { CommandService } from 'xforge-common/command.service'; -import { DocSubscription } from 'xforge-common/models/realtime-doc'; -import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { Snapshot } from 'xforge-common/models/snapshot'; import { noopDestroyRef } from 'xforge-common/realtime.service'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; @@ -89,12 +87,36 @@ describe('TrainingDataService', () => { await trainingDataService.createTrainingDataAsync(newTrainingData); tick(); - const trainingDataDoc = await realtimeService.get( + const offlineData = await realtimeService.offlineStore.get( TrainingDataDoc.COLLECTION, - getTrainingDataId('project01', 'data03'), - new DocSubscription('spec') + getTrainingDataId('project01', 'data03') ); - expect(trainingDataDoc.data).toEqual(newTrainingData); + expect(offlineData?.data).toEqual(newTrainingData); + })); + + it('should not keep the created training data doc subscribed after create completes', fakeAsync(async () => { + const newTrainingData: TrainingData = { + projectRef: 'project01', + dataId: 'data04', + fileUrl: 'project01/test4.csv', + mimeType: 'text/csv', + skipRows: 0, + title: 'test4.csv', + ownerRef: 'user01', + deleted: false + }; + + // SUT + await trainingDataService.createTrainingDataAsync(newTrainingData); + tick(); + + expect(realtimeService.totalDocCount).toEqual(0); + + const offlineData = await realtimeService.offlineStore.get( + TrainingDataDoc.COLLECTION, + getTrainingDataId('project01', 'data04') + ); + expect(offlineData?.data).toEqual(newTrainingData); })); it('should request deletion via RPC', fakeAsync(async () => { 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 0628b2aba2c..16f1d7b0c8a 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 @@ -16,18 +16,22 @@ import { TrainingDataDoc } from '../../../core/models/training-data-doc'; export class TrainingDataService { constructor( private readonly realtimeService: RealtimeService, - private readonly commandService: CommandService, - private readonly destroyRef: DestroyRef + private readonly commandService: CommandService ) {} async createTrainingDataAsync(trainingData: TrainingData): Promise { const docId: string = getTrainingDataId(trainingData.projectRef, trainingData.dataId); - await this.realtimeService.create( - TrainingDataDoc.COLLECTION, - docId, - trainingData, - new DocSubscription('TrainingDataService', this.destroyRef) - ); + const docSubscription = new DocSubscription('TrainingDataService.createTrainingDataAsync'); + try { + await this.realtimeService.create( + TrainingDataDoc.COLLECTION, + docId, + trainingData, + docSubscription + ); + } finally { + docSubscription.unsubscribe(); + } } async deleteTrainingDataAsync(trainingData: TrainingData): Promise { 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 2a8275fd23f..e81827a1385 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 @@ -192,7 +192,7 @@ export abstract class RealtimeDoc { } async create(data: T, type?: string): Promise { - void this.adapter.create(data, type).then(() => this.updateOfflineData(true)); + await this.adapter.create(data, type); this.loadOfflineDataPromise = Promise.resolve(); await this.updateOfflineData(true); await this.realtimeService.onLocalDocUpdate(this); From 79ce345fd89f95d640aa3acd0c87b1b63e6dde54 Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 23 Jun 2026 16:36:24 -0600 Subject: [PATCH 22/27] onboarding-export: safely read values before unsubscribe --- .../onboarding-requests-export.service.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 1275ee9bfd0..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 @@ -93,9 +93,13 @@ export class OnboardingRequestsExportService { ): Promise { const sfProjectId = request.submission.projectId; const projectDocSubscription = new DocSubscription('OnboardingRequestsExportService.createSpreadsheetRow'); - const projectDoc = await this.servalAdministrationService.subscribe(sfProjectId, projectDocSubscription); - projectDocSubscription.unsubscribe(); - const projectShortName = projectDoc?.data?.shortName ?? sfProjectId; + 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) ?? '', @@ -116,9 +120,14 @@ export class OnboardingRequestsExportService { const entries = await Promise.all( assigneeIds.map(async assigneeId => { const docSubscription = new DocSubscription('OnboardingRequestsExportService.lookupAssigneeNames'); - const userDoc = await this.userService.get(assigneeId, docSubscription); - docSubscription.unsubscribe(); - return [assigneeId, userDoc?.data?.displayName ?? ''] as const; + 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); From e3f0058d6dda1cce97176c288be48ecaf65961e5 Mon Sep 17 00:00:00 2001 From: MarkS Date: Tue, 23 Jun 2026 17:04:00 -0600 Subject: [PATCH 23/27] Fix how was followed up from rebase --- .../ClientApp/src/app/app.component.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts index 5982f1f1e8c..4a7c104f643 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts @@ -36,7 +36,6 @@ import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { LocationService } from 'xforge-common/location.service'; import { Breakpoint, MediaBreakpointService } from 'xforge-common/media-breakpoints/media-breakpoint.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'; @@ -333,11 +332,7 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest } this.projectFont = this.fontService.getFontFamilyFromProject(this._selectedProjectDoc.data); void this.userService.setCurrentProjectId(this.currentUserDoc!, this._selectedProjectDoc.id); - await this.projectService.getUserConfig( - this._selectedProjectDoc.id, - this.currentUserDoc!.id, - new DocSubscription('AppComponent', this.destroyRef) - ); + if (this.selectedProjectDeleteSub != null) { this.selectedProjectDeleteSub.unsubscribe(); } From 27a3d7e928f379539ea326b0fa6a2c3d28f22c57 Mon Sep 17 00:00:00 2001 From: MarkS Date: Thu, 25 Jun 2026 10:39:41 -0600 Subject: [PATCH 24/27] More fixes from how was followed up from rebase --- .../training-data.service.spec.ts | 33 +++---------------- .../training-data/training-data.service.ts | 20 +++++------ .../src/xforge-common/models/realtime-doc.ts | 2 +- 3 files changed, 14 insertions(+), 41 deletions(-) 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 dc91f77d317..0f55a89f66c 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,36 +88,12 @@ describe('TrainingDataService', () => { await trainingDataService.createTrainingDataAsync(newTrainingData); tick(); - const offlineData = await realtimeService.offlineStore.get( + const trainingDataDoc = await realtimeService.get( TrainingDataDoc.COLLECTION, - getTrainingDataId('project01', 'data03') + getTrainingDataId('project01', 'data03'), + new DocSubscription('spec') ); - expect(offlineData?.data).toEqual(newTrainingData); - })); - - it('should not keep the created training data doc subscribed after create completes', fakeAsync(async () => { - const newTrainingData: TrainingData = { - projectRef: 'project01', - dataId: 'data04', - fileUrl: 'project01/test4.csv', - mimeType: 'text/csv', - skipRows: 0, - title: 'test4.csv', - ownerRef: 'user01', - deleted: false - }; - - // SUT - await trainingDataService.createTrainingDataAsync(newTrainingData); - tick(); - - expect(realtimeService.totalDocCount).toEqual(0); - - const offlineData = await realtimeService.offlineStore.get( - TrainingDataDoc.COLLECTION, - getTrainingDataId('project01', 'data04') - ); - expect(offlineData?.data).toEqual(newTrainingData); + expect(trainingDataDoc.data).toEqual(newTrainingData); })); it('should request deletion via RPC', fakeAsync(async () => { 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 16f1d7b0c8a..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 @@ -16,22 +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); - const docSubscription = new DocSubscription('TrainingDataService.createTrainingDataAsync'); - try { - await this.realtimeService.create( - TrainingDataDoc.COLLECTION, - docId, - trainingData, - docSubscription - ); - } finally { - docSubscription.unsubscribe(); - } + await this.realtimeService.create( + TrainingDataDoc.COLLECTION, + docId, + trainingData, + new DocSubscription('TrainingDataService', this.destroyRef) + ); } async deleteTrainingDataAsync(trainingData: TrainingData): Promise { 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 e81827a1385..2a8275fd23f 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 @@ -192,7 +192,7 @@ export abstract class RealtimeDoc { } async create(data: T, type?: string): Promise { - await this.adapter.create(data, type); + void this.adapter.create(data, type).then(() => this.updateOfflineData(true)); this.loadOfflineDataPromise = Promise.resolve(); await this.updateOfflineData(true); await this.realtimeService.onLocalDocUpdate(this); From b1583b801ba1f17787cf3a669463e492e080bc31 Mon Sep 17 00:00:00 2001 From: MarkS Date: Mon, 29 Jun 2026 11:10:11 -0600 Subject: [PATCH 25/27] checking: add query subscription lifecycle support Loading questions appears to be tied to the lifetime of the requesting component, which may be giving a performance penalty when navigating around the application. (I did a lot of timing experiments, and didn't yet find it as clear cut as one might have hoped.) This patch gives more flexibility for query subscription lifecycle management by letting callers pass essentially a DocSubscription (but called a QuerySubscription) when creating a query. Then the query can stay around longer, such as until switching to another project. Since we already have subscription and resource management in queries, this may not work out entirely as intended so far, but this patch is at least a step. --- .../checking-overview.component.ts | 15 ++--- .../checking/checking-questions.service.ts | 16 ++--- .../checking/checking/checking.component.ts | 10 +-- .../src/xforge-common/models/realtime-doc.ts | 8 +++ .../models/realtime-query.spec.ts | 41 ++++++++++++ .../xforge-common/realtime.service.spec.ts | 46 +++++++++++++- .../src/xforge-common/realtime.service.ts | 63 ++++++++++++------- 7 files changed, 152 insertions(+), 47 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-query.spec.ts 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 3c1c08de5dd..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,12 +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 } from 'xforge-common/models/realtime-doc'; +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'; @@ -78,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; @@ -100,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, @@ -236,11 +238,10 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O this.userService.currentUserId, new DocSubscription('CheckingOverviewComponent', this.destroyRef) ); - this.questionsQuery?.dispose(); this.questionsQuery = await this.checkingQuestionsService.queryQuestions( projectId, { sort: true }, - this.destroyRef + new QuerySubscription('checking-overview/main-questions', this.activatedProjectService.switched$) ); this.initTexts(); } finally { @@ -287,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-questions.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-questions.service.ts index 1556a8a23e8..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,7 +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 } from 'xforge-common/models/realtime-doc'; +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'; @@ -53,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 @@ -79,7 +79,7 @@ export class CheckingQuestionsService { QuestionDoc.COLLECTION, 'query_questions ' + JSON.stringify(options), queryParams, - destroyRef + lifetimeIndicator ); } @@ -88,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; @@ -154,14 +154,14 @@ export class CheckingQuestionsService { QuestionDoc.COLLECTION, 'query_adjacent_questions', queryParams, - destroyRef + lifetimeIndicator ); } async queryFirstUnansweredQuestion( projectId: string, userId: string, - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { const queryParams: QueryParameters = { [obj().pathStr(q => q.projectRef)]: projectId, @@ -181,7 +181,7 @@ export class CheckingQuestionsService { QuestionDoc.COLLECTION, 'query_first_unanswered_question', queryParams, - destroyRef + lifetimeIndicator ); } 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 76f42c746f5..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,12 +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 } from 'xforge-common/models/realtime-doc'; +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'; @@ -209,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, @@ -633,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)) @@ -674,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$) @@ -1782,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/xforge-common/models/realtime-doc.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts index 2a8275fd23f..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 @@ -66,6 +66,14 @@ export class DocSubscription { } } +/** + * 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. 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/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 4be90f3aae2..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 @@ -2,7 +2,7 @@ import { DestroyRef, Injectable, Optional } from '@angular/core'; import { filter, lastValueFrom, race, Subject, take, timer } from 'rxjs'; import { AppError } from 'xforge-common/exception-handling.service'; import { FileService } from './file.service'; -import { DocSubscription, 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'; @@ -10,6 +10,8 @@ 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}`; } @@ -202,12 +204,14 @@ export class RealtimeService { collection: string, name: string, parameters: QueryParameters, - destroyRef: DestroyRef + lifetimeIndicator: DestroyRef | QuerySubscription ): Promise> { - const query = this.createQuery(collection, name, 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 ); } @@ -275,31 +279,31 @@ export class RealtimeService { } /** - * 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()); @@ -307,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()); + }); + } } From 4f6c047ea5013e1fea115dccb6a87a3fa5bc7a64 Mon Sep 17 00:00:00 2001 From: MarkS Date: Wed, 8 Jul 2026 15:39:39 -0600 Subject: [PATCH 26/27] draft: use absolute xforge-common imports in specs --- .../src/app/shared/cache-service/cache.service.spec.ts | 6 ++++-- .../training-data/training-data.service.spec.ts | 2 +- .../editor/lynx/insights/text-doc-reader.spec.ts | 9 +++------ 3 files changed, 8 insertions(+), 9 deletions(-) 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 5d0237a80bc..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 @@ -6,7 +6,7 @@ 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 { DocSubscription } from 'xforge-common/models/realtime-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { PermissionsService } from '../../core/permissions.service'; @@ -371,7 +371,9 @@ class TestEnvironment { const sourceTextDocId = new TextDocId('sourceId', bookNum, chapterNumber); when(mockPermissionsService.canAccessTextAsync(deepEqual(textDocId))).thenResolve(userHasTextPermission); - when(mockPermissionsService.canAccessTextAsync(deepEqual(sourceTextDocId))).thenResolve(userHasSourceTextPermission); + when(mockPermissionsService.canAccessTextAsync(deepEqual(sourceTextDocId))).thenResolve( + userHasSourceTextPermission + ); } texts.push({ bookNum: bookNum, 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 0f55a89f66c..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,7 +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 { DocSubscription } from 'xforge-common/models/realtime-doc'; import { TrainingDataDoc } from '../../../core/models/training-data-doc'; import { TrainingDataService } from './training-data.service'; 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 index 18f1a336cd2..9d099a9652b 100644 --- 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 @@ -1,12 +1,9 @@ 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 { 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'; From ea6c2bf563d678772b7387e63be4695f859ffc3b Mon Sep 17 00:00:00 2001 From: MarkS Date: Wed, 8 Jul 2026 16:03:28 -0600 Subject: [PATCH 27/27] Fix formatting --- .../ClientApp/src/app/core/permissions.service.ts | 5 ++++- .../editor-resource/editor-resource.component.spec.ts | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 feba71aacd7..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 @@ -105,7 +105,10 @@ export class PermissionsService { const docSubscription = new DocSubscription('PermissionsService.canAccessTextAsync'); try { - const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(textDocId.projectId, docSubscription); + const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile( + textDocId.projectId, + docSubscription + ); return this.canAccessText(textDocId, projectDoc.data); } finally { docSubscription.unsubscribe(); 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 6f1daf8d071..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 @@ -65,7 +65,7 @@ describe('EditorResourceComponent', () => { component.projectId = projectId; component.bookNum = 1; component.chapter = 1; -when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); @@ -82,7 +82,7 @@ when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(project id: projectId, data: createTestProjectProfile({ isRightToLeft: true }) } as SFProjectProfileDoc; -when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(rtlProjectDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(rtlProjectDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); @@ -98,7 +98,7 @@ when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(rtlProj id: projectId, data: createTestProjectProfile({ copyrightBanner: 'Test copyright', copyrightNotice: 'Test notice' }) } as SFProjectProfileDoc; -when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectNoticeDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectNoticeDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick(); @@ -112,7 +112,7 @@ when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(project component.projectId = projectId; component.bookNum = 1; component.chapter = 1; -when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); + when(mockSFProjectService.getProfile(projectId, anything())).thenResolve(projectDoc); component['initProjectDetails'](); component.resourceText.editorCreated.next(); tick();