diff --git a/src/RealtimeServer/scriptureforge/models/sf-project-test-data.ts b/src/RealtimeServer/scriptureforge/models/sf-project-test-data.ts index a7b6626d82f..aeaf2ba769a 100644 --- a/src/RealtimeServer/scriptureforge/models/sf-project-test-data.ts +++ b/src/RealtimeServer/scriptureforge/models/sf-project-test-data.ts @@ -1,7 +1,7 @@ import merge from 'lodash/merge'; import { RecursivePartial } from '../../common/utils/type-utils'; import { CheckingAnswerExport } from './checking-config'; -import { SFProject, SFProjectProfile } from './sf-project'; +import { EditingRequires, SFProject, SFProjectProfile } from './sf-project'; function testProjectProfile(ordinal: number): SFProjectProfile { return { @@ -44,7 +44,8 @@ function testProjectProfile(ordinal: number): SFProjectProfile { biblicalTermsEnabled: false, hasRenderings: false }, - editable: true, + editable: false, + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, defaultFontSize: 12, defaultFont: 'Charis SIL', maxGeneratedUsersPerShareKey: 250 diff --git a/src/RealtimeServer/scriptureforge/models/sf-project.ts b/src/RealtimeServer/scriptureforge/models/sf-project.ts index 271c26475c9..a91e9d31ceb 100644 --- a/src/RealtimeServer/scriptureforge/models/sf-project.ts +++ b/src/RealtimeServer/scriptureforge/models/sf-project.ts @@ -42,6 +42,7 @@ export interface SFProjectProfile extends Project { noteTags?: NoteTag[]; sync: Sync; editable: boolean; + editingRequires: EditingRequires; defaultFontSize?: number; defaultFont?: string; maxGeneratedUsersPerShareKey?: number; @@ -66,3 +67,26 @@ export function isResource(project: SFProjectProfile): boolean { const resourceIdLength: number = DBL_RESOURCE_ID_LENGTH; return project.paratextId.length === resourceIdLength; } + +/** + * A bitwise-flag enumeration to represent what frontend features are required to edit this project's text documents. + * + * To add more required features, add as follows: + * + * FutureFeatureA = 1 << 2, // 4 + * FutureFeatureB = 1 << 3, // 8 + * + * NOTE: Adding a new required feature and migrating editingRequires to include it will block older editors. + * The new required featured should be added to the MaxSupportedEditingRequiresValue below. + */ +export enum EditingRequires { + ParatextEditingEnabled = 1 << 0, // 1 + ViewModelBlankSupport = 1 << 1 // 2 +} + +/** + * This value is by the frontend to determine if a feature has been added + * which should disable editing on the frontend until it is updated. + */ +export const MaxSupportedEditingRequiresValue: EditingRequires = + EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport; diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts index 199c21766cb..fcbe16606b6 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts @@ -627,6 +627,69 @@ describe('SFProjectMigrations', () => { expect(projectDoc.data.translateConfig.draftConfig.additionalTrainingData).toBeUndefined(); }); }); + + describe('version 25', () => { + it('migrates editable to editingRequires when true', async () => { + const env = new TestEnvironment(24); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + editable: true + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBe(true); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBe(false); + expect(projectDoc.data.editingRequires).toBe(3); + }); + + it('migrates editable to editingRequires when false', async () => { + const env = new TestEnvironment(24); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + editable: false + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBe(false); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBe(false); + expect(projectDoc.data.editingRequires).toBe(2); + }); + + it('migrates editable to editingRequires when null', async () => { + const env = new TestEnvironment(24); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', {}); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBeUndefined(); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBe(false); + expect(projectDoc.data.editingRequires).toBe(3); + }); + + it('does not remigrate editingRequires', async () => { + const env = new TestEnvironment(24); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { editingRequires: 6 }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBeUndefined(); + expect(projectDoc.data.editingRequires).toBe(6); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.editable).toBeUndefined(); + expect(projectDoc.data.editingRequires).toBe(6); + }); + }); }); class TestEnvironment { diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts index 2b8b5fe5e3f..7877a362529 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts @@ -4,6 +4,7 @@ import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationLis import { Operation } from '../../common/models/project-rights'; import { submitMigrationOp } from '../../common/realtime-server'; import { NoteTag } from '../models/note-tag'; +import { EditingRequires } from '../models/sf-project'; import { SF_PROJECT_RIGHTS, SFProjectDomain } from '../models/sf-project-rights'; import { SFProjectRole } from '../models/sf-project-role'; import { TextInfoPermission } from '../models/text-info-permission'; @@ -453,6 +454,39 @@ class SFProjectMigration24 extends DocMigration { } } +class SFProjectMigration25 extends DocMigration { + static readonly VERSION = 25; + + async migrateDoc(doc: Doc): Promise { + const ops: Op[] = []; + + if (doc.data.editingRequires == null) { + const editable: boolean = doc.data.editable !== false; + if (doc.data.editable == null) { + ops.push({ + p: ['editable'], + oi: false + }); + } else if (doc.data.editable == true) { + ops.push({ + p: ['editable'], + od: true, + oi: false + }); + } + + ops.push({ + p: ['editingRequires'], + oi: (editable ? EditingRequires.ParatextEditingEnabled : 0) | EditingRequires.ViewModelBlankSupport + }); + } + + if (ops.length > 0) { + await submitMigrationOp(SFProjectMigration25.VERSION, doc, ops); + } + } +} + export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ SFProjectMigration1, SFProjectMigration2, @@ -477,5 +511,6 @@ export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncrea SFProjectMigration21, SFProjectMigration22, SFProjectMigration23, - SFProjectMigration24 + SFProjectMigration24, + SFProjectMigration25 ]); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts index ab7ee655b4a..1628836456e 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts @@ -27,6 +27,7 @@ const SF_PROJECT_PROFILE_FIELDS: ShareDB.ProjectionFields = { isRightToLeft: true, biblicalTermsConfig: true, editable: true, + editingRequires: true, defaultFontSize: true, defaultFont: true, translateConfig: true, @@ -513,6 +514,9 @@ export class SFProjectService extends ProjectService { editable: { bsonType: 'bool' }, + editingRequires: { + bsonType: 'int' + }, defaultFontSize: { bsonType: 'int' }, 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 ad5cf30fe0a..2d05b06ff5d 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 @@ -490,6 +490,7 @@ describe('CheckingComponent', () => { env.setBookChapter('JHN', 2); env.fixture.detectChanges(); expect(env.component.questionsList!.activeQuestionDoc!.data!.dataId).toBe('q15Id'); + tick(); flush(); discardPeriodicTasks(); })); @@ -3847,19 +3848,15 @@ class TestEnvironment { private createTextDataForChapter(chapter: number): TextData { const delta = new Delta(); delta.insert({ chapter: { number: chapter.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert(`target: chapter ${chapter}, verse 1.`, { segment: `verse_${chapter}_1` }); delta.insert({ verse: { number: '2', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${chapter}_2` }); delta.insert('\n', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: `verse_${chapter}_2/p_1` }); delta.insert({ verse: { number: '3', style: 'v' } }); delta.insert(`target: chapter ${chapter}, verse 3.`, { segment: `verse_${chapter}_3` }); delta.insert({ verse: { number: '4', style: 'v' } }); delta.insert(`target: chapter ${chapter}, verse 4.`, { segment: `verse_${chapter}_4` }); delta.insert('\n', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: `verse_${chapter}_4/p_1` }); delta.insert({ verse: { number: '5', style: 'v' } }); delta.insert(`target: chapter ${chapter}, `, { segment: `verse_${chapter}_5` }); delta.insert('\n', { para: { style: 'p' } }); 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 89fd8dbb415..d80fe5f5370 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 @@ -1,5 +1,6 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { Delta } from 'quill'; +import { EditingRequires } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; 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 { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; @@ -79,7 +80,7 @@ describe('TextDocService', () => { it('should return true if the project and user are correctly configured', () => { const env = new TestEnvironment(); const project = createTestProjectProfile({ - editable: true, + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, sync: { dataInSync: true }, texts: [ { bookNum: 1, chapters: [{ number: 1, isValid: true, permissions: { user01: TextInfoPermission.Write } }] } @@ -105,7 +106,7 @@ describe('TextDocService', () => { it('should return false if user does not have general edit right', () => { const env = new TestEnvironment(); const project = createTestProjectProfile({ - editable: true, + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, sync: { dataInSync: true }, texts: [ { bookNum: 1, chapters: [{ number: 1, isValid: true, permissions: { user01: TextInfoPermission.Write } }] } @@ -121,7 +122,7 @@ describe('TextDocService', () => { it('should return false if user does not have chapter edit permission', () => { const env = new TestEnvironment(); const project = createTestProjectProfile({ - editable: true, + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, sync: { dataInSync: true }, texts: [ { bookNum: 1, chapters: [{ number: 1, isValid: true, permissions: { user01: TextInfoPermission.Read } }] } @@ -137,7 +138,7 @@ describe('TextDocService', () => { it('should return false if data is not in sync', () => { const env = new TestEnvironment(); const project = createTestProjectProfile({ - editable: true, + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, sync: { dataInSync: false }, texts: [ { bookNum: 1, chapters: [{ number: 1, isValid: true, permissions: { user01: TextInfoPermission.Write } }] } @@ -153,7 +154,7 @@ describe('TextDocService', () => { it('should return false if editing is disabled', () => { const env = new TestEnvironment(); const project = createTestProjectProfile({ - editable: false, + editingRequires: EditingRequires.ViewModelBlankSupport, sync: { dataInSync: true }, texts: [ { bookNum: 1, chapters: [{ number: 1, isValid: true, permissions: { user01: TextInfoPermission.Write } }] } @@ -169,7 +170,7 @@ describe('TextDocService', () => { it('should return true if all conditions are met', () => { const env = new TestEnvironment(); const project = createTestProjectProfile({ - editable: true, + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, sync: { dataInSync: true }, texts: [ { bookNum: 1, chapters: [{ number: 1, isValid: true, permissions: { user01: TextInfoPermission.Write } }] } @@ -241,7 +242,9 @@ describe('TextDocService', () => { it('should return false if the project is editable', () => { const env = new TestEnvironment(); - const project = createTestProjectProfile({ editable: true }); + const project = createTestProjectProfile({ + editingRequires: EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport + }); // SUT const actual: boolean = env.textDocService.isEditingDisabled(project); @@ -250,7 +253,29 @@ describe('TextDocService', () => { it('should return true if the project is not editable', () => { const env = new TestEnvironment(); - const project = createTestProjectProfile({ editable: false }); + const project = createTestProjectProfile({ editingRequires: EditingRequires.ViewModelBlankSupport }); + + // SUT + const actual: boolean = env.textDocService.isEditingDisabled(project); + expect(actual).toBe(true); + }); + + it('should return true if the project is has been upgraded to a version beyond the supported version', () => { + const env = new TestEnvironment(); + const project = createTestProjectProfile({ + editingRequires: Number.MAX_SAFE_INTEGER + }); + + // SUT + const actual: boolean = env.textDocService.isEditingDisabled(project); + expect(actual).toBe(true); + }); + + it('should return true if the project is has not been upgraded to view model support', () => { + const env = new TestEnvironment(); + const project = createTestProjectProfile({ + editingRequires: EditingRequires.ParatextEditingEnabled + }); // SUT const actual: boolean = env.textDocService.isEditingDisabled(project); 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 2a56ec96d90..08cafe9c8fd 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 @@ -1,7 +1,11 @@ import { Injectable } from '@angular/core'; import { Delta } from 'quill'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; -import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { + EditingRequires, + MaxSupportedEditingRequiresValue, + SFProjectProfile +} from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; @@ -103,6 +107,16 @@ export class TextDocService { return project?.sync?.dataInSync !== false; } + /** + * Determines if an update is required to allow editing. + * + * @param {SFProjectProfile | undefined} project The project. + * @returns {boolean} A value indicating whether the app must be updated. + */ + isUpdateRequired(project: SFProjectProfile | undefined): boolean { + return (project?.editingRequires ?? 0) > MaxSupportedEditingRequiresValue; + } + /** * Determines if editing is disabled for a project. * @@ -110,7 +124,12 @@ export class TextDocService { * @returns {boolean} A value indicating whether editing is disabled for the project. */ isEditingDisabled(project: SFProjectProfile | undefined): boolean { - return project?.editable === false; + return ( + project != null && + (this.isUpdateRequired(project) || + (project.editingRequires & EditingRequires.ViewModelBlankSupport) !== EditingRequires.ViewModelBlankSupport || + (project.editingRequires & EditingRequires.ParatextEditingEnabled) !== EditingRequires.ParatextEditingEnabled) + ); } /** diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts index 82a9fbc77bd..dfab03cd64d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts @@ -5,45 +5,81 @@ import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-dat import { TextDocId } from '../core/models/text-doc'; import { RIGHT_TO_LEFT_MARK } from './utils'; -export function getTextDoc(id: TextDocId): TextData { +/* USFM: +\s Title for chapter 1 +\c 1 +\p +\v 1 target: chapter 1, verse 1. +\v 2 +\v 3 target: chapter 1, verse 3. +\v 4 target: chapter 1, verse 4. +\p +\v 5 target: chapter 1, +\p +\v 6 target: chapter 1, verse 6. +\p +\v 7 target: chapter 1, verse 7. +\p target: chapter 1, verse 7 - 2nd paragraph. +*/ +export function getTextDoc(id: TextDocId, modelHasBlanks: boolean = false): TextData { const delta = new Delta(); delta.insert(`Title for chapter ${id.chapterNum}`, { segment: 's_1' }); delta.insert('\n', { para: { style: 's' } }); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 1.`, { segment: `verse_${id.chapterNum}_1` }); delta.insert({ verse: { number: '2', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_2` }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_2` }); delta.insert({ verse: { number: '3', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 3.`, { segment: `verse_${id.chapterNum}_3` }); delta.insert({ verse: { number: '4', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 4.`, { segment: `verse_${id.chapterNum}_4` }); delta.insert('\n', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_4/p_1` }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_4/p_1` }); delta.insert({ verse: { number: '5', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, `, { segment: `verse_${id.chapterNum}_5` }); delta.insert('\n', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_5/p_1` }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_5/p_1` }); delta.insert({ verse: { number: '6', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 6. `, { segment: `verse_${id.chapterNum}_6` }); delta.insert('\n', { para: { style: 'p' } }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_6/p_1` }); + delta.insert({ verse: { number: '7', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 7.`, { segment: `verse_${id.chapterNum}_7` }); delta.insert('\n', { para: { style: 'p' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 7 - 2nd paragraph.`, { segment: `verse_${id.chapterNum}_7/p_1` }); + delta.insert('\n', { para: { style: 'p' } }); return delta; } -export function getCombinedVerseTextDoc(id: TextDocId, rtl: boolean = false): TextData { +/* USFM +\s Title for chapter 1 +\c 1 +\p +\v 1 target: chapter 1, verse 1. +\v 2-3 target: chapter 1, verse 2-3. +\s Text in section heading +\p +\v 4 target: chapter 1, verse 4. +\v 5,7 target: chapter 1, verse 5,7. +\v 6a target: chapter 1, verse 6a. +\v 6b target: chapter 1, verse 6b. +*/ +export function getCombinedVerseTextDoc( + id: TextDocId, + modelHasBlanks: boolean = false, + rtl: boolean = false +): TextData { const verse2Str: string = rtl ? `2${RIGHT_TO_LEFT_MARK}-3` : '2-3'; const verse5Str: string = rtl ? `5${RIGHT_TO_LEFT_MARK},7` : '5,7'; const delta = new Delta(); delta.insert(`Title for chapter ${id.chapterNum}`, { segment: 's_1' }); delta.insert('\n', { para: { style: 's' } }); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 1.`, { segment: `verse_${id.chapterNum}_1` }); delta.insert({ verse: { number: verse2Str, style: 'v' } }); @@ -53,7 +89,7 @@ export function getCombinedVerseTextDoc(id: TextDocId, rtl: boolean = false): Te delta.insert('\n', { para: { style: 'p' } }); delta.insert('Text in section heading', { segment: 's_2' }); delta.insert('\n', { para: { style: 's' } }); - delta.insert({ blank: true }, { segment: 'p_2' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'p_2' }); delta.insert({ verse: { number: '4', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 4.`, { segment: `verse_${id.chapterNum}_4` }); delta.insert({ verse: { number: verse5Str, style: 'v' } }); @@ -68,44 +104,66 @@ export function getCombinedVerseTextDoc(id: TextDocId, rtl: boolean = false): Te return delta; } -export function getPoetryVerseTextDoc(id: TextDocId): TextData { +/* USFM: +\s Title for chapter 1 +\c 1 +\q +\v 1 Poetry first line +\q Poetry second line +\b +\q Poetry third line +\q Poetry fourth line +*/ +export function getPoetryVerseTextDoc(id: TextDocId, modelHasBlanks: boolean = false): TextData { const delta = new Delta(); delta.insert(`Title for chapter ${id.chapterNum}`, { segment: 's_1' }); delta.insert('\n', { para: { style: 's' } }); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'q_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'q_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert('Poetry first line', { segment: `verse_${id.chapterNum}_1` }); delta.insert('\n', { para: { style: 'q' } }); delta.insert('Poetry second line', { segment: `verse_${id.chapterNum}_1/q_1` }); delta.insert('\n', { para: { style: 'q' } }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_1/b_2` }); delta.insert('\n', { para: { style: 'b' } }); - delta.insert('Poetry third line', { segment: `verse_${id.chapterNum}_1/q_2` }); + delta.insert('Poetry third line', { segment: `verse_${id.chapterNum}_1/q_3` }); delta.insert('\n', { para: { style: 'q' } }); - delta.insert('Poetry fourth line.', { segment: `verse_${id.chapterNum}_1/q_3` }); + delta.insert('Poetry fourth line.', { segment: `verse_${id.chapterNum}_1/q_4` }); delta.insert('\n', { para: { style: 'q' } }); return delta; } -export function getEmptyChapterDoc(id: TextDocId): TextData { +/* USFM: +\s +\c 1 +\s +\p +\v 1 +\v 2 +\s +\p +\v 3 +*/ +export function getEmptyChapterDoc(id: TextDocId, modelHasBlanks: boolean): TextData { const delta = new Delta(); - delta.insert({ blank: true }, { segment: 's_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 's_1' }); delta.insert('\n', { para: { style: 's' } }); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 's_2' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 's_2' }); delta.insert('\n', { para: { style: 's' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); - delta.insert({ blank: true }, { segment: 'verse_1_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'verse_1_1' }); delta.insert({ verse: { number: '2', style: 'v' } }); - delta.insert({ blank: true }, { segment: 'verse_1_2' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'verse_1_2' }); delta.insert('\n', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: 's_3' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 's_3' }); delta.insert('\n', { para: { style: 's' } }); - delta.insert({ blank: true }, { segment: 'p_2' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'p_2' }); delta.insert({ verse: { number: '3', style: 'v' } }); - delta.insert({ blank: true }, { segment: 'verse_1_3' }); - delta.insert('\n', { para: { style: 'p' } }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'verse_1_3' }); + delta.insert('\n\n', { para: { style: 'p' } }); return delta; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blots.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blots.ts index b70c06cfd0d..fba4f8d9d50 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blots.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blots.ts @@ -131,12 +131,20 @@ export class BlankEmbed extends QuillEmbedBlot { static create(value: boolean): Node { const node = super.create(value) as HTMLElement; + setUsxValue(node, value); node.innerText = NBSP.repeat(8); return node; } - static value(_node: HTMLElement): boolean { - return true; + static value(node: HTMLElement): boolean { + return getUsxValue(node); + } + + value(): any { + // The base implementation will always return true, so we override it to allow { blank: false } + return { + [this.statics.blotName]: this.statics.value(this.domNode) + }; } static formats(node: HTMLElement): any { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-history.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-history.spec.ts index 84c92cffe21..94c08f663f4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-history.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-history.spec.ts @@ -56,12 +56,12 @@ describe('Quill history', () => { }, { name: 'mixed text and embeds with trailing text', - delta: new Delta().insert({ blank: true }).insert('text').insert({ image: 'test.jpg' }).insert('more'), + delta: new Delta().insert({ image: 'test1.jpg' }).insert('text').insert({ image: 'test.jpg' }).insert('more'), expected: 10 }, { name: 'mixed text and embeds with trailing embed', - delta: new Delta().insert({ blank: true }).insert('text').insert({ image: 'test.jpg' }), + delta: new Delta().insert({ image: 'test1.jpg' }).insert('text').insert({ image: 'test.jpg' }), expected: 5 }, { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts index eb2ed09a0ab..451c046efbb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts @@ -44,8 +44,8 @@ const PARA_STYLES: Set = new Set([ 'lf', 'lim', - // Book - 'id' + // Should not contain verse text, but sometimes do + 'b' ]); function canParaContainVerseText(style: string): boolean { @@ -88,6 +88,8 @@ export interface EditorRange { leadingEmbedCount: number; /** Count of sequential embeds immediately following the range */ trailingEmbedCount: number; + /** Count of the blanks in the range. */ + blanksWithinRange: number; } /** Represents the position of an embed. */ @@ -128,7 +130,6 @@ class SegmentInfo { export class TextViewModel implements OnDestroy, LynxTextModelConverter { editor?: Quill; - private readonly _segments: Map = new Map(); private changesSub?: Subscription; private onCreateSub?: Subscription; private textDoc?: TextDoc; @@ -137,7 +138,8 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { * A mapping of IDs of elements embedded into the quill editor to their positions. * These elements are in addition to the text data i.e. Note threads */ - private _embeddedElements: Map = new Map(); + private _embeddedElements = new Map(); + private _segments = new Map(); segments$ = new BehaviorSubject>(this._segments); @@ -177,6 +179,14 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { return this.textDoc?.isLoaded === true && this.textDoc.data?.ops != null && isBadDelta(this.textDoc.data.ops); } + private get blankPositions(): number[] { + return this.embeddedElementPositions( + Array.from(this._embeddedElements.entries()) + .filter(([key, _]) => key.startsWith('blank_')) + .map(([_, value]) => value) + ); + } + private get embedPositions(): number[] { return this.embeddedElementPositions(Array.from(this._embeddedElements.values())); } @@ -194,19 +204,25 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { this.textDocId = textDocId; this.textDoc = textDoc; - editor.setContents(this.textDoc.data as Delta); + const deltaWithBlanks: Delta = this.addBlanksToDelta(textDoc.data as Delta); + editor.setContents(deltaWithBlanks); editor.history.clear(); if (subscribeToUpdates) { - this.changesSub = this.textDoc.remoteChanges$.subscribe(ops => { - const deltaWithEmbeds: Delta = this.addEmbeddedElementsToDelta(ops as Delta); - editor.updateContents(deltaWithEmbeds, 'api'); + this.changesSub = textDoc.remoteChanges$.subscribe(() => { + // Transform the editor rather than use the incoming ops + const oldEditor: Delta = editor.getContents(); + const newData: Delta = textDoc.data as Delta; + const newEditor: Delta = this.addBlanksToDelta(newData as Delta); + const updateDelta = oldEditor.diff(newEditor); + editor.updateContents(updateDelta, 'api'); }); } - this.onCreateSub = this.textDoc.create$.subscribe(() => { + this.onCreateSub = textDoc.create$.subscribe(() => { if (textDoc.data != null) { - editor.setContents(textDoc.data as Delta); + const deltaWithBlanks: Delta = this.addBlanksToDelta(textDoc.data as Delta); + editor.setContents(deltaWithBlanks); } editor.history.clear(); }); @@ -229,9 +245,8 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { * * @param {Delta} delta The view model delta. * @param {EmitterSource} source The source of the change. - * @param {boolean} isOnline Whether the user is online. */ - update(delta: Delta, source: EmitterSource, isOnline: boolean): void { + update(delta: Delta, source: EmitterSource): void { const editor = this.checkEditor(); if (this.textDoc == null) { return; @@ -246,11 +261,11 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { } // Re-compute segment boundaries so the insertion point stays in the right place. - this.updateSegments(editor, isOnline); + this.updateSegments(editor, undefined); // Defer the update, since it might cause the segment ranges to be out-of-sync with the view model Promise.resolve().then(() => { - const updateDelta = this.updateSegments(editor, isOnline); + const updateDelta = this.updateSegments(editor, undefined); if (updateDelta.ops != null && updateDelta.ops.length > 0) { // Clean up blanks in quill editor. This may result in re-entering the update() method. editor.updateContents(updateDelta, source); @@ -470,27 +485,42 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { /** Returns editor range information that corresponds to a text position past an editor position. */ getEditorContentRange(startEditorPosition: number, textPosPast: number): EditorRange { + // Cache these getters so they are not recalculated each time + const blankEditorPositions: number[] = this.blankPositions; const embedEditorPositions: number[] = this.embedPositions; - const leadingEmbedCount: number = this.countSequentialEmbedsStartingAt(startEditorPosition); + + // Get the leading number of embeds in the range. This will include the number of blanks + const leadingEmbedCount: number = this.countSequentialEmbedsStartingAt(startEditorPosition, embedEditorPositions); + let embedsWithinRange = leadingEmbedCount; let resultingEditorPos = startEditorPosition + leadingEmbedCount; + + // Get the leading number of blanks in the range. This is a subset of embedsWithinRange. + const leadingBlankCount: number = this.countSequentialEmbedsStartingAt(startEditorPosition, blankEditorPositions); + let blanksWithinRange = leadingBlankCount; + + // Get number of characters, embeds, and blanks inside the range let textCharactersFound = 0; - let embedsWithinRange = leadingEmbedCount; while (textCharactersFound < textPosPast) { if (!embedEditorPositions.includes(resultingEditorPos)) { textCharactersFound++; } else { embedsWithinRange++; + if (blankEditorPositions.includes(resultingEditorPos)) { + blanksWithinRange++; + } } + resultingEditorPos++; } // trailing embeds do not count towards embedsWithinRange - const trailingEmbedCount: number = this.countSequentialEmbedsStartingAt(resultingEditorPos); + const trailingEmbedCount: number = this.countSequentialEmbedsStartingAt(resultingEditorPos, embedEditorPositions); return { startEditorPosition, editorLength: resultingEditorPos - startEditorPosition, embedsWithinRange, leadingEmbedCount, - trailingEmbedCount + trailingEmbedCount, + blanksWithinRange }; } @@ -594,8 +624,7 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { return this.addEmbeddedElementsToDelta(dataDelta); } - private countSequentialEmbedsStartingAt(startEditorPosition: number): number { - const embedEditorPositions = this.embedPositions; + private countSequentialEmbedsStartingAt(startEditorPosition: number, embedEditorPositions: number[]): number { // add up the leading embeds let leadingEmbedCount = 0; while (embedEditorPositions.includes(startEditorPosition + leadingEmbedCount)) { @@ -634,7 +663,7 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { } (modelDelta as any).push(modelOp); } - // Remove Paratext notes from model delta + // Remove blanks and Paratext notes from model delta modelDelta = this.removeEmbeddedElementsFromDelta(modelDelta); } return modelDelta.chop(); @@ -644,16 +673,17 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { * Re-generate segment boundaries from quill editor ops. Return ops to clean up where and whether blanks are * represented. */ - private updateSegments(editor: Quill, isOnline: boolean): Delta { + private updateSegments(editor?: Quill, delta?: Delta): Delta { const convertDelta = new Delta(); let fixDelta = new Delta(); let fixOffset = 0; - const delta = editor.getContents(); - this._segments.clear(); - this._embeddedElements.clear(); - if (delta.ops == null) { + delta ??= editor?.getContents(); + if (delta?.ops == null) { return convertDelta; } + + const segments = new Map(); + const embeddedElements = new Map(); const nextIds = new Map(); let paraSegments: SegmentInfo[] = []; let chapter = ''; @@ -687,12 +717,12 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { } for (const paraSegment of paraSegments) { - if (this._segments.has(paraSegment.ref) && paraSegment.ref.startsWith('verse')) { + if (segments.has(paraSegment.ref) && paraSegment.ref.startsWith('verse')) { paraSegment.ref = getParagraphRef(nextIds, paraSegment.ref, paraSegment.ref + '/' + style); } - [fixDelta, fixOffset] = this.fixSegment(editor, paraSegment, fixDelta, fixOffset, isOnline); - this._segments.set(paraSegment.ref, { index: paraSegment.index, length: paraSegment.length }); + [fixDelta, fixOffset] = this.fixSegment(editor, paraSegment, fixDelta, fixOffset); + segments.set(paraSegment.ref, { index: paraSegment.index, length: paraSegment.length }); } paraSegments = []; curIndex++; @@ -712,12 +742,15 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { // title/header curSegment ??= new SegmentInfo('', curIndex); curSegment.ref = getParagraphRef(nextIds, style, style); - [fixDelta, fixOffset] = this.fixSegment(editor, curSegment, fixDelta, fixOffset, isOnline); - this._segments.set(curSegment.ref, { index: curSegment.index, length: curSegment.length }); + [fixDelta, fixOffset] = this.fixSegment(editor, curSegment, fixDelta, fixOffset); + segments.set(curSegment.ref, { index: curSegment.index, length: curSegment.length }); paraSegments = []; curIndex += curSegment.length + len; curSegment = undefined; } + } else if (op.delete != null || op.retain != null) { + // Corrupt op + continue; } else if ((op.insert as any).chapter != null) { // chapter chapter = (op.insert as any).chapter.number; @@ -747,6 +780,24 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { } curSegment.length += len; if ((op.insert as any)?.blank != null) { + // record the presence of a blank in the segment, if it was generated by the view model + if ((op.insert as any)?.blank === false) { + const position: number = curIndex + curSegment.length - 1; + const id = `blank_${position}`; + let embedPosition: EmbedPosition | undefined = embeddedElements.get(id); + if (embedPosition == null) { + embedPosition = { position }; + embeddedElements.set(id, embedPosition); + } else { + if (embedPosition.duplicatePosition != null) { + console.warn( + 'Warning: text-view-model.updateSegments() did not expect to encounter an embed with >2 positions' + ); + } + embedPosition.duplicatePosition = position; + } + } + curSegment.containsBlank = true; if (op.attributes != null && op.attributes['initial'] === true) { curSegment.hasInitialFormat = true; @@ -754,11 +805,11 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { } else if (op.insert != null && op.insert['note-thread-embed'] != null) { // record the presence of an embedded note in the segment const id: string | undefined = op.attributes?.['threadid'] as string | undefined; - let embedPosition: EmbedPosition | undefined = id == null ? undefined : this._embeddedElements.get(id); + let embedPosition: EmbedPosition | undefined = id == null ? undefined : embeddedElements.get(id); const position: number = curIndex + curSegment.length - 1; if (embedPosition == null && id != null) { embedPosition = { position }; - this._embeddedElements.set(id, embedPosition); + embeddedElements.set(id, embedPosition); } else { if (embedPosition != null) { if (embedPosition.duplicatePosition != null) { @@ -775,46 +826,55 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { convertDelta.retain(len, attrs); } - this.segments$.next(this._segments); - + const updateViewModel: boolean = editor != null; + if (updateViewModel) { + this._segments.clear(); + this._segments = segments; + this._embeddedElements = embeddedElements; + this.segments$.next(this._segments); + } return convertDelta.compose(fixDelta).chop(); } /** Computes and adds to `fixDelta` a change to add or remove a blank indication as needed on `segment`, and other * fixes. */ private fixSegment( - editor: Quill, + editor: Quill | undefined, segment: SegmentInfo, fixDelta: Delta, - fixOffset: number, - isOnline: boolean + fixOffset: number ): [Delta, number] { // inserting blank embeds onto text docs while offline creates a scenario where quill misinterprets // the diff delta and can cause merge issues when returning online and duplicating verse segments - if (segment.length - segment.notesCount === 0 && isOnline) { + if (segment.length - segment.notesCount === 0) { // insert blank const delta = new Delta(); // insert blank after any existing notes - delta.retain(segment.index + segment.notesCount + fixOffset); + const position = segment.index + fixOffset + segment.notesCount; + delta.retain(position); const attrs: any = { segment: segment.ref, 'para-contents': true, 'direction-segment': 'auto' }; if (segment.isInitial) { attrs.initial = true; } - delta.insert({ blank: true }, attrs); + delta.insert({ blank: false }, attrs); + segment.containsBlank = true; fixDelta = fixDelta.compose(delta); fixOffset++; } else if (segment.containsBlank && segment.length - segment.notesCount > 1) { // The segment contains a blank and there is text other than translation notes // delete blank - const delta = new Delta().retain(segment.index + fixOffset + segment.notesCount).delete(1); + const position = segment.index + fixOffset + segment.notesCount; + const delta = new Delta().retain(position).delete(1); fixDelta = fixDelta.compose(delta); fixOffset--; - const sel = editor.getSelection(); + + // Update the selection + const sel = editor?.getSelection(); if (sel != null && sel.index === segment.index && sel.length === 0) { // if the segment is no longer blank, ensure that the selection is at the end of the segment. // Sometimes after typing in a blank segment, the selection will be at the beginning. This seems to be a bug // in Quill. - Promise.resolve().then(() => editor.setSelection(segment.index + segment.length - 1, 0, 'user')); + Promise.resolve().then(() => editor?.setSelection(segment.index + segment.length - 1, 0, 'user')); } } else if (segment.containsBlank && segment.length === 1 && !segment.hasInitialFormat && segment.isInitial) { const delta = new Delta(); @@ -890,7 +950,11 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { if (cloneOp.delete < 1) { cloneOp = undefined; } - } else if (cloneOp.insert != null && cloneOp.insert['note-thread-embed'] != null) { + } else if ( + cloneOp.insert != null && + // Only remove notes or the view model created blanks + (cloneOp.insert['note-thread-embed'] != null || cloneOp.insert['blank'] === false) + ) { cloneOp = undefined; } @@ -902,6 +966,117 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { return adjustedDelta; } + /** + * Adds blanks to deltas. This must be done before adding embedded elements to give the correct segments to reference. + * @param modelDelta The model delta. + * @returns The view model delta. + */ + private addBlanksToDelta(modelDelta: Delta): Delta { + if (modelDelta == null || modelDelta.ops == null || modelDelta.ops.length < 1) { + return new Delta(); + } + + const nextIds = new Map(); + let chapter = ''; + let curRef = ''; + const segmentsWithContent: string[] = []; + const adjustedDelta = new Delta(); + const ops = cloneDeep(modelDelta.ops); + for (let i = 0; i < ops.length; i++) { + const op: DeltaOperation = cloneDeep(ops[i]); + let newCurRef = ''; + + // Split two or more \n characters into multiple ops we will iterate over + if (typeof op.insert === 'string' && /^\n{2,}$/.test(op.insert)) { + const newlineCount = op.insert.length; + + // Change current op to just the first \n, and insert additional ops after the current op + op.insert = '\n'; + const extraOps = Array.from({ length: newlineCount - 1 }, () => ({ + insert: '\n', + attributes: op.attributes + })); + + ops.splice(i + 1, 0, ...extraOps); + } + + if (op.insert === '\n' || op.attributes?.para != null || op.attributes?.book != null) { + const style: string = (op.attributes?.para ?? (op.attributes?.book as any))?.style ?? 'p'; + if (op.attributes?.table != null && op.attributes?.row != null && op.attributes?.cell != null) { + // table cell + newCurRef = + (op.attributes.row as any).id.replace('row', 'cell') + + '_' + + (op.attributes.cell as any).style[(op.attributes.cell as any).style.length - 1]; + + // Insert a blank if the new cur ref does not have content + if (newCurRef !== '' && !segmentsWithContent.includes(newCurRef)) { + adjustedDelta.insert({ blank: false }, { segment: newCurRef }); + segmentsWithContent.push(newCurRef); + } + } else if (canParaContainVerseText(style)) { + // paragraph + if (curRef !== '') { + newCurRef = curRef; + const slashIndex = curRef.indexOf('/'); + if (slashIndex !== -1) newCurRef = newCurRef.substring(0, slashIndex); + newCurRef = getParagraphRef(nextIds, newCurRef, newCurRef + '/' + style); + } else { + newCurRef = getParagraphRef(nextIds, style, style); + } + } else { + // blank line or title/header + newCurRef = getParagraphRef(nextIds, style, style); + + // Insert a blank if the new cur ref does not have content + if (newCurRef !== '' && !segmentsWithContent.includes(newCurRef)) { + adjustedDelta.insert({ blank: false }, { segment: newCurRef }); + segmentsWithContent.push(newCurRef); + } + } + } else if ((op.insert as any)?.chapter != null) { + // chapter + chapter = (op.insert as any)?.chapter.number; + newCurRef = ''; + } else if ((op.insert as any)?.verse != null) { + // verse + newCurRef = 'verse_' + chapter + '_' + (op.insert as any).verse.number; + } else { + // segment - ignore + if ((op.attributes as any)?.segment == null) { + // This is an op to be applied to a delta + } else { + // If we have content for the cur ref, ensure we are tracking the right reference + curRef = (op.attributes as any).segment; + segmentsWithContent.push(curRef); + newCurRef = curRef; + } + } + + if (newCurRef !== curRef) { + // NOTE: The blank must go before if the para has no content, or after if the para has content + + // Insert a blank if the cur ref does not have content + if (!segmentsWithContent.includes(curRef) && curRef !== '') { + adjustedDelta.insert({ blank: false }, { segment: curRef }); + segmentsWithContent.push(curRef); + } + + curRef = newCurRef; + } + + adjustedDelta.push(op); + } + + // Update the segments + const updateDelta = this.updateSegments(this.editor, adjustedDelta); + if (updateDelta.ops != null && updateDelta.ops.length > 0) { + return adjustedDelta.compose(updateDelta); + } + + return adjustedDelta; + } + /** * Add in the embedded elements displayed in quill to the delta. This can be used to convert a delta from a remote * edit to apply to the current editor content. @@ -952,7 +1127,7 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter { editorStartPos = curIndex + embedsUpToIndex; // remove any embeds subsequent the previous insert so they can be redrawn - const embedsAfterLastEdit = this.countSequentialEmbedsStartingAt(editorStartPos); + const embedsAfterLastEdit = this.countSequentialEmbedsStartingAt(editorStartPos, this.embedPositions); if (embedsAfterLastEdit > 0 && previousOp === 'insert') { (adjustedDelta as any).push({ delete: embedsAfterLastEdit } as DeltaOperation); } 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 214e872f034..14dfb61af23 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 @@ -264,14 +264,22 @@ describe('TextComponent', () => { env.id = new TextDocId('project01', 40, 1); env.waitForEditor(); expect(env.component.editor?.getText()).withContext('setup').toContain('chapter 1, verse 6.'); - expect(env.component.editor?.getContents().ops?.length).withContext('setup').toEqual(25); + expect(env.component.editor?.getContents().ops?.length).withContext('setup').toEqual(27); - env.component.editor?.updateContents(new Delta().retain(109).retain(31, { para: null })); + // Check the update's validity + const updateDelta = new Delta().retain(109).retain(31, { para: null }); + const preUpdateOps = env.component.editor?.getContents().ops!; + expect(preUpdateOps[16].attributes).not.toBeUndefined(); + const postUpdateOps = new Delta(preUpdateOps).compose(updateDelta).ops; + expect(postUpdateOps[16].attributes).toBeUndefined(); + + // Perform the update + env.component.editor?.updateContents(updateDelta); flush(); const ops = env.component.editor?.getContents().ops; if (ops != null) { - const lastPara = ops[18]; + const lastPara = ops[16]; expect(lastPara.attributes).not.toBeNull(); } else { fail('should not get here if test is working properly!'); @@ -333,8 +341,9 @@ describe('TextComponent', () => { env.fixture.detectChanges(); expect(env.isSegmentHighlighted(1, '1')).toBe(true); expect(env.isSegmentHighlighted(1, '1/q_1')).toBe(true); - expect(env.isSegmentHighlighted(1, '1/q_2')).toBe(true); + expect(env.isSegmentHighlighted(1, '1/b_2')).toBe(true); expect(env.isSegmentHighlighted(1, '1/q_3')).toBe(true); + expect(env.isSegmentHighlighted(1, '1/q_4')).toBe(true); TestEnvironment.waitForPresenceTimer(); })); @@ -406,8 +415,8 @@ describe('TextComponent', () => { env.waitForEditor(); const verseSegments: string[] = env.component.getVerseSegments(new VerseRef('LUK 1:1')); - expect(verseSegments).toEqual(['verse_1_1', 'verse_1_1/q_1', 'verse_1_1/q_2', 'verse_1_1/q_3']); - const segmentText = env.component.getSegmentText('verse_1_1/q_2'); + expect(verseSegments).toEqual(['verse_1_1', 'verse_1_1/q_1', 'verse_1_1/b_2', 'verse_1_1/q_3', 'verse_1_1/q_4']); + const segmentText = env.component.getSegmentText('verse_1_1/q_3'); expect(segmentText).toEqual('Poetry third line'); })); @@ -431,44 +440,46 @@ describe('TextComponent', () => { TestEnvironment.waitForPresenceTimer(); })); - it('keeps verse selection after user undoes edits', fakeAsync(() => { - const env = new TestEnvironment(); - env.fixture.detectChanges(); - env.id = new TextDocId('project01', 43, 1); - env.waitForEditor(); + [true, false].forEach(modelHasBlanks => { + it('keeps verse selection after user undoes edits', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.fixture.detectChanges(); + env.id = new TextDocId('project01', 43, 1); + env.waitForEditor(); - const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; - env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); - env.component.editor!.setSelection(range.index + 1, 'user'); - tick(); - env.fixture.detectChanges(); - let contents: Delta = env.component.getSegmentContents('verse_1_1')!; - expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); - expect((contents.ops![0].insert as any).blank).toBe(true); - const formats = getAttributesAtPosition(env.component.editor!, range.index); - // use apply delta to control the formatting - env.applyDelta(new Delta().retain(range.index).insert('text', formats).delete(1), 'user'); - contents = env.component.getSegmentContents('verse_1_1')!; - expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); - const verse2Range: QuillRange = env.component.getSegmentRange('verse_1_2')!; - env.component.editor!.setSelection(verse2Range.index + 1, 'user'); - env.component.toggleVerseSelection(new VerseRef('JHN 1:2')); - env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); - tick(); - env.fixture.detectChanges(); - contents = env.component.getSegmentContents('verse_1_1')!; - expect(contents.ops![0].attributes!['commenter-selection']).toBeUndefined(); + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; + env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); + env.component.editor!.setSelection(range.index + 1, 'user'); + tick(); + env.fixture.detectChanges(); + let contents: Delta = env.component.getSegmentContents('verse_1_1')!; + expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); + expect((contents.ops![0].insert as any).blank).toBe(modelHasBlanks); + const formats = getAttributesAtPosition(env.component.editor!, range.index); + // use apply delta to control the formatting + env.applyDelta(new Delta().retain(range.index).insert('text', formats).delete(1), 'user'); + contents = env.component.getSegmentContents('verse_1_1')!; + expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); + const verse2Range: QuillRange = env.component.getSegmentRange('verse_1_2')!; + env.component.editor!.setSelection(verse2Range.index + 1, 'user'); + env.component.toggleVerseSelection(new VerseRef('JHN 1:2')); + env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); + tick(); + env.fixture.detectChanges(); + contents = env.component.getSegmentContents('verse_1_1')!; + expect(contents.ops![0].attributes!['commenter-selection']).toBeUndefined(); - // SUT - env.triggerUndo(); - env.component.toggleVerseSelection(new VerseRef('JHN 1:2')); - env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); - contents = env.component.getSegmentContents('verse_1_1')!; - expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); - expect((contents.ops![0].insert as any).blank).toBe(true); + // SUT + env.triggerUndo(); + env.component.toggleVerseSelection(new VerseRef('JHN 1:2')); + env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); + contents = env.component.getSegmentContents('verse_1_1')!; + expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); + expect((contents.ops![0].insert as any).blank).toBe(modelHasBlanks); - TestEnvironment.waitForPresenceTimer(); - })); + TestEnvironment.waitForPresenceTimer(); + })); + }); it('pastes text with proper attributes', fakeAsync(() => { const env = new TestEnvironment(); @@ -1241,46 +1252,6 @@ describe('TextComponent', () => { expect(isValidSpy).withContext('the test may have worked for the wrong reason').toHaveBeenCalled(); })); - it('does not add blank embeds while offline', fakeAsync(() => { - const env = new TestEnvironment(); - env.fixture.detectChanges(); - env.component.id = new TextDocId('project01', 40, 1); - env.onlineStatus = false; - env.waitForEditor(); - - let range: QuillRange = env.component.getSegmentRange('verse_1_1')!; - let verse1Contents: Delta = env.component.getSegmentContents('verse_1_1')!; - expect(verse1Contents.ops!.length).toBe(1); - env.component.editor!.setSelection(range.index + range.length, 'user'); - tick(); - env.fixture.detectChanges(); - // delete all the text in the verse - env.applyDelta(new Delta().retain(range.index).delete(range.length), 'user'); - tick(); - env.fixture.detectChanges(); - verse1Contents = env.component.getSegmentContents('verse_1_1')!; - // no content exists, not even a blank - expect(verse1Contents.ops!.length).toBe(0); - - env.onlineStatus = true; - tick(); - env.fixture.detectChanges(); - range = env.component.getSegmentRange('verse_1_3')!; - let verse3Contents: Delta = env.component.getSegmentContents('verse_1_3')!; - expect(verse3Contents.ops!.length).toBe(1); - // delete all the text in the verse - env.applyDelta(new Delta().retain(range.index).delete(range.length), 'user'); - tick(); - env.fixture.detectChanges(); - verse3Contents = env.component.getSegmentContents('verse_1_3')!; - // blank exists - expect(verse3Contents.ops![0].insert).toEqual({ blank: true }); - verse1Contents = env.component.getSegmentContents('verse_1_1')!; - // blank restored to verse 1 - expect(verse1Contents.ops![0].insert).toEqual({ blank: true }); - TestEnvironment.waitForPresenceTimer(); - })); - it('can display footnote dialog', fakeAsync(() => { const chapterNum = 2; const segmentRef: string = `verse_${chapterNum}_1`; @@ -1640,13 +1611,15 @@ class TestEnvironment { chapterNum, presenceEnabled = true, callback, - placeholderInput + placeholderInput, + modelHasBlanks = false }: { textDoc?: RichText.DeltaOperation[]; chapterNum?: number; presenceEnabled?: boolean; callback?: (env: TestEnvironment) => void; placeholderInput?: string; + modelHasBlanks?: boolean; } = {}) { when(mockedTranslocoService.translate(anything())).thenCall( (translationStringKey: string) => translationStringKey @@ -1702,20 +1675,24 @@ class TestEnvironment { this.realtimeService.addSnapshots(TextDoc.COLLECTION, [ { id: this.matTextDocId.toString(), - data: getTextDoc(this.matTextDocId), + data: getTextDoc(this.matTextDocId, modelHasBlanks), type: RichText.type.name }, { id: this.mrkTextDocId.toString(), - data: getCombinedVerseTextDoc(this.mrkTextDocId), + data: getCombinedVerseTextDoc(this.mrkTextDocId, modelHasBlanks), type: RichText.type.name }, { id: this.lukTextDocId.toString(), - data: getPoetryVerseTextDoc(this.lukTextDocId), + data: getPoetryVerseTextDoc(this.lukTextDocId, modelHasBlanks), type: RichText.type.name }, - { id: this.jhnTextDocId.toString(), data: getEmptyChapterDoc(this.jhnTextDocId), type: RichText.type.name } + { + id: this.jhnTextDocId.toString(), + data: getEmptyChapterDoc(this.jhnTextDocId, modelHasBlanks), + type: RichText.type.name + } ]); this.realtimeService.addSnapshot(UserDoc.COLLECTION, { id: 'user01', 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 e86f0193962..ddeba0a566a 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 @@ -733,15 +733,19 @@ export class TextComponent implements AfterViewInit, OnDestroy { editorPosOfSegmentToModify.index, startTextPosInVerse ); + // Insert at the start of the segment and before any other embeds in the segment const embedInsertPos: number = - editorRange.startEditorPosition + editorRange.editorLength + editorRange.trailingEmbedCount; + editorRange.startEditorPosition + + editorRange.editorLength + + editorRange.trailingEmbedCount - + editorRange.blanksWithinRange; let insertFormat = this.editor.getFormat(embedInsertPos); // Include formatting from the current insert position as well as any unique formatting format = { ...insertFormat, ...format }; this.editor.insertEmbed(embedInsertPos, formatName, format, 'api'); const textAnchorRange = this.viewModel.getEditorContentRange(embedInsertPos, textAnchor.length); - const formatLength: number = textAnchorRange.editorLength; + const formatLength: number = textAnchorRange.editorLength - editorRange.blanksWithinRange; // Add text anchors as a separate formatText call rather than part of insertEmbed as it needs to expand over a // a length of text @@ -816,7 +820,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { onContentChanged(delta: Delta, source: string): void { const preDeltaSegmentCache: IterableIterator<[string, Range]> = this.viewModel.segmentsSnapshot; const preDeltaEmbedCache: Readonly> = this.viewModel.embeddedElementsSnapshot; - this.viewModel.update(delta, source as EmitterSource, this.onlineStatusService.isOnline); + this.viewModel.update(delta, source as EmitterSource); // skip updating when only formatting changes occurred if (delta.ops != null && delta.ops.some(op => op.insert != null || op.delete != null)) { const isUserEdit: boolean = source === 'user'; @@ -872,15 +876,19 @@ export class TextComponent implements AfterViewInit, OnDestroy { } let previousEmbedIndex = -1; const deleteDelta = new Delta(); - for (const embedIndex of this.viewModel.embeddedElements.values()) { - const lengthBetweenEmbeds: number = embedIndex - (previousEmbedIndex + 1); - if (lengthBetweenEmbeds > 0) { - // retain elements other than notes between the previous and current embed - deleteDelta.retain(lengthBetweenEmbeds); + for (const [embedId, embedIndex] of this.viewModel.embeddedElements) { + // Do not remove any blank embeds + if (!embedId.startsWith('blank_')) { + const lengthBetweenEmbeds: number = embedIndex - (previousEmbedIndex + 1); + if (lengthBetweenEmbeds > 0) { + // retain elements other than notes between the previous and current embed + deleteDelta.retain(lengthBetweenEmbeds); + } + deleteDelta.delete(1); + previousEmbedIndex = embedIndex; } - deleteDelta.delete(1); - previousEmbedIndex = embedIndex; } + deleteDelta.chop(); if (deleteDelta.ops != null && deleteDelta.ops.length > 0) { this.editor.updateContents(deleteDelta, 'api'); @@ -1471,12 +1479,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { // Embedding notes into quill makes quill emit deltas when it registers that content has changed // but quill incorrectly interprets the change when the selection is within the updated segment. // Content coming after the selection gets moved before the selection. This moves the selection back. - const curSegmentRange: Range = this.segment.range; - const insertionPoint: number = getRetainCount(delta.ops[0]) ?? 0; - const segmentEndPoint: number = curSegmentRange.index + curSegmentRange.length - 1; - if (insertionPoint >= curSegmentRange.index && insertionPoint <= segmentEndPoint) { - this._editor.setSelection(segmentEndPoint); - } + this._editor.setSelection(this.segment.range.index + this.segment.range.length); } // get currently selected segment ref const selection = this._editor.getSelection(); @@ -1571,7 +1574,9 @@ export class TextComponent implements AfterViewInit, OnDestroy { if (range != null) { // setTimeout seems necessary to ensure that the editor is focused setTimeout(() => { - if (this._editor != null) { + // Get the range again so it is up-to-date with any inserted blanks + const range = this.viewModel.getSegmentRange(segmentRef); + if (this._editor != null && range != null) { this._editor.setSelection(end ? range.index + range.length : range.index, 0, 'user'); } }); 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 1a19d2529f0..dbf9d6fd758 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 @@ -529,13 +529,10 @@ class TestEnvironment { const delta = new Delta(); delta.insert({ chapter: { number: '1', style: 'c' } }); delta.insert('heading text', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert('target: chapter 1, verse 1.', { segment: 'verse_1_1' }); delta.insert({ verse: { number: '2', style: 'v' } }); - delta.insert({ blank: true }, { segment: 'verse_1_2' }); delta.insert('\n', { para: { style: 'p' } }); - delta.insert({ blank: true }, { segment: 'verse_1_2/p_1' }); delta.insert({ verse: { number: '3', style: 'v' } }); delta.insert('target: chapter 1, verse 3.', { segment: 'verse_1_3' }); delta.insert({ verse: { number: '4', style: 'v' } }); @@ -552,7 +549,6 @@ class TestEnvironment { delta.insert({ verse: { number: '8', style: 'v' } }); delta.insert(' target: chapter 1, verse 8. ', { segment: 'verse_1_8' }); delta.insert({ verse: { number: '9', style: 'v' } }); - delta.insert({ blank: true }, { segment: 'verse_1_9' }); delta.insert({ verse: { number: '10', style: 'v' } }); delta.insert('verse ten', { segment: 'verse_1_10' }); return delta; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts index 20c19f0c09c..0bf7a80c5e5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts @@ -36,34 +36,6 @@ describe('DraftHandlingService', () => { service = TestBed.inject(DraftHandlingService); }); - describe('hasDraftOps', () => { - it('should return false if draft is empty', () => { - const draft: DraftSegmentMap = {}; - const targetOps: DeltaOperation[] = []; - expect(service.hasDraftOps(draft, targetOps)).toBeFalse(); - }); - - it('should return false if all target ops have existing translation', () => { - const draft: DraftSegmentMap = { verse_1_1: 'In the beginning' }; - const targetOps: DeltaOperation[] = [{ insert: 'existing translation', attributes: { segment: 'verse_1_1' } }]; - expect(service.hasDraftOps(draft, targetOps)).toBeFalse(); - }); - - it('should return false for ops with insert object that is not { blank: true}', () => { - const draft: DraftSegmentMap = { verse_1_1: 'In the beginning' }; - const targetOps: DeltaOperation[] = [ - { insert: { 'note-thread-embed': {} }, attributes: { segment: 'verse_1_1' } } - ]; - expect(service.hasDraftOps(draft, targetOps)).toBeFalse(); - }); - - it('should return true if there is a target op without existing translation', () => { - const draft: DraftSegmentMap = { verse_1_1: 'In the beginning' }; - const targetOps: DeltaOperation[] = [{ insert: '', attributes: { segment: 'verse_1_1' } }]; - expect(service.hasDraftOps(draft, targetOps)).toBeTrue(); - }); - }); - describe('getDraft', () => { it('should get a draft', () => { const textDocId = new TextDocId('project01', 1, 1); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts index e8afb4dee75..390a0b6f750 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts @@ -34,34 +34,6 @@ export class DraftHandlingService { private readonly errorReportingService: ErrorReportingService ) {} - /** - * Whether draft has any pretranslation segments that are not already translated in target ops. - * @param draft dictionary of segment refs to pretranslations - * @param targetOps current delta ops for target editor - */ - hasDraftOps(draft: DraftSegmentMap, targetOps: DeltaOperation[]): boolean { - // Check for empty draft - if (Object.keys(draft).length === 0) { - return false; - } - - return targetOps.some(op => { - if (op.insert == null) { - return false; - } - - const draftSegmentText: string | undefined = draft[op.attributes?.segment as string]; - const isSegmentDraftAvailable = draftSegmentText != null && draftSegmentText.trim().length > 0; - - // Can populate draft if insert is a blank string OR insert is object that has 'blank: true' property. - // Other objects are not draftable (e.g. 'note-thread-embed'). - const isInsertBlank = - (isString(op.insert) && op.insert.trim().length === 0) || (!isString(op.insert) && op.insert.blank === true); - - return isSegmentDraftAvailable && isInsertBlank; - }); - } - /** * Returns array of target ops with draft pretranslation copied * to corresponding target op segments that are not already translated. diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts index c62135f3a14..0a8046d9053 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts @@ -466,7 +466,7 @@ const targetDelta = new Delta([ segment: 'verse_1_3', 'para-contents': true }, - insert: { blank: true } + insert: { blank: false } }, { insert: '\n', diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts index b6195cd7dc2..158f7785d1d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts @@ -34,6 +34,12 @@ describe('EditorHistoryService', () => { expect(obj.subObj).toBeNull(); }); + it('should remove blanks', () => { + const delta = new Delta().insert('Hello ').insert({ blank: true }).insert({ blank: false }).insert('World'); + const result = service.removeBlanks(delta); + expect(result).toEqual(new Delta().insert('Hello World')); + }); + describe('formatTimestamp', () => { it('should return "Invalid Date" if timestamp is null or empty', () => { expect(service.formatTimestamp(null)).toBe('Invalid Date'); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts index 12fb7634c8b..62841615f75 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; +import { cloneDeep } from 'lodash-es'; import { Delta } from 'quill'; +import { DeltaOperation } from 'rich-text'; import { I18nService } from 'xforge-common/i18n.service'; const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; @@ -42,6 +44,10 @@ export class EditorHistoryService { deltaA.forEach(obj => this.removeCid(obj)); deltaB.forEach(obj => this.removeCid(obj)); + // Remove the blanks from the deltas + deltaA = this.removeBlanks(deltaA); + deltaB = this.removeBlanks(deltaB); + const diff: Delta = deltaA.diff(deltaB); // Process each op in the diff @@ -70,4 +76,19 @@ export class EditorHistoryService { if (typeof obj[subObj] === 'object' && obj[subObj] != null) this.removeCid(obj[subObj]); } } + + removeBlanks(modelDelta: Delta): Delta { + if (modelDelta.ops == null || modelDelta.ops.length < 1) { + return new Delta(); + } + const adjustedDelta = new Delta(); + for (const op of modelDelta.ops) { + const cloneOp: DeltaOperation | undefined = cloneDeep(op); + if (!(cloneOp.insert != null && cloneOp.insert['blank'] != null)) { + (adjustedDelta as any).push(cloneOp); + } + } + + return adjustedDelta; + } } 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 444f85d76dc..1744da07e24 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 @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { SimpleChange } from '@angular/core'; import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -147,6 +148,17 @@ describe('HistoryChooserComponent', () => { expect(env.revertHistoryButton).toBeNull(); })); + it('should not display the revert history button if snapshot is corrupt', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedParatextService.getSnapshot('project01', 'MAT', 1, 'date_here')).thenReject( + new HttpErrorResponse({ status: 409 }) + ); + env.triggerNgOnChanges(); + env.wait(); + expect(env.revertHistoryButton).toBeNull(); + expect(env.component.selectedSnapshot).toBeUndefined(); + })); + it('should revert to the snapshot', fakeAsync(() => { const env = new TestEnvironment(); when(mockedDialogService.confirm(anything(), anything())).thenResolve(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 a02b667fdee..dc04501f54e 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 @@ -217,6 +217,8 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { // Remember the snapshot so we can apply it this.selectedSnapshot = snapshot; this.revisionSelect.emit({ revision, snapshot }); - }); + }) + // On error, do not emit the revision + .catch(() => {}); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html index a58c787aa8b..ee137b16bb6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html @@ -104,7 +104,7 @@ [isReadOnly]="true" [highlightSegment]="targetFocused" (loaded)="onTextLoaded('source')" - (updated)="onSourceUpdated($event.delta != null)" + (updated)="onSourceUpdated($event.delta)" [isRightToLeft]="isSourceRightToLeft" [fontSize]="sourceFontSize" [style.--project-font]="fontService.getFontFamilyFromProject(sourceProjectDoc)" @@ -142,6 +142,11 @@ {{ t("project_data_out_of_sync") }} } + @if (updateRequired && hasEditRight) { + + {{ t("update_required") }} + + } @if (target.areOpsCorrupted && hasEditRight) { {{ t("text_doc_corrupted") }} 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 6f510296b0c..08d75b4427b 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 @@ -46,7 +46,7 @@ import { NoteType } from 'realtime-server/lib/esm/scriptureforge/models/note-thread'; import { ParatextUserProfile } from 'realtime-server/lib/esm/scriptureforge/models/paratext-user-profile'; -import { SFProject, SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { EditingRequires, SFProject, SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProject, @@ -156,4196 +156,4262 @@ class MockConsole { } describe('EditorComponent', () => { - configureTestingModule(() => ({ - declarations: [ - EditorComponent, - SuggestionsComponent, - TrainingProgressComponent, - EditorDraftComponent, - HistoryRevisionFormatPipe - ], - imports: [ - BiblicalTermsComponent, - CopyrightBannerComponent, - NoopAnimationsModule, - RouterModule.forRoot(ROUTES), - SharedModule.forRoot(), - UICommonModule, - TestTranslocoModule, - TranslocoMarkupModule, - TestOnlineStatusModule.forRoot(), - TestRealtimeModule.forRoot(SF_TYPE_REGISTRY), - SFTabsModule, - LynxInsightsModule.forRoot(), - AngularSplitModule - ], - providers: [ - { provide: AuthService, useMock: mockedAuthService }, - { provide: SFProjectService, useMock: mockedSFProjectService }, - { provide: UserService, useMock: mockedUserService }, - { provide: NoticeService, useMock: mockedNoticeService }, - { provide: ActivatedRoute, useMock: mockedActivatedRoute }, - { provide: CONSOLE, useValue: new MockConsole() }, - { provide: BugsnagService, useMock: mockedBugsnagService }, - { provide: CookieService, useMock: mockedCookieService }, - { provide: OnlineStatusService, useClass: TestOnlineStatusService }, - { provide: TranslationEngineService, useMock: mockedTranslationEngineService }, - { provide: MatDialog, useMock: mockedMatDialog }, - { provide: BreakpointObserver, useClass: TestBreakpointObserver }, - { provide: HttpClient, useMock: mockedHttpClient }, - { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, - { provide: ParatextService, useMock: mockedParatextService }, - { provide: TabFactoryService, useValue: EditorTabFactoryService }, - { provide: TabMenuService, useValue: EditorTabMenuService }, - { provide: PermissionsService, useMock: mockedPermissionsService }, - { provide: LynxWorkspaceService, useMock: mockedLynxWorkspaceService }, - { provide: FeatureFlagService, useMock: mockedFeatureFlagService } - ] - })); - - it('sharing is only enabled for administrators', fakeAsync(() => { - const env = new TestEnvironment(); - flush(); - env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); - env.wait(); - // Null for non admins - expect(env.sharingButton).toBeNull(); - - // Truthy for admins - env.setCurrentUser('user04'); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(env.sharingButton).not.toBeNull(); - env.dispose(); - })); - - it('response to remote text deletion', fakeAsync(() => { - const env = new TestEnvironment(); - flush(); - env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); - env.wait(); - - const dialogMessage = spyOn((env.component as any).dialogService, 'message').and.callThrough(); - env.setupDialogRef(); - - const textDocId = new TextDocId('project02', 40, 1, 'target'); - 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(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedBookNum: 40, - selectedChapterNum: 2, - selectedSegment: 'verse_2_1', - selectedSegmentChecksum: 12345 - }); - env.wait(); + [true, false].forEach(modelHasBlanks => { + describe(modelHasBlanks ? 'model has blanks' : 'model does not have blanks', () => { + configureTestingModule(() => ({ + declarations: [ + EditorComponent, + SuggestionsComponent, + TrainingProgressComponent, + EditorDraftComponent, + HistoryRevisionFormatPipe + ], + imports: [ + BiblicalTermsComponent, + CopyrightBannerComponent, + NoopAnimationsModule, + RouterModule.forRoot(ROUTES), + SharedModule.forRoot(), + UICommonModule, + TestTranslocoModule, + TranslocoMarkupModule, + TestOnlineStatusModule.forRoot(), + TestRealtimeModule.forRoot(SF_TYPE_REGISTRY), + SFTabsModule, + LynxInsightsModule.forRoot(), + AngularSplitModule + ], + providers: [ + { provide: AuthService, useMock: mockedAuthService }, + { provide: SFProjectService, useMock: mockedSFProjectService }, + { provide: UserService, useMock: mockedUserService }, + { provide: NoticeService, useMock: mockedNoticeService }, + { provide: ActivatedRoute, useMock: mockedActivatedRoute }, + { provide: CONSOLE, useValue: new MockConsole() }, + { provide: BugsnagService, useMock: mockedBugsnagService }, + { provide: CookieService, useMock: mockedCookieService }, + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: TranslationEngineService, useMock: mockedTranslationEngineService }, + { provide: MatDialog, useMock: mockedMatDialog }, + { provide: BreakpointObserver, useClass: TestBreakpointObserver }, + { provide: HttpClient, useMock: mockedHttpClient }, + { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, + { provide: ParatextService, useMock: mockedParatextService }, + { provide: TabFactoryService, useValue: EditorTabFactoryService }, + { provide: TabMenuService, useValue: EditorTabMenuService }, + { provide: PermissionsService, useMock: mockedPermissionsService }, + { provide: LynxWorkspaceService, useMock: mockedLynxWorkspaceService }, + { provide: FeatureFlagService, useMock: mockedFeatureFlagService } + ] + })); - expect(env.component.target!.segmentRef).toEqual('verse_2_1'); - env.getProjectUserConfigDoc().submitJson0Op(op => op.set(puc => puc.selectedSegment, 'verse_2_2'), false); - env.wait(); - expect(env.component.target!.segmentRef).toEqual('verse_2_1'); + it('sharing is only enabled for administrators', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + flush(); + env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); + env.wait(); + // Null for non admins + expect(env.sharingButton).toBeNull(); - env.dispose(); - })); + // Truthy for admins + env.setCurrentUser('user04'); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(env.sharingButton).not.toBeNull(); + env.dispose(); + })); - it('shows warning to users in Chrome when translation is Korean, Japanese, or Chinese', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ - writingSystem: { tag: 'ko' } - }); - env.wait(); + it('response to remote text deletion', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + flush(); + env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); + env.wait(); - expect(env.component.canEdit).toBe(true); - expect(env.component.projectDoc?.data?.writingSystem.tag).toEqual('ko'); - if (isBlink()) { - expect(env.component.writingSystemWarningBanner).toBe(true); - expect(env.showWritingSystemWarningBanner).not.toBeNull(); - } else { - expect(env.component.writingSystemWarningBanner).toBe(false); - expect(env.showWritingSystemWarningBanner).toBeNull(); - } + const dialogMessage = spyOn((env.component as any).dialogService, 'message').and.callThrough(); + env.setupDialogRef(); - env.dispose(); - })); + const textDocId = new TextDocId('project02', 40, 1, 'target'); + env.deleteText(textDocId.toString()); + expect(dialogMessage).toHaveBeenCalledTimes(1); + tick(); + expect(env.location.path()).toEqual('/projects/project02/translate'); + env.dispose(); + })); - it('does not show warning to users when translation is not Korean, Japanese, or Chinese', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ - writingSystem: { tag: 'en' } - }); - env.wait(); - - expect(env.component.canEdit).toBe(true); - expect(env.component.projectDoc?.data?.writingSystem.tag).toEqual('en'); - expect(env.component.writingSystemWarningBanner).toBe(false); - expect(env.showWritingSystemWarningBanner).toBeNull(); - discardPeriodicTasks(); - })); - - it('does not show warning to users if they do not have edit permissions on the selected book', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ - writingSystem: { tag: 'ko' }, - translateConfig: defaultTranslateConfig - }); - // user03 only has read permissions on Luke 1 - // As the editor is disabled, we do not need to show the writing system warning - // The no_permission_edit_chapter message will be displayed instead - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - - expect(env.component.projectDoc?.data?.writingSystem.tag).toEqual('ko'); - expect(env.component.writingSystemWarningBanner).toBe(false); - expect(env.showWritingSystemWarningBanner).toBeNull(); - expect(env.component.userHasGeneralEditRight).toBe(true); - expect(env.component.hasChapterEditPermission).toBe(false); - expect(env.component.canEdit).toBe(false); - expect(env.component.showNoEditPermissionMessage).toBe(true); - expect(env.noChapterEditPermissionMessage).not.toBeNull(); - - discardPeriodicTasks(); - })); - - describe('Translation Suggestions enabled', () => { - it('start with no previous selection', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.invalidWarning).toBeNull(); - env.dispose(); - })); - - it('start with previously selected segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 2, selectedSegment: 'verse_2_1' }); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.chapter).toBe(2); - expect(env.component.verse).toBe('1'); - expect(env.component.target!.segmentRef).toEqual('verse_2_1'); - verify(mockedTranslationEngineService.trainSelectedSegment(anything(), anything())).never(); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(30); - expect(selection!.length).toBe(0); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(false); - env.dispose(); - })); - - it('source retrieved after target', fakeAsync(() => { - 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))); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - expect(env.component.showSuggestions).toBe(false); - - resolve!(env.getTextDoc(sourceId)); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(true); - - env.dispose(); - })); - - it('select non-blank segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(false); - - resetCalls(env.mockedRemoteTranslationEngine); - const range = env.component.target!.getSegmentRange('verse_1_3'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_3'); - const selection = env.targetEditor.getSelection(); - // 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'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(false); - - env.dispose(); - })); - - it('select blank segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - - resetCalls(env.mockedRemoteTranslationEngine); - const range = env.component.target!.getSegmentRange('verse_1_2'); - env.targetEditor.setSelection(range!.index + 1, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(33); - expect(selection!.length).toBe(0); - expect(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']); - - env.dispose(); - })); - - it('delete all text from non-verse paragraph segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'p_1' }); - env.wait(); - let segmentRange = env.component.target!.segment!.range; - let segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); - let op = segmentContents.ops![0]; - expect((op.insert as any).blank).toBe(true); - expect(op.attributes!.segment).toEqual('p_1'); - - const index = env.typeCharacters('t'); - segmentRange = env.component.target!.segment!.range; - segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); - op = segmentContents.ops![0]; - expect((op.insert as any).blank).toBeUndefined(); - expect(op.attributes!.segment).toEqual('p_1'); - - env.targetEditor.setSelection(index - 2, 1, 'user'); - env.deleteCharacters(); - segmentRange = env.component.target!.segment!.range; - segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); - op = segmentContents.ops![0]; - expect((op.insert as any).blank).toBe(true); - expect(op.attributes!.segment).toEqual('p_1'); - - env.dispose(); - })); - - it('delete all text from verse paragraph segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_4/p_1' }); - env.wait(); - let segmentRange = env.component.target!.segment!.range; - let segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); - let op = segmentContents.ops![0]; - expect(op.insert).toEqual({ - 'note-thread-embed': { - iconsrc: '--icon-file: url(/assets/icons/TagIcons/01flag1.png);', - preview: 'Note from user01', - threadid: 'dataid05' - } - }); - op = segmentContents.ops![1]; - expect((op.insert as any).blank).toBeUndefined(); - expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); - - let index = env.targetEditor.getSelection()!.index; - const length = 'Paragraph break.'.length; - env.targetEditor.setSelection(index - length, length, 'user'); - index = env.typeCharacters('t'); - segmentRange = env.component.target!.segment!.range; - segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); - - // The note remains, the blank is removed - op = segmentContents.ops![0]; - expect(op.insert).toEqual({ - 'note-thread-embed': { - iconsrc: '--icon-file: url(/assets/icons/TagIcons/01flag1.png);', - preview: 'Note from user01', - threadid: 'dataid05' - } - }); - op = segmentContents.ops![1]; - expect((op.insert as any).blank).toBeUndefined(); - expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); - - env.targetEditor.setSelection(index - 1, 1, 'user'); - env.deleteCharacters(); - segmentRange = env.component.target!.segment!.range; - segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); - - // The note remains, the blank returns - op = segmentContents.ops![0]; - expect(op.insert).toEqual({ - 'note-thread-embed': { - iconsrc: '--icon-file: url(/assets/icons/TagIcons/01flag1.png);', - preview: 'Note from user01', - threadid: 'dataid05' - } - }); - op = segmentContents.ops![1]; - expect((op.insert as any).blank).toBe(true); - expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); - - env.dispose(); - })); - - it('selection not at end of incomplete segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.component.target!.segmentRef).toBe(''); - - const range = env.component.target!.getSegmentRange('verse_1_5'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(false); - - env.dispose(); - })); - - it('selection at end of incomplete segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.component.target!.segmentRef).toBe(''); - - const range = env.component.target!.getSegmentRange('verse_1_5'); - env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(true); - expect(env.component.suggestions[0].words).toEqual(['verse', '5']); - - env.dispose(); - })); - - it('should increment offered suggestion count when inserting suggestion', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.component.target!.segmentRef).toBe(''); - const range = env.component.target!.getSegmentRange('verse_1_5'); - env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.component.showSuggestions).toBe(true); - expect(env.component.suggestions[0].words).toEqual(['verse', '5']); - expect(env.component.metricsSession?.metrics.type).toEqual('navigate'); - - env.insertSuggestion(); - - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - expect(env.component.showSuggestions).toBe(false); - expect(env.component.metricsSession?.metrics.type).toEqual('edit'); - expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); - tick(ACTIVE_EDIT_TIMEOUT); - expect(env.component.metricsSession?.metrics.type).toEqual('edit'); - expect(env.component.metricsSession?.metrics.suggestionAcceptedCount).toBe(1); - expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); - env.dispose(); - })); - - it("should not increment accepted suggestion if the content doesn't change", fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.component.target!.segmentRef).toBe(''); - const range = env.component.target!.getSegmentRange('verse_1_5'); - env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); - env.wait(); - env.typeCharacters('verse 5'); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - expect(env.component.showSuggestions).toBe(true); - expect(env.component.suggestions[0].words).toEqual(['5']); - expect(env.component.metricsSession?.metrics.type).toEqual('edit'); - expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); - expect(env.component.metricsSession?.metrics.suggestionAcceptedCount).toBeUndefined(); - - env.insertSuggestion(); - - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - expect(env.component.showSuggestions).toBe(false); - tick(ACTIVE_EDIT_TIMEOUT); - expect(env.component.metricsSession?.metrics.type).toEqual('edit'); - expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); - expect(env.component.metricsSession?.metrics.suggestionAcceptedCount).toBeUndefined(); - env.dispose(); - })); - - it('should display the verse too long error', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - // Change to the long verse - const range = env.component.target!.getSegmentRange('verse_1_6'); - env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); - env.wait(); - - // Verify an error displayed - expect(env.component.target!.segmentRef).toBe('verse_1_6'); - expect(env.component.showSuggestions).toBe(false); - verify(mockedNoticeService.show(anything())).once(); - - env.dispose(); - })); - - it('should not display the verse too long error if user has suggestions disabled', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedBookNum: 40, - selectedChapterNum: 1, - selectedSegment: 'verse_1_5', - translationSuggestionsEnabled: false - }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(false); - - // Change to the long verse - const range = env.component.target!.getSegmentRange('verse_1_6'); - env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); - env.wait(); - - // Verify an error did not display - expect(env.component.target!.segmentRef).toBe('verse_1_6'); - expect(env.component.showSuggestions).toBe(false); - verify(mockedNoticeService.show(anything())).never(); - - env.dispose(); - })); - - it('should not call getWordGraph if user has suggestions disabled', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedBookNum: 40, - selectedChapterNum: 1, - selectedSegment: 'verse_1_5', - translationSuggestionsEnabled: false - }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(false); - - // Change to the long verse - const range = env.component.target!.getSegmentRange('verse_1_6'); - env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); - env.wait(); - - // Verify an error did not display - expect(env.component.target!.segmentRef).toBe('verse_1_6'); - expect(env.component.showSuggestions).toBe(false); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - - env.dispose(); - })); - - it('insert suggestion in non-blank segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - expect(env.component.showSuggestions).toBe(false); - - env.dispose(); - })); - - it('insert second suggestion in non-blank segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedBookNum: 40, - selectedChapterNum: 1, - selectedSegment: 'verse_1_5', - numSuggestions: 2 - }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.downArrow(); - env.insertSuggestion(); - expect(env.component.target!.segmentText).toBe('target: chapter 1, versa 5'); - expect(env.component.showSuggestions).toBe(false); - - env.dispose(); - })); - - it('insert space when typing character after inserting a suggestion', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(1); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse'); - expect(env.component.showSuggestions).toBe(true); - - const selectionIndex = env.typeCharacters('5.'); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5.'); - expect(env.component.showSuggestions).toBe(false); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(selectionIndex + 1); - expect(selection!.length).toBe(0); - - env.dispose(); - })); - - it('insert space when inserting a suggestion after inserting a previous suggestion', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(1); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse'); - expect(env.component.showSuggestions).toBe(true); - - let selection = env.targetEditor.getSelection(); - const selectionIndex = selection!.index; - env.insertSuggestion(1); - expect(env.component.target!.segmentText).toEqual('target: chapter 1, verse 5'); - expect(env.component.showSuggestions).toBe(false); - selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(selectionIndex + 2); - expect(selection!.length).toBe(0); - - env.dispose(); - })); - - it('do not insert space when typing punctuation after inserting a suggestion', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(1); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse'); - expect(env.component.showSuggestions).toBe(true); - - const selectionIndex = env.typeCharacters('.'); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse.'); - expect(env.component.showSuggestions).toBe(false); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(selectionIndex); - expect(selection!.length).toBe(0); - - env.dispose(); - })); - - it('train a modified segment after selecting a different segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - - const range = env.component.target!.getSegmentRange('verse_1_1'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), 'target: chapter 1, verse 5', true)).once(); - - env.dispose(); - })); - - it('does not train a modified segment after selecting a different segment if offline', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedTask: 'translate', - selectedBookNum: 40, - selectedChapterNum: 1, - selectedSegment: 'verse_1_5', - projectRef: 'project01' - }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - env.insertSuggestion(); - const text = 'target: chapter 1, verse 5'; - expect(env.component.target!.segmentText).toBe(text); - env.onlineStatus = false; - const range = env.component.target!.getSegmentRange('verse_1_1'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - verify(mockedTranslationEngineService.storeTrainingSegment('project01', 'project02', 40, 1, 'verse_1_5')).once(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).never(); - - env.dispose(); - })); - - it('train a modified segment after switching to another text and back', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - expect(env.bookName).toEqual('Mark'); - expect(env.component.target!.segmentRef).toEqual('verse_1_5'); - verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).never(); - - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.target!.segmentRef).toEqual('verse_1_5'); - const range = env.component.target!.getSegmentRange('verse_1_1'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), 'target: chapter 1, verse 5', true)).once(); - - env.dispose(); - })); - - it('train a modified segment after selecting a segment in a different text', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedTask: 'translate', - selectedBookNum: 40, - selectedChapterNum: 1, - selectedSegment: 'verse_1_5', - selectedSegmentChecksum: 0 - }); - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe(''); - expect(env.component.showSuggestions).toBe(false); - - const range = env.component.target!.getSegmentRange('verse_1_1'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(mockedTranslationEngineService.trainSelectedSegment(anything(), anything())).once(); - - env.dispose(); - })); - - it('do not train an unmodified segment after selecting a different segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_5'); - expect(env.component.showSuggestions).toBe(true); - - env.insertSuggestion(); - expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - - const selection = env.targetEditor.getSelection(); - env.targetEditor.deleteText(selection!.index - 7, 7, 'user'); - env.wait(); - expect(env.component.target!.segmentText).toBe('target: chapter 1, '); - - const range = env.component.target!.getSegmentRange('verse_1_1'); - env.targetEditor.setSelection(range!.index, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).never(); - - env.dispose(); - })); - - it('does not build machine project if no source books exists', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - when(mockedTranslationEngineService.checkHasSourceBooks(anything())).thenReturn(false); - env.wait(); - verify(mockedTranslationEngineService.createTranslationEngine(anything())).never(); - env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); - env.wait(); - verify(mockedTranslationEngineService.createTranslationEngine(anything())).never(); - expect().nothing(); - env.dispose(); - })); - - it('change texts', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.target!.segmentRef).toEqual('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - - resetCalls(env.mockedRemoteTranslationEngine); - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - expect(env.bookName).toEqual('Mark'); - expect(env.component.target!.segmentRef).toEqual('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - - resetCalls(env.mockedRemoteTranslationEngine); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.target!.segmentRef).toEqual('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - - env.dispose(); - })); - - it('change chapters', fakeAsync(() => { - const env = new TestEnvironment(); - env.ngZone.run(() => { - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + it('remote user config should not change segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedBookNum: 40, + selectedChapterNum: 2, + selectedSegment: 'verse_2_1', + selectedSegmentChecksum: 12345 + }); env.wait(); - expect(env.component.chapter).toBe(1); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - resetCalls(env.mockedRemoteTranslationEngine); - env.component.chapter = 2; - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); + expect(env.component.target!.segmentRef).toEqual('verse_2_1'); + env + .getProjectUserConfigDoc() + .submitJson0Op(op => op.set(puc => puc.selectedSegment, 'verse_2_2'), false); env.wait(); - const verseText = env.component.target!.getSegmentText('verse_2_1'); - expect(verseText).toBe('target: chapter 2, verse 1.'); - expect(env.component.target!.segmentRef).toEqual('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - - resetCalls(env.mockedRemoteTranslationEngine); - env.component.chapter = 1; - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); + expect(env.component.target!.segmentRef).toEqual('verse_2_1'); + + env.dispose(); + })); + + it('shows warning to users in Chrome when translation is Korean, Japanese, or Chinese', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ + writingSystem: { tag: 'ko' } + }); env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - }); - env.dispose(); - })); - - it('selected segment checksum unset on server', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedBookNum: 40, - selectedChapterNum: 1, - selectedSegment: 'verse_1_1', - selectedSegmentChecksum: 0 - }); - env.wait(); - expect(env.component.chapter).toBe(1); - 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); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - expect(env.component.target!.segment!.initialChecksum).not.toBe(0); - - env.dispose(); - })); - - it('training status', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - expect(env.trainingProgress).toBeNull(); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); - - resetCalls(env.mockedRemoteTranslationEngine); - env.updateTrainingProgress(0.1); - expect(env.trainingProgress).not.toBeNull(); - expect(env.trainingProgressSpinner).not.toBeNull(); - env.updateTrainingProgress(1); - expect(env.trainingCompleteIcon).not.toBeNull(); - expect(env.trainingProgressSpinner).toBeNull(); - env.completeTrainingProgress(); - expect(env.trainingProgress).not.toBeNull(); - tick(5000); - env.wait(); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - expect(env.trainingProgress).toBeNull(); - env.updateTrainingProgress(0.1); - expect(env.trainingProgress).not.toBeNull(); - expect(env.trainingProgressSpinner).not.toBeNull(); - - env.dispose(); - })); - - it('close training status', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - expect(env.trainingProgress).toBeNull(); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); - - resetCalls(env.mockedRemoteTranslationEngine); - env.updateTrainingProgress(0.1); - expect(env.trainingProgress).not.toBeNull(); - expect(env.trainingProgressSpinner).not.toBeNull(); - env.clickTrainingProgressCloseButton(); - expect(env.trainingProgress).toBeNull(); - env.updateTrainingProgress(1); - env.completeTrainingProgress(); - env.wait(); - verify(mockedNoticeService.show(anything())).once(); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - - env.updateTrainingProgress(0.1); - expect(env.trainingProgress).not.toBeNull(); - expect(env.trainingProgressSpinner).not.toBeNull(); - - env.dispose(); - })); - - it('error in training status', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_1'); - expect(env.trainingProgress).toBeNull(); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); - - resetCalls(env.mockedRemoteTranslationEngine); - env.updateTrainingProgress(0.1); - expect(env.trainingProgress).not.toBeNull(); - expect(env.trainingProgressSpinner).not.toBeNull(); - env.throwTrainingProgressError(); - expect(env.trainingProgress).toBeNull(); - - tick(30000); - env.updateTrainingProgress(0.1); - expect(env.trainingProgress).not.toBeNull(); - expect(env.trainingProgressSpinner).not.toBeNull(); - - env.dispose(); - })); - - it('source is missing book/text', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual('verse_1_1'); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(50); - expect(selection!.length).toBe(0); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - expect(env.component.showSuggestions).toBe(false); - env.dispose(); - })); - - it('source correctly displays when text changes', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject( - { - texts: [ - { - bookNum: 44, - chapters: [ - { - number: 1, - lastVerse: 3, - isValid: true, - permissions: { - user01: TextInfoPermission.Read - } - } - ], - hasSource: false, - permissions: { - user01: TextInfoPermission.Read - } - } - ] - }, - 'project02' - ); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - let selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - let sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); - expect(sourceText).toEqual('This book does not exist.'); - - env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); - env.wait(); - expect(env.bookName).toEqual('Acts'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); - expect(sourceText).not.toEqual('This book does not exist.'); - - env.dispose(); - })); - - it('user cannot edit', fakeAsync(() => { - const env = new TestEnvironment(); - env.setCurrentUser('user02'); - env.setProjectUserConfig(); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(false); - env.dispose(); - })); - - it('user can edit a chapter with permission', fakeAsync(() => { - const env = new TestEnvironment(); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(2); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - expect(env.outOfSyncWarning).toBeNull(); - const sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); - expect(sourceText).toEqual('This book is empty. Add chapters in Paratext.'); - - env.setDataInSync('project01', false); - expect(env.component.canEdit).toBe(false); - expect(env.outOfSyncWarning).not.toBeNull(); - env.dispose(); - })); - - it('user cannot edit a chapter source text visible', fakeAsync(() => { - const env = new TestEnvironment(); - env.setCurrentUser('user03'); - env.setProjectUserConfig(); - env.wait(); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.canEdit).toBe(false); - expect(env.component.showSource).toBe(true); - env.dispose(); - })); - - it('user cannot edit a chapter with permission', fakeAsync(() => { - const env = new TestEnvironment(); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(false); - env.dispose(); - })); - - it('user cannot edit a text that is not editable', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ editable: false }); - env.setProjectUserConfig(); - env.wait(); - - expect(env.bookName).toEqual('Matthew'); - expect(env.component.projectTextNotEditable).toBe(true); - expect(env.component.canEdit).toBe(false); - expect(env.fixture.debugElement.query(By.css('.text-area .project-text-not-editable'))).not.toBeNull(); - env.dispose(); - })); - - it('user cannot edit a text if their permissions change', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject(); - env.setProjectUserConfig(); - env.wait(); - - const userId: string = 'user01'; - const projectId: string = 'project01'; - let projectDoc = env.getProjectDoc(projectId); - expect(projectDoc.data?.userRoles[userId]).toBe(SFProjectRole.ParatextTranslator); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.canEdit).toBe(true); - - let range = env.component.target!.getSegmentRange('verse_1_2'); - env.targetEditor.setSelection(range!.index + 1, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - 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); - env.wait(); - resetCalls(env.mockedRemoteTranslationEngine); - - projectDoc = env.getProjectDoc(projectId); - expect(projectDoc.data?.userRoles[userId]).toBe(SFProjectRole.Viewer); - expect(env.bookName).toEqual('Matthew'); - expect(env.component.canEdit).toBe(false); - - range = env.component.target!.getSegmentRange('verse_1_3'); - env.targetEditor.setSelection(range!.index + 1, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_3'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - - env.dispose(); - })); - - it('uses default font size', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ defaultFontSize: 18 }); - env.setProjectUserConfig(); - env.wait(); - - const ptToRem = 12; - expect(env.targetTextEditor.style.fontSize).toEqual(18 / ptToRem + 'rem'); - expect(env.sourceTextEditor.style.fontSize).toEqual(18 / ptToRem + 'rem'); - - 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); - expect(env.sourceTextEditor.style.fontSize).toEqual(24 / ptToRem + 'rem'); - env.dispose(); - })); - - it('user has no resource access', fakeAsync(() => { - when(mockedSFProjectService.getProfile('resource01')).thenResolve({ - id: 'resource01', - data: createTestProjectProfile() - } as SFProjectProfileDoc); - - const env = new TestEnvironment(); - env.setupProject({ - translateConfig: { - translationSuggestionsEnabled: true, - source: { - paratextId: 'resource01', - name: 'Resource 1', - shortName: 'SRC', - projectRef: 'resource01', - writingSystem: { - tag: 'qaa' - } - } + + expect(env.component.canEdit).toBe(true); + expect(env.component.projectDoc?.data?.writingSystem.tag).toEqual('ko'); + if (isBlink()) { + expect(env.component.writingSystemWarningBanner).toBe(true); + expect(env.showWritingSystemWarningBanner).not.toBeNull(); + } else { + expect(env.component.writingSystemWarningBanner).toBe(false); + expect(env.showWritingSystemWarningBanner).toBeNull(); } - }); - env.setCurrentUser('user01'); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); - env.wait(); - verify(mockedSFProjectService.get('resource01')).never(); - expect(env.bookName).toEqual('Acts'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - expect(env.isSourceAreaHidden).toBe(false); - env.dispose(); - })); - - it('empty book', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'JHN' }); - env.wait(); - expect(env.bookName).toEqual('John'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - expect(env.component.showSuggestions).toBe(false); - const sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); - expect(sourceText).not.toEqual('This book does not exist.'); - expect(env.component.target!.readOnlyEnabled).toBe(true); - env.dispose(); - })); - - it('chapter is invalid', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - expect(env.bookName).toEqual('Mark'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(false); - expect(env.isSourceAreaHidden).toBe(false); - expect(env.invalidWarning).not.toBeNull(); - env.dispose(); - })); - - it('first chapter is missing', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject(); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'ROM' }); - env.wait(); - expect(env.bookName).toEqual('Romans'); - expect(env.component.chapter).toBe(2); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - env.dispose(); - })); - - it('ensure direction is RTL when project is to set to RTL', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ isRightToLeft: true }); - env.wait(); - expect(env.component.target!.isRtl).toBe(true); - env.dispose(); - })); - - it('does not highlight read-only text editor', fakeAsync(() => { - const env = new TestEnvironment(); - env.setCurrentUser('user02'); - env.wait(); - const segmentRange = env.component.target!.getSegmentRange('verse_1_1')!; - env.targetEditor.setSelection(segmentRange.index); - env.wait(); - let element: HTMLElement = env.targetTextEditor.querySelector('usx-segment[data-segment="verse_1_1"]')!; - expect(element.classList).not.toContain('highlight-segment'); - - env.setCurrentUser('user01'); - env.wait(); - element = env.targetTextEditor.querySelector('usx-segment[data-segment="verse_1_1"]')!; - expect(element.classList).toContain('highlight-segment'); - env.dispose(); - })); - - it('backspace and delete disabled for non-text elements and at segment boundaries', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.targetEditor.history['stack']['undo'].length).withContext('setup').toEqual(0); - let range = env.component.target!.getSegmentRange('verse_1_2')!; - let contents = env.targetEditor.getContents(range.index, 1); - expect((contents.ops![0].insert as any).blank).toBeDefined(); - - // set selection on a blank segment - env.targetEditor.setSelection(range.index, 'user'); - env.wait(); - // the selection is programmatically set to after the blank - expect(env.targetEditor.getSelection()!.index).toEqual(range.index + 1); - expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); - - env.pressKey('backspace'); - expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); - env.pressKey('delete'); - expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); - contents = env.targetEditor.getContents(range.index, 1); - expect((contents.ops![0].insert as any).blank).toBeDefined(); - - // set selection at segment boundaries - range = env.component.target!.getSegmentRange('verse_1_4')!; - env.targetEditor.setSelection(range.index + range.length, 'user'); - env.wait(); - env.pressKey('delete'); - expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); - env.targetEditor.setSelection(range.index, 'user'); - env.wait(); - env.pressKey('backspace'); - expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); - - // other non-text elements - range = env.component.target!.getSegmentRange('verse_1_1')!; - env.targetEditor.insertEmbed(range.index, 'note', { caller: 'a', style: 'ft' }, 'api'); - env.wait(); - contents = env.targetEditor.getContents(range.index, 1); - expect((contents.ops![0].insert as any).note).toBeDefined(); - env.targetEditor.setSelection(range.index + 1, 'user'); - env.pressKey('backspace'); - expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); - contents = env.targetEditor.getContents(range.index, 1); - expect((contents.ops![0].insert as any).note).toBeDefined(); - env.dispose(); - })); - - it('undo/redo', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - - const verse2SegmentIndex = 8; - const verse3EmbedIndex = 9; - env.typeCharacters('test'); - let contents = env.targetEditor.getContents(); - expect(contents.ops![verse2SegmentIndex].insert).toEqual('test'); - expect(contents.ops![verse2SegmentIndex].attributes) - .withContext('typeCharacters verse2SegmentIndex attributes') - .toEqual({ - 'para-contents': true, - segment: 'verse_1_2', - 'highlight-segment': true - }); - expect(contents.ops![verse3EmbedIndex].insert).toEqual({ verse: { number: '3', style: 'v' } }); - expect(contents.ops![verse3EmbedIndex].attributes).toEqual({ 'para-contents': true }); - - env.triggerUndo(); - contents = env.targetEditor.getContents(); - // check that edit has been undone - expect(contents.ops![verse2SegmentIndex].insert).toEqual({ blank: true }); - expect(contents.ops![verse2SegmentIndex].attributes) - .withContext('triggerUndo verse2SegmentIndex attributes') - .toEqual({ - 'para-contents': true, - segment: 'verse_1_2', - 'highlight-segment': true - }); - // check to make sure that data after the affected segment hasn't gotten corrupted - expect(contents.ops![verse3EmbedIndex].insert).toEqual({ verse: { number: '3', style: 'v' } }); - expect(contents.ops![verse3EmbedIndex].attributes).toEqual({ 'para-contents': true }); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(33); - expect(selection!.length).toBe(0); - - env.triggerRedo(); - contents = env.targetEditor.getContents(); - expect(contents.ops![verse2SegmentIndex].insert).toEqual('test'); - expect(contents.ops![verse2SegmentIndex].attributes) - .withContext('triggerRedo verse2SegmentIndex attributes') - .toEqual({ - 'para-contents': true, - segment: 'verse_1_2', - 'highlight-segment': true + env.dispose(); + })); + + it('does not show warning to users when translation is not Korean, Japanese, or Chinese', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ + writingSystem: { tag: 'en' } }); - expect(contents.ops![verse3EmbedIndex].insert).toEqual({ verse: { number: '3', style: 'v' } }); - expect(contents.ops![verse3EmbedIndex].attributes).toEqual({ 'para-contents': true }); - - env.dispose(); - })); - - it('ensure resolved notes do not appear', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - const segment: HTMLElement = env.targetTextEditor.querySelector('usx-segment[data-segment=verse_1_5]')!; - expect(segment).not.toBeNull(); - const note = segment.querySelector('display-note')! as HTMLElement; - expect(note).toBeNull(); - env.dispose(); - })); - - it('ensure inserting in a blank segment only produces required delta ops', fakeAsync(() => { - const env = new TestEnvironment(); - env.wait(); - - const range = env.component.target!.getSegmentRange('verse_1_2'); - env.targetEditor.setSelection(range!.index + 1, 0, 'user'); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - - let contents = env.targetEditor.getContents(); - const verse2SegmentIndex = 8; - expect(contents.ops![verse2SegmentIndex].insert).toEqual({ blank: true }); - - // Keep track of operations triggered in Quill - let textChangeOps: RichText.DeltaOperation[] = []; - env.targetEditor.on('text-change', (delta: Delta, _oldContents: Delta, _source: EmitterSource) => { - if (delta.ops != null) { - textChangeOps = textChangeOps.concat( - delta.ops.map(op => { - delete op.attributes; - return op; - }) - ); - } - }); + env.wait(); - // Type a character and observe the correct operations are returned - env.typeCharacters('t', { 'commenter-selection': true }); - contents = env.targetEditor.getContents(); - expect(contents.ops![verse2SegmentIndex].insert).toEqual('t'); - const expectedOps = [ - { retain: 33 }, - { insert: 't' }, - { retain: 32 }, - { delete: 1 }, - { retain: 1 }, - { retain: 32 }, - { retain: 1 } - ]; - expect(textChangeOps).toEqual(expectedOps); - const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); - const attributes: StringMap = textDoc.data!.ops![5].attributes!; - expect(Object.keys(attributes)).toEqual(['segment']); - env.dispose(); - })); - }); + expect(env.component.canEdit).toBe(true); + expect(env.component.projectDoc?.data?.writingSystem.tag).toEqual('en'); + expect(env.component.writingSystemWarningBanner).toBe(false); + expect(env.showWritingSystemWarningBanner).toBeNull(); + discardPeriodicTasks(); + })); - describe('Note threads', () => { - it('embeds note on verse segments', fakeAsync(() => { - const env = new TestEnvironment(); - env.addParatextNoteThread(6, 'MAT 1:2', '', { start: 0, length: 0 }, ['user01']); - env.addParatextNoteThread( - 7, - 'LUK 1:0', - 'for chapter', - { start: 6, length: 11 }, - ['user01'], - NoteStatus.Todo, - 'user02' - ); - env.addParatextNoteThread(8, 'LUK 1:2-3', '', { start: 0, length: 0 }, ['user01'], NoteStatus.Todo, 'user01'); - env.addParatextNoteThread( - 9, - 'LUK 1:2-3', - 'section heading', - { start: 38, length: 15 }, - ['user01'], - NoteStatus.Todo, - AssignedUsers.TeamUser - ); - env.addParatextNoteThread(10, 'MAT 1:4', '', { start: 27, length: 0 }, ['user01']); - env.setProjectUserConfig(); - env.wait(); - const verse1Segment: HTMLElement = env.getSegmentElement('verse_1_1')!; - const verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; - expect(verse1Note).not.toBeNull(); - expect(verse1Note.getAttribute('style')).toEqual('--icon-file: url(/assets/icons/TagIcons/01flag1.png);'); - expect(verse1Note.getAttribute('title')).toEqual('Note from user01\n--- 2 more note(s) ---'); - const contents = env.targetEditor.getContents(); - expect(contents.ops![3].insert).toEqual('target: '); - expect(contents.ops![4].attributes!['iconsrc']).toEqual('--icon-file: url(/assets/icons/TagIcons/01flag1.png);'); - - // three notes in the segment on verse 3 - const noteVerse3: NodeListOf = env.getSegmentElement('verse_1_3')!.querySelectorAll('display-note')!; - expect(noteVerse3.length).toEqual(3); - - const blankSegmentNote = env.getSegmentElement('verse_1_2')!.querySelector('display-note') as HTMLElement; - expect(blankSegmentNote.getAttribute('style')).toEqual('--icon-file: url(/assets/icons/TagIcons/01flag1.png);'); - expect(blankSegmentNote.getAttribute('title')).toEqual('Note from user01'); - - const segmentEndNote = env.getSegmentElement('verse_1_4')!.querySelector('display-note') as HTMLElement; - expect(segmentEndNote).not.toBeNull(); - - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - const redFlagIcon = '01flag1.png'; - const grayFlagIcon = '01flag4.png'; - const titleUsxSegment: HTMLElement = env.getSegmentElement('s_1')!; - expect(titleUsxSegment.classList).toContain('note-thread-segment'); - const titleUsxNote: HTMLElement | null = titleUsxSegment.querySelector('display-note'); - expect(titleUsxNote).not.toBeNull(); - // Note assigned to a different specific user - expect(titleUsxNote!.getAttribute('style')).toEqual(`--icon-file: url(/assets/icons/TagIcons/${grayFlagIcon});`); - - const sectionHeadingUsxSegment: HTMLElement = env.getSegmentElement('s_2')!; - expect(sectionHeadingUsxSegment.classList).toContain('note-thread-segment'); - const sectionHeadingNote: HTMLElement | null = sectionHeadingUsxSegment.querySelector('display-note'); - expect(sectionHeadingNote).not.toBeNull(); - // Note assigned to team - expect(sectionHeadingNote!.getAttribute('style')).toEqual( - `--icon-file: url(/assets/icons/TagIcons/${redFlagIcon});` - ); - const combinedVerseUsxSegment: HTMLElement = env.getSegmentElement('verse_1_2-3')!; - const combinedVerseNote: HTMLElement | null = combinedVerseUsxSegment.querySelector('display-note'); - expect(combinedVerseNote!.getAttribute('data-thread-id')).toEqual('dataid08'); - // Note assigned to current user - expect(combinedVerseNote!.getAttribute('style')).toEqual( - `--icon-file: url(/assets/icons/TagIcons/${redFlagIcon});` - ); - env.dispose(); - })); - - it('handles text doc updates with note embed offset', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); - env.wait(); - expect(env.component.target!.segmentRef).toBe('verse_1_2'); - - const verse1EmbedIndex = 2; - const verse1SegmentIndex = 3; - const verse1NoteIndex = verse1SegmentIndex + 1; - const verse1NoteAnchorIndex = verse1SegmentIndex + 2; - const verse2SegmentIndex = 8; - env.typeCharacters('t'); - const contents = env.targetEditor.getContents(); - expect(contents.ops![verse2SegmentIndex].insert).toEqual('t'); - expect(contents.ops![verse2SegmentIndex].attributes).toEqual({ - 'para-contents': true, - segment: 'verse_1_2', - 'highlight-segment': true - }); - const textDoc: TextDoc = 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.'); - expect(textOps[5].insert).toBe('t'); - expect(contents.ops![verse1EmbedIndex]!.insert!['verse']['number']).toBe('1'); - expect(contents.ops![verse1SegmentIndex].insert).toBe('target: '); - expect(contents.ops![verse1NoteIndex]!.attributes!['iconsrc']).toBe( - '--icon-file: url(/assets/icons/TagIcons/01flag1.png);' - ); - // text anchor for thread01 - expect(contents.ops![verse1NoteAnchorIndex]!.insert).toBe('chapter 1'); - expect(contents.ops![verse1NoteAnchorIndex]!.attributes).toEqual({ - 'para-contents': true, - 'text-anchor': true, - segment: 'verse_1_1', - 'note-thread-segment': true - }); - expect(contents.ops![verse2SegmentIndex]!.insert).toBe('t'); - env.dispose(); - })); - - it('correctly removes embedded elements', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - let contents = env.targetEditor.getContents(); - let noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); - expect(noteThreadEmbedCount).toEqual(5); - env.component.removeEmbeddedElements(); - env.wait(); - - contents = env.targetEditor.getContents(); - noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); - expect(noteThreadEmbedCount).toEqual(0); - env.dispose(); - })); - - it('uses note thread text anchor as anchor', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - let doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - const noteStart1 = env.component.target!.getSegmentRange('verse_1_1')!.index + doc.data!.position.start; - doc = env.getNoteThreadDoc('project01', 'dataid02'); - const noteStart2 = env.component.target!.getSegmentRange('verse_1_3')!.index + doc.data!.position.start; - doc = 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'); - // 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'); - 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]; - expect(Array.from(env.component.target!.embeddedElements.values())).toEqual(expected); - env.dispose(); - })); - - it('note position correctly accounts for footnote symbols', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; - const contents = env.targetEditor.getContents(range.index, range.length); - // The footnote starts after a note thread in the segment - 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 noteThread3StartPosition = 20; - expect(noteThreadDoc3.data!.position).toEqual({ start: noteThread3StartPosition, length: 7 }); - const note3Position = env.getNoteThreadEditorPosition('dataid03'); - // plus 1 for the note icon embed at the beginning of the verse - expect(range.index + noteThread3StartPosition + 1).toEqual(note3Position); - env.dispose(); - })); - - it('correctly places note in subsequent segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.addParatextNoteThread(6, 'MAT 1:4', 'target', { start: 0, length: 6 }, ['user01']); - // Note 7 should be at position 0 on segment 1_4/p_1 - env.addParatextNoteThread(7, 'MAT 1:4', '', { start: 28, length: 0 }, ['user01']); - env.setProjectUserConfig(); - env.wait(); - - const note7Position = env.getNoteThreadEditorPosition('dataid07'); - const note4EmbedLength = 1; - expect(note7Position).toEqual(env.component.target!.getSegmentRange('verse_1_4/p_1')!.index + note4EmbedLength); - env.dispose(); - })); - - it('shows reattached note in updated location', fakeAsync(() => { - 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); - - // 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 note4Anchor: TextAnchor = note4Doc.data!.position; - expect(note4Anchor).toEqual(position); - expect(note4Position).toEqual(range.index + position.start); - // The original note thread was on verse 3 - expect(note4Doc.data!.verseRef.verseNum).toEqual(3); - env.dispose(); - })); - - it('shows an invalid reattached note in original location', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - // invalid reattachment string - 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')!; - 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(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.convertToConflictNote('project01', 'dataid02'); - env.wait(); - - expect(env.getNoteThreadIconElement('verse_1_3', 'dataid02')).toBeNull(); - env.dispose(); - })); - - it('shows note on verse with letter', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.addParatextNoteThread(6, 'LUK 1:6a', '', { start: 0, length: 0 }, ['user01']); - env.addParatextNoteThread(7, 'LUK 1:6b', '', { start: 0, length: 0 }, ['user01']); - env.wait(); - - expect(env.getNoteThreadIconElement('verse_1_6a', 'dataid06')).not.toBeNull(); - expect(env.getNoteThreadIconElement('verse_1_6b', 'dataid07')).not.toBeNull(); - env.dispose(); - })); - - it('highlights note icons when new content is unread', fakeAsync(() => { - const env = new TestEnvironment(); - env.setCurrentUser('user02'); - env.setProjectUserConfig({ noteRefsRead: ['thread01_note0', 'thread02_note0'] }); - env.wait(); - - expect(env.isNoteIconHighlighted('dataid01')).toBe(true); - expect(env.isNoteIconHighlighted('dataid02')).toBe(false); - expect(env.isNoteIconHighlighted('dataid03')).toBe(true); - expect(env.isNoteIconHighlighted('dataid04')).toBe(true); - expect(env.isNoteIconHighlighted('dataid05')).toBe(true); - - let puc: SFProjectUserConfigDoc = 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'); - expect(puc.data!.noteRefsRead).toContain('thread01_note1'); - expect(puc.data!.noteRefsRead).toContain('thread01_note2'); - expect(env.isNoteIconHighlighted('dataid01')).toBe(false); - - expect(puc.data!.noteRefsRead).toContain('thread02_note0'); - iconElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; - iconElement.click(); - env.wait(); - puc = 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(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - - let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); - - // edit before start position - env.targetEditor.setSelection(5, 0, 'user'); - const text = ' add text '; - const length = text.length; - env.typeCharacters(text); - expect(noteThreadDoc.data!.position).toEqual({ start: 8 + length, length: 9 }); - - // edit at note position - let notePosition = env.getNoteThreadEditorPosition('dataid01'); - env.targetEditor.setSelection(notePosition, 0, 'user'); - env.typeCharacters(text); - expect(noteThreadDoc.data!.position).toEqual({ start: length * 2 + 8, length: 9 }); - - // edit immediately after note - notePosition = env.getNoteThreadEditorPosition('dataid01'); - env.targetEditor.setSelection(notePosition + 1, 0, 'user'); - env.typeCharacters(text); - expect(noteThreadDoc.data!.position).toEqual({ start: length * 2 + 8, length: 9 + length }); - - // edit immediately after verse note - noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid02'); - notePosition = env.getNoteThreadEditorPosition('dataid02'); - expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); - env.targetEditor.setSelection(notePosition, 0, 'user'); - env.wait(); - expect(env.targetEditor.getSelection()!.index).toEqual(notePosition + 1); - env.typeCharacters(text); - expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); - env.dispose(); - })); - - it('should update note position when deleting text', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); - - // delete text before note - const length = 3; - const noteEmbedLength = 1; - let notePosition = env.getNoteThreadEditorPosition('dataid01'); - env.targetEditor.setSelection(notePosition - length, length, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 8 - length, length: 9 }); - - // delete text at the beginning of note text - notePosition = env.getNoteThreadEditorPosition('dataid01'); - env.targetEditor.setSelection(notePosition + noteEmbedLength, length, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 8 - length, length: 9 - length }); - - // delete text right after note text - notePosition = env.getNoteThreadEditorPosition('dataid01'); - const noteLength = noteThreadDoc.data!.position.length; - env.targetEditor.setSelection(notePosition + noteEmbedLength + noteLength, length, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 8 - length, length: 9 - length }); - env.dispose(); - })); - - it('does not try to update positions with an unchanged value', fakeAsync(() => { - 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 origPriorThreadDocAnchorStart: number = priorThreadDoc.data!.position.start; - const origPriorThreadDocAnchorLength: number = priorThreadDoc.data!.position.length; - const origLaterThreadDocAnchorStart: number = laterThreadDoc.data!.position.start; - const origLaterThreadDocAnchorLength: number = laterThreadDoc.data!.position.length; - expect(laterThreadDoc.data!.position.start) - .withContext('setup: have some space between the anchorings') - .toBeGreaterThan(origPriorThreadDocAnchorStart + origPriorThreadDocAnchorLength); - - const insertedText = 'inserted text'; - const insertedTextLength = insertedText.length; - const priorThreadEditorPos = env.getNoteThreadEditorPosition(priorThreadId); - - // Edit between anchorings - env.targetEditor.setSelection(priorThreadEditorPos, 0, 'user'); - env.wait(); - const priorThreadDocSpy: jasmine.Spy = spyOn(priorThreadDoc, 'submitJson0Op').and.callThrough(); - const laterThreadDocSpy: jasmine.Spy = spyOn(laterThreadDoc, 'submitJson0Op').and.callThrough(); - // SUT - env.typeCharacters(insertedText); - expect(priorThreadDoc.data!.position) - .withContext('unchanged') - .toEqual({ start: origPriorThreadDocAnchorStart, length: origPriorThreadDocAnchorLength }); - expect(laterThreadDoc.data!.position) - .withContext('pushed over') - .toEqual({ start: origLaterThreadDocAnchorStart + insertedTextLength, length: origLaterThreadDocAnchorLength }); - // It makes sense to update thread anchor position information when they changed, but we need not request - // position changes with unchanged information. - expect(priorThreadDocSpy.calls.count()) - .withContext('do not try to update position with an unchanged value') - .toEqual(0); - expect(laterThreadDocSpy.calls.count()).withContext('do update position where it changed').toEqual(1); - - env.dispose(); - })); - - it('re-embeds a note icon when a user deletes it', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(Array.from(env.component.target!.embeddedElements.values())).toEqual([11, 34, 55, 56, 94]); - - // deletes just the note icon - 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)); - 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'); - expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); - env.typeCharacters('t'); - // 4 characters deleted and 1 character inserted - expect(Array.from(env.component.target!.embeddedElements.values())).toEqual([10, 31, 52, 53, 91]); - expect(noteThreadDoc.data!.position).toEqual({ start: 7, length: 7 }); - expect(textDoc.data!.ops![3].insert).toBe('targettapter 1, verse 1.'); - - // switch to a different text - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - expect(noteThreadDoc.data!.position).toEqual({ start: 7, length: 7 }); - - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(Array.from(env.component!.target!.embeddedElements.values())).toEqual([10, 31, 52, 53, 91]); - env.dispose(); - })); - - it('should re-embed deleted note and allow user to open note dialog', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const position: number = env.getNoteThreadEditorPosition('dataid03'); - const length = 9; - // $target: chapter 1, |->$$verse 3<-|. - env.targetEditor.setSelection(position, length, 'api'); - env.deleteCharacters(); - const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; - expect(env.getNoteThreadEditorPosition('dataid02')).toEqual(range.index); - expect(env.getNoteThreadEditorPosition('dataid03')).toEqual(range.index + 1); - expect(env.getNoteThreadEditorPosition('dataid04')).toEqual(range.index + 2); - - for (let i = 0; i <= 2; i++) { - const noteThreadId: number = i + 2; - const note: HTMLElement = env.getNoteThreadIconElement('verse_1_3', `dataid0${noteThreadId}`)!; - note.click(); + it('does not show warning to users if they do not have edit permissions on the selected book', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ + writingSystem: { tag: 'ko' }, + translateConfig: defaultTranslateConfig + }); + // user03 only has read permissions on Luke 1 + // As the editor is disabled, we do not need to show the writing system warning + // The no_permission_edit_chapter message will be displayed instead + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).times(i + 1); - } - env.dispose(); - })); - - it('handles deleting parts of two notes text anchors', fakeAsync(() => { - const env = new TestEnvironment(); - env.addParatextNoteThread(6, 'MAT 1:1', 'verse', { start: 19, length: 5 }, ['user01']); - env.setProjectUserConfig(); - env.wait(); - - // 1 target: $chapter|-> 1, $ve<-|rse 1. - env.targetEditor.setSelection(19, 7, 'user'); - env.deleteCharacters(); - const note1 = env.getNoteThreadDoc('project01', 'dataid01'); - expect(note1.data!.position).toEqual({ start: 8, length: 7 }); - const note2 = env.getNoteThreadDoc('project01', 'dataid06'); - expect(note2.data!.position).toEqual({ start: 15, length: 3 }); - const textDoc = 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(() => { - 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'); - expect(noteThreadDoc.data!.position).toEqual({ start: 28, length: 9 }); - env.targetEditor.setSelection(86, 0, 'user'); - const text = ' new text '; - const length = text.length; - env.typeCharacters(text); - expect(noteThreadDoc.data!.position).toEqual({ start: 28 + length, length: 9 }); - env.dispose(); - })); - - it('should update note position if deleting across position end boundary', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const noteThreadDoc: NoteThreadDoc = 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'); - const deletionLength = 10; - const noteEmbedLength: number = 1; - // Arbitrary text position within thread anchoring, at which to start deleting. - const textPositionWithinAnchors = 4; - // Editor position to begin deleting. This should be in the note anchoring span. - const delStart: number = notePosition + noteEmbedLength + textPositionWithinAnchors; - const deletionLengthWithinTextAnchor = noteThreadDoc.data!.position.length - textPositionWithinAnchors; - env.targetEditor.setSelection(delStart, deletionLength, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 - deletionLengthWithinTextAnchor }); - env.dispose(); - })); - - it('handles insert at the last character position', fakeAsync(() => { - 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 thread1Position = env.getNoteThreadEditorPosition('dataid01'); - expect(thread1Doc.data!.position).toEqual({ start: 8, length: 9 }); - - const embedLength = 1; - // Editor position immediately following the end of the anchoring. Note that both the thread1 and thread6 note - // icon embeds need to be accounted for. - const immediatelyAfter: number = thread1Position + embedLength * 2 + thread1Doc.data!.position.length; - // Test insert at index one character outside the text anchor. So not immediately after the anchoring, but another - // character past that. - env.targetEditor.setSelection(immediatelyAfter + 1, 0, 'user'); - env.typeCharacters('a'); - expect(thread1Doc.data!.position).toEqual({ start: 8, length: 9 }); - - // the insert should be included in the text anchor length if inserting immediately after last character - env.targetEditor.setSelection(immediatelyAfter, 0, 'user'); - env.typeCharacters('b'); - 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'); - 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`); - 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(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); - - // delete the entire text anchor - let notePosition = env.getNoteThreadEditorPosition('dataid01'); - let length = 9; - env.targetEditor.setSelection(notePosition + 1, length, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); - - // delete text that includes the entire text anchor - noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); - expect(noteThreadDoc.data!.position).toEqual({ start: 20, length: 7 }); - notePosition = env.getNoteThreadEditorPosition('dataid03'); - length = 8; - env.targetEditor.setSelection(notePosition + 1, length, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); - env.dispose(); - })); - - it('should update paratext notes position after editing verse with multiple notes', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - - const thread3Doc: NoteThreadDoc = 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'); - expect(otherNoteThreadDoc.data!.position).toEqual({ start: 20, length: thread4AnchorLength }); - const verseNoteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid02'); - expect(verseNoteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); - // edit before paratext note - let thread3Position = env.getNoteThreadEditorPosition('dataid03'); - env.targetEditor.setSelection(thread3Position, 0, 'user'); - env.wait(); - const textBeforeNote = 'add text before '; - const length1 = textBeforeNote.length; - env.typeCharacters(textBeforeNote); - expect(thread3Doc.data!.position).toEqual({ start: 20 + length1, length: thread3AnchorLength }); - expect(otherNoteThreadDoc.data!.position).toEqual({ start: 20 + length1, length: thread4AnchorLength }); - - // edit within note selection start - thread3Position = env.getNoteThreadEditorPosition('dataid03'); - env.targetEditor.setSelection(thread3Position + 1, 0, 'user'); - env.wait(); - const textWithinNote = 'edit within note '; - const length2 = textWithinNote.length; - env.typeCharacters(textWithinNote); - env.wait(); - let lengthChange: number = length2; - expect(thread3Doc.data!.position).toEqual({ start: 20 + length1, length: thread3AnchorLength + lengthChange }); - expect(otherNoteThreadDoc.data!.position).toEqual({ - start: 20 + length1, - length: thread4AnchorLength + lengthChange - }); - // edit within note selection end - const verse3Range = env.component.target!.getSegmentRange('verse_1_3')!; - // Verse 3 ends with "[...]ter 1, verse 3.". Thread 4 anchors to "verse". - const extraAmount: number = ` 3.`.length; - const editorPosImmediatelyFollowingThread4Anchoring = verse3Range.index + verse3Range.length - extraAmount; - env.targetEditor.setSelection(editorPosImmediatelyFollowingThread4Anchoring, 0, 'user'); - env.typeCharacters(textWithinNote); - lengthChange += length2; - expect(thread3Doc.data!.position).toEqual({ start: 20 + length1, length: thread3AnchorLength + lengthChange }); - expect(otherNoteThreadDoc.data!.position).toEqual({ - start: 20 + length1, - length: thread4AnchorLength + lengthChange - }); + expect(env.component.projectDoc?.data?.writingSystem.tag).toEqual('ko'); + expect(env.component.writingSystemWarningBanner).toBe(false); + expect(env.showWritingSystemWarningBanner).toBeNull(); + expect(env.component.userHasGeneralEditRight).toBe(true); + expect(env.component.hasChapterEditPermission).toBe(false); + expect(env.component.canEdit).toBe(false); + expect(env.component.showNoEditPermissionMessage).toBe(true); + expect(env.noChapterEditPermissionMessage).not.toBeNull(); - // delete text within note selection - thread3Position = env.getNoteThreadEditorPosition('dataid03'); - const deleteLength = 5; - const lengthAfterNote = 2; - env.targetEditor.setSelection(thread3Position + lengthAfterNote, deleteLength, 'user'); - env.wait(); - env.typeCharacters(''); - lengthChange -= deleteLength; - expect(thread3Doc.data!.position).toEqual({ start: 20 + length1, length: thread3AnchorLength + lengthChange }); - expect(otherNoteThreadDoc.data!.position).toEqual({ - start: 20 + length1, - length: thread4AnchorLength + lengthChange - }); - // the verse note thread position never changes - expect(verseNoteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); - - // delete text at the end of a note anchor - const thread4IconLength = 1; - const lastTextAnchorPosition: number = thread3Position + thread4IconLength + thread3AnchorLength + lengthChange; - env.targetEditor.setSelection(lastTextAnchorPosition, 1, 'user'); - env.deleteCharacters(); - lengthChange--; - expect(thread3Doc.data!.position).toEqual({ start: 20 + length1, length: thread3AnchorLength + lengthChange }); - env.dispose(); - })); - - it('update note thread anchors when multiple edits within a verse', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - const origNoteAnchor: TextAnchor = { start: 8, length: 9 }; - expect(noteThreadDoc.data!.position).toEqual(origNoteAnchor); - - const notePosition: number = env.getNoteThreadEditorPosition('dataid01'); - const deleteStart: number = notePosition + 1; - const text = 'chap'; - - // target: $chapter 1, verse 1. - // move this ---- here ^ - const deleteOps: DeltaOperation[] = [{ retain: deleteStart }, { delete: text.length }]; - const deleteDelta: Delta = new Delta(deleteOps); - env.targetEditor.setSelection(deleteStart, text.length); - // simulate a drag and drop operation, which include a delete and an insert operation - env.targetEditor.updateContents(deleteDelta, 'user'); - tick(); - env.fixture.detectChanges(); - const insertStart: number = notePosition + 'ter 1, ver'.length; - const insertOps: DeltaOperation[] = [{ retain: insertStart }, { insert: text }]; - const insertDelta: Delta = new Delta(insertOps); - env.targetEditor.updateContents(insertDelta, 'user'); - - env.wait(); - const expectedNoteAnchor: TextAnchor = { - start: origNoteAnchor.start, - length: origNoteAnchor.length - text.length - }; - expect(noteThreadDoc.data!.position).toEqual(expectedNoteAnchor); - // SUT - env.triggerUndo(); - // this triggers undoing the drag and drop in one delta - expect(noteThreadDoc.data!.position).toEqual(origNoteAnchor); - env.dispose(); - })); - - it('updates note anchor for non-verse segments', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - const origThread06Pos: TextAnchor = { start: 38, length: 7 }; - env.addParatextNoteThread(6, 'LUK 1:2-3', 'section', origThread06Pos, ['user01']); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - const textBeforeNote = 'Text in '; - 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'); - let textAnchor: TextAnchor = thread06Doc.data!.position; - expect(textAnchor).toEqual(origThread06Pos); - - const verse2_3Range: Range = env.component.target!.getSegmentRange('verse_1_2-3')!; - env.targetEditor.setSelection(verse2_3Range.index + verse2_3Range.length); - env.wait(); - env.typeCharacters('T'); - env.wait(); - textAnchor = thread06Doc.data!.position; - expect(textAnchor).toEqual({ start: origThread06Pos.start + 1, length: origThread06Pos.length }); - env.dispose(); - })); - - it('can display note dialog', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const note = env.fixture.debugElement.query(By.css('display-note')); - expect(note).not.toBeNull(); - note.nativeElement.click(); - env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - env.dispose(); - })); - - it('note belongs to a segment after a blank', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - const noteThreadDoc: NoteThreadDoc = 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); - // user deletes all of the text in segment before - const range = env.component.target!.getSegmentRange('verse_1_4')!; - env.targetEditor.setSelection(range.index, range.length, 'user'); - env.deleteCharacters(); - expect(noteThreadDoc.data!.position).toEqual({ start: 2, length: 9 }); - - // switch to a new book and back - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - let note5Index: number = env.getNoteThreadEditorPosition('dataid05'); - verse4p1Index = env.component.target!.getSegmentRange('verse_1_4/p_1')!.index; - expect(note5Index).toEqual(verse4p1Index); - - // user inserts text in blank segment - const index = env.component.target!.getSegmentRange('verse_1_4')!.index; - env.targetEditor.setSelection(index + 1, 0, 'user'); - env.wait(); - const text = 'abc'; - env.typeCharacters(text); - const nextSegmentLength = 1; - expect(noteThreadDoc.data!.position).toEqual({ start: nextSegmentLength + text.length, length: 9 }); - - // switch to a new book and back - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(noteThreadDoc.data!.position).toEqual({ start: nextSegmentLength + text.length, length: 9 }); - verse4p1Index = env.component.target!.getSegmentRange('verse_1_4/p_1')!.index; - note5Index = env.getNoteThreadEditorPosition('dataid05'); - expect(note5Index).toEqual(verse4p1Index); - env.dispose(); - })); - - it('remote edits correctly applied to editor', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); - - // The remote user inserts text after the thread01 note - let notePosition: number = env.getNoteThreadEditorPosition('dataid01'); - let remoteEditPositionAfterNote: number = 4; - let noteCountBeforePosition: number = 1; - // Text position in the text doc at which the remote user edits - let remoteEditTextPos: number = env.getRemoteEditPosition( - notePosition, - remoteEditPositionAfterNote, - noteCountBeforePosition - ); - // $ represents a note thread embed - // target: $chap|ter 1, verse 1. - const textDoc: TextDoc = 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); - - // SUT 1 - env.wait(); - // The local editor was updated to apply the remote edit in the correct position locally - expect(env.component.target!.getSegmentText('verse_1_1')).toEqual('target: chap' + 'abc' + 'ter 1, verse 1.'); - const verse1Range = env.component.target!.getSegmentRange('verse_1_1')!; - const verse1Contents = env.targetEditor.getContents(verse1Range.index, verse1Range.length); - // ops are [0]target: , [1]$, [2]chapabcter 1, [3], verse 1. - expect(verse1Contents.ops!.length).withContext('has expected op structure').toEqual(4); - expect(verse1Contents.ops![2].attributes!['text-anchor']).withContext('inserted text has formatting').toBe(true); - - // The remote user selects some text and pastes in a replacement - notePosition = env.getNoteThreadEditorPosition('dataid02'); - // 1 note from verse 1, and 1 in verse 3 before the selection point - noteCountBeforePosition = 2; - remoteEditPositionAfterNote = 5; - remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); - const originalNotePosInVerse: number = 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; - const insertDeleteDelta: Delta = new Delta(); - (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); - - // SUT 2 - env.wait(); - expect(env.component.target!.getSegmentText('verse_1_3')).toEqual('targ' + 'defgh' + 'pter 1, verse 3.'); - - // The remote user selects and deletes some text that includes a couple note embeds. - remoteEditPositionAfterNote = 15; - remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); - // $*targdefghpter |->1, $$v<-|erse 3. - // ------ editor range deleted - const deleteDelta: Delta = new Delta(); - (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); - - // 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); - 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 - let notesBefore: number = 1; - expect(env.getNoteThreadEditorPosition('dataid03')).toEqual(verse3Index + originalNotePosInVerse + notesBefore); - notesBefore = 2; - expect(env.getNoteThreadEditorPosition('dataid04')).toEqual(verse3Index + originalNotePosInVerse + notesBefore); - env.dispose(); - })); - - it('remote edits do not affect note thread text anchors', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const noteThread1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - const noteThread4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); - const originalNoteThread1TextPos: TextAnchor = noteThread1Doc.data!.position; - const originalNoteThread4TextPos: TextAnchor = noteThread4Doc.data!.position; - expect(originalNoteThread1TextPos).toEqual({ start: 8, length: 9 }); - expect(originalNoteThread4TextPos).toEqual({ start: 20, length: 5 }); - - // simulate text changes at current segment - let notePosition: number = env.getNoteThreadEditorPosition('dataid04'); - let remoteEditPositionAfterNote: number = 1; - // 1 note in verse 1, and 3 in verse 3 - let noteCountBeforePosition: number = 4; - // $target: chapter 1, $$v|erse 3. - let remoteEditTextPos: number = env.getRemoteEditPosition( - notePosition, - remoteEditPositionAfterNote, - noteCountBeforePosition - ); - env.targetEditor.setSelection(notePosition + remoteEditPositionAfterNote); - 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); - - // SUT 1 - env.wait(); - expect(env.component.target!.getSegmentText('verse_1_3')).toEqual('target: chapter 1, v' + insert + 'erse 3.'); - expect(noteThread4Doc.data!.position).toEqual(originalNoteThread4TextPos); - - // simulate text changes at a different segment - notePosition = env.getNoteThreadEditorPosition('dataid01'); - noteCountBeforePosition = 1; - // target: $c|hapter 1, verse 1. - remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); - insert = 'def'; - deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; - const outOfSegmentDelta = new Delta(deltaOps); - textDoc.submit(outOfSegmentDelta); - - // SUT 2 - env.wait(); - expect(env.component.target!.getSegmentText('verse_1_1')).toEqual('target: c' + insert + 'hapter 1, verse 1.'); - expect(noteThread1Doc.data!.position).toEqual(originalNoteThread1TextPos); - expect(noteThread4Doc.data!.position).toEqual(originalNoteThread4TextPos); - - // simulate text changes just before a note embed - remoteEditPositionAfterNote = -1; - noteCountBeforePosition = 0; - // target: |$cdefhapter 1, verse 1. - remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); - insert = 'before'; - deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; - const insertDelta = new Delta(deltaOps); - textDoc.submit(insertDelta); - const note1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); - const anchor: TextAnchor = { start: 8 + insert.length, length: 12 }; - note1Doc.submitJson0Op(op => op.set(nt => nt.position, anchor)); - - // SUT 3 - env.wait(); - expect(env.component.target!.getSegmentText('verse_1_1')).toEqual('target: ' + insert + 'cdefhapter 1, verse 1.'); - const range: Range = env.component.target!.getSegmentRange('verse_1_1')!; - expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(range.index + anchor.start); - const contents = env.targetEditor.getContents(range.index, range.length); - expect(contents.ops![0].insert).toEqual('target: ' + insert); - expect(contents.ops![0].attributes!['text-anchor']).toBeUndefined(); - - // simulate text changes just after a note embed - notePosition = env.getNoteThreadEditorPosition('dataid01'); - remoteEditPositionAfterNote = 0; - noteCountBeforePosition = 1; - // target: before$|cdefhapter 1, verse 1. - remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); - insert = 'ghi'; - deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; - const insertAfterNoteDelta = new Delta(deltaOps); - textDoc.submit(insertAfterNoteDelta); - - // SUT 4 - env.wait(); - expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(notePosition); - expect(env.component.target!.getSegmentText('verse_1_1')).toEqual( - 'target: before' + insert + 'cdefhapter 1, verse 1.' - ); - env.dispose(); - })); - - it('can backspace the last character in a segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const range: Range = env.component.target!.getSegmentRange('verse_1_2')!; - env.targetEditor.setSelection(range.index); - env.wait(); - env.typeCharacters('t'); - let contents: Delta = env.targetEditor.getContents(range.index, 3); - expect(contents.length()).toEqual(3); - expect(contents.ops![0].insert).toEqual('t'); - expect(contents.ops![1].insert!['verse']).toBeDefined(); - expect(contents.ops![2].insert!['note-thread-embed']).toBeDefined(); - - env.backspace(); - contents = env.targetEditor.getContents(range.index, 3); - expect(contents.length()).toEqual(3); - expect((contents.ops![0].insert as any).blank).toBeDefined(); - expect(contents.ops![1].insert!['verse']).toBeDefined(); - expect(contents.ops![2].insert!['note-thread-embed']).toBeDefined(); - env.dispose(); - })); - - it('remote edits next to note on verse applied correctly', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - let verse3Element: HTMLElement = env.getSegmentElement('verse_1_3')!; - let noteThreadIcon = verse3Element.querySelector('.note-thread-segment display-note'); - expect(noteThreadIcon).not.toBeNull(); - // Insert text next to thread02 icon - const notePosition: number = env.getNoteThreadEditorPosition('dataid02'); - const remoteEditPositionAfterNote: number = 0; - const noteCountBeforePosition = 2; - // $|*target: chapter 1, $$verse 3. - const remoteEditTextPos: number = env.getRemoteEditPosition( - notePosition, - remoteEditPositionAfterNote, - noteCountBeforePosition - ); - 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)); - - env.wait(); - expect(env.getNoteThreadEditorPosition('dataid02')).toEqual(notePosition); - verse3Element = env.getSegmentElement('verse_1_3')!; - noteThreadIcon = verse3Element.querySelector('.note-thread-segment display-note'); - expect(noteThreadIcon).not.toBeNull(); - // check that the note thread underline does not get applied - const insertTextDelta = env.targetEditor.getContents(notePosition + 1, 3); - expect(insertTextDelta.ops![0].insert).toEqual('abc'); - expect(insertTextDelta.ops![0].attributes!['text-anchor']).toBeUndefined(); - env.dispose(); - })); - - it('undo delete-a-note-icon removes the duplicate recreated icon', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - const noteThread6Anchor: TextAnchor = { start: 19, length: 5 }; - env.addParatextNoteThread(6, 'MAT 1:1', 'verse', noteThread6Anchor, ['user01']); - env.wait(); - - // undo deleting just the note - const noteThread1: NoteThreadDoc = 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)); - expect(textDoc.data!.ops![3].insert).toEqual('target: chapter 1, verse 1.'); - const note1Position: number = env.getNoteThreadEditorPosition('dataid01'); - // target: |->$<-|chapter 1, $verse 1. - env.targetEditor.setSelection(note1Position, 1, 'user'); - env.deleteCharacters(); - const positionAfterDelete: number = env.getNoteThreadEditorPosition('dataid01'); - expect(positionAfterDelete).toEqual(note1Position); - env.triggerUndo(); - expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(note1Position); - expect(env.component.target!.getSegmentText('verse_1_1')).toBe('target: chapter 1, verse 1.'); - expect(noteThread1.data!.position).toEqual(noteThread1Anchor); - - // undo deleting note and context - let deleteLength: number = 5; - const beforeNoteLength: number = 2; - // target|->: $ch<-|apter 1, $verse 1. - env.targetEditor.setSelection(note1Position - beforeNoteLength, deleteLength, 'user'); - env.deleteCharacters(); - let newNotePosition: number = env.getNoteThreadEditorPosition('dataid01'); - expect(newNotePosition).toEqual(note1Position - beforeNoteLength); - env.triggerUndo(); - expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(note1Position); - expect(noteThread1.data!.position).toEqual(noteThread1Anchor); - - // undo deleting just the note when note thread doc has history - // target: |->$<-|chapter 1, $verse 1. - env.targetEditor.setSelection(note1Position, 1, 'user'); - env.deleteCharacters(); - env.triggerUndo(); - expect(noteThread1.data!.position).toEqual(noteThread1Anchor); - - // undo deleting note and entire selection - const embedLength = 1; - deleteLength = beforeNoteLength + embedLength + noteThread1.data!.position.length; - // target|->: $chapter<-| 1: $verse 1. - env.targetEditor.setSelection(note1Position - beforeNoteLength, deleteLength, 'user'); - env.deleteCharacters(); - newNotePosition = env.getNoteThreadEditorPosition('dataid01'); - const range = env.component.target!.getSegmentRange('verse_1_1')!; - // note moves to the beginning of the verse - expect(newNotePosition).toEqual(range.index); - env.triggerUndo(); - expect(noteThread1.data!.position).toEqual({ start: 8, length: 9 }); - - // 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'); - deleteLength = 3; - const text = 'abc'; - // target: $chapter 1, |->$ve<-|rse 1. - env.targetEditor.setSelection(note6Position, deleteLength, 'api'); - env.typeCharacters(text); - newNotePosition = env.getNoteThreadEditorPosition('dataid06'); - expect(newNotePosition).toEqual(note6Position + text.length); - env.triggerUndo(); - expect(env.getNoteThreadEditorPosition('dataid06')).toEqual(note6Position); - expect(noteThread6.data!.position).toEqual(noteThread6Anchor); - expect(noteThread1.data!.position).toEqual(noteThread1Anchor); - 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 noteThread3Anchor: TextAnchor = { start: 20, length: 7 }; - const noteThread4Anchor: TextAnchor = { start: 20, length: 5 }; - expect(noteThread3.data!.position).toEqual(noteThread3Anchor); - expect(noteThread4.data!.position).toEqual(noteThread4Anchor); - expect(textDoc.data!.ops![8].insert).toEqual('target: chapter 1, verse 3.'); - const note3Position: number = env.getNoteThreadEditorPosition('dataid03'); - const note4Position: number = env.getNoteThreadEditorPosition('dataid04'); - deleteLength = 6; - // $target: chapter 1|->, $$ve<-|rse 3. - env.targetEditor.setSelection(note3Position - beforeNoteLength, deleteLength, 'api'); - env.deleteCharacters(); - newNotePosition = env.getNoteThreadEditorPosition('dataid03'); - expect(newNotePosition).toEqual(note3Position - beforeNoteLength); - newNotePosition = env.getNoteThreadEditorPosition('dataid04'); - expect(newNotePosition).toEqual(note4Position - beforeNoteLength); - env.triggerUndo(); - env.wait(); - expect(env.getNoteThreadEditorPosition('dataid03')).toEqual(note3Position); - expect(env.getNoteThreadEditorPosition('dataid04')).toEqual(note4Position); - expect(noteThread3.data!.position).toEqual(noteThread3Anchor); - expect(noteThread4.data!.position).toEqual(noteThread4Anchor); - expect(textDoc.data!.ops![8].insert).toEqual('target: chapter 1, verse 3.'); - env.dispose(); - })); - - it('note icon is changed after remote update', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const threadDataId: string = 'dataid01'; - const projectId: string = 'project01'; - const currentIconTag: string = '01flag1'; - const newIconTag: string = '02tag1'; - - const verse1Segment: HTMLElement = env.getSegmentElement('verse_1_1')!; - let verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; - expect(verse1Note).not.toBeNull(); - expect(verse1Note.getAttribute('style')).toEqual( - `--icon-file: url(/assets/icons/TagIcons/${currentIconTag}.png);` - ); + discardPeriodicTasks(); + })); - // Update the last note on the thread as that is the icon displayed - const noteThread: NoteThreadDoc = 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); - verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; - expect(verse1Note.getAttribute('style')).toEqual(`--icon-file: url(/assets/icons/TagIcons/${newIconTag}.png);`); - env.dispose(); - })); - - it('note dialog appears after undo delete-a-note', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - let iconElement02: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; - iconElement02.click(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - let iconElement03: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid03')!; - iconElement03.click(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); - - const notePosition: number = env.getNoteThreadEditorPosition('dataid02'); - const selectionIndex: number = notePosition + 1; - env.targetEditor.setSelection(selectionIndex, 'user'); - env.wait(); - env.backspace(); - - // SUT - env.triggerUndo(); - iconElement02 = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; - iconElement02.click(); - env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).thrice(); - expect(iconElement02.parentElement!.tagName.toLowerCase()).toBe('display-text-anchor'); - iconElement03 = env.getNoteThreadIconElement('verse_1_3', 'dataid03')!; - iconElement03.click(); - env.wait(); - // ensure that clicking subsequent notes in a verse still works - verify(mockedMatDialog.open(NoteDialogComponent, anything())).times(4); - env.dispose(); - })); - - it('selection position on editor is kept when note dialog is opened and editor loses focus', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - const segmentRef = 'verse_1_3'; - const segmentRange = env.component.target!.getSegmentRange(segmentRef)!; - env.targetEditor.setSelection(segmentRange.index); - expect(env.activeElementClasses).toContain('ql-editor'); - const iconElement: HTMLElement = env.getNoteThreadIconElement(segmentRef, 'dataid02')!; - iconElement.click(); - const element: HTMLElement = env.targetTextEditor.querySelector( - 'usx-segment[data-segment="' + segmentRef + '"]' - )!; - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - env.wait(); - expect(env.activeElementTagName).toBe('DIV'); - expect(element.classList).withContext('dialog opened').toContain('highlight-segment'); - mockedMatDialog.closeAll(); - expect(element.classList).withContext('dialog closed').toContain('highlight-segment'); - env.dispose(); - })); - - it('shows only note threads published in Scripture Forge', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - const threadId: string = 'thread06'; - env.addParatextNoteThread( - threadId, - 'MAT 1:4', - 'Paragraph break.', - { start: 0, length: 0 }, - ['user05'], - NoteStatus.Todo, - '', - true - ); - env.wait(); - - const noteThreadElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', 'dataid01'); - expect(noteThreadElem).toBeNull(); - const sfNoteElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_4', 'dataidthread06'); - expect(sfNoteElem).toBeTruthy(); - env.dispose(); - })); - - it('shows insert note button for users with permission', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - // user02 does not have read permission on the text - const usersWhoCanInsertNotes = ['user01', 'user03', 'user04', 'user05']; - for (const user of usersWhoCanInsertNotes) { - env.setCurrentUser(user); - tick(); - env.fixture.detectChanges(); - expect(env.insertNoteFab).toBeTruthy(); - } + describe('Translation Suggestions enabled', () => { + it('start with no previous selection', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.invalidWarning).toBeNull(); + env.dispose(); + })); + + it('start with previously selected segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 2, selectedSegment: 'verse_2_1' }); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.chapter).toBe(2); + expect(env.component.verse).toBe('1'); + expect(env.component.target!.segmentRef).toEqual('verse_2_1'); + verify(mockedTranslationEngineService.trainSelectedSegment(anything(), anything())).never(); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(30); + expect(selection!.length).toBe(0); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(false); + env.dispose(); + })); + + it('source retrieved after target', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + const sourceId = new TextDocId('project02', 40, 1); + let resolve: (value: TextDoc | PromiseLike) => void; + when(mockedSFProjectService.getText(deepEqual(sourceId))).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'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); + expect(env.component.showSuggestions).toBe(false); + + resolve!(env.getTextDoc(sourceId)); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_2'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(true); - const usersWhoCannotInsertNotes = ['user06', 'user07']; - for (const user of usersWhoCannotInsertNotes) { - env.setCurrentUser(user); - tick(); - env.fixture.detectChanges(); - expect(env.insertNoteFab).toBeNull(); - } - env.dispose(); - })); - - it('shows insert note button using bottom sheet for mobile viewport', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.setCurrentUser('user05'); - env.wait(); - - // Initial setup will state FALSE when checking for mobile viewports - let verseSegment: HTMLElement = env.getSegmentElement('verse_1_3')!; - verseSegment.click(); - env.wait(); - expect(env.insertNoteFabMobile).toBeNull(); - - // Allow check for mobile viewports to return TRUE - env.breakpointObserver.matchedResult = true; - verseSegment = env.getSegmentElement('verse_1_2')!; - verseSegment.click(); - env.wait(); - expect(env.insertNoteFabMobile).toBeTruthy(); - expect(env.mobileNoteTextArea).toBeFalsy(); - env.insertNoteFabMobile!.click(); - env.wait(); - expect(env.mobileNoteTextArea).toBeTruthy(); - // Close the bottom sheet - verseSegment = env.getSegmentElement('verse_1_2')!; - verseSegment.click(); - env.wait(); - - env.dispose(); - })); - - it('shows insert new note from mobile viewport', fakeAsync(() => { - const content: string = 'content in the thread'; - const userId: string = 'user05'; - const segmentRef: string = 'verse_1_2'; - const verseRef: VerseRef = new VerseRef('MAT', '1', '2'); - const env = new TestEnvironment(); - env.setProjectUserConfig({ - selectedBookNum: verseRef.bookNum, - selectedChapterNum: verseRef.chapterNum, - selectedSegment: 'verse_1_3' - }); - env.setCurrentUser(userId); - env.wait(); - - // Allow check for mobile viewports to return TRUE - env.breakpointObserver.matchedResult = true; - env.clickSegmentRef(segmentRef); - env.insertNoteFabMobile!.click(); - env.wait(); - env.component.mobileNoteControl.setValue(content); - env.saveMobileNoteButton!.click(); - env.wait(); - const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); - expect(noteThread.verseRef).toEqual(fromVerseRef(verseRef)); - expect(noteThread.publishedToSF).toBe(true); - expect(noteThread.notes[0].ownerRef).toEqual(userId); - expect(noteThread.notes[0].content).toEqual(content); - - env.dispose(); - })); - - it('shows fab for users with editing rights but uses bottom sheet for adding new notes on mobile viewport', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.setCurrentUser('user04'); - env.wait(); - - // Allow check for mobile viewports to return TRUE - env.breakpointObserver.matchedResult = true; - env.clickSegmentRef('verse_1_2'); - expect(env.insertNoteFabMobile).toBeFalsy(); - expect(env.insertNoteFab).toBeTruthy(); - env.insertNoteFab.nativeElement.click(); - env.wait(); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); - expect(env.mobileNoteTextArea).toBeTruthy(); - expect(env.component.currentSegmentReference).toEqual('Matthew 1:2'); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).never(); - // Close the bottom sheet - env.bottomSheetCloseButton!.click(); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); - env.wait(); - - env.dispose(); - })); - - it('shows current selected verse on bottom sheet', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - - // Allow check for mobile viewports to return TRUE - env.breakpointObserver.matchedResult = true; - env.clickSegmentRef('verse_1_1'); - env.wait(); - expect(env.insertNoteFabMobile).toBeTruthy(); - env.insertNoteFabMobile!.click(); - expect(env.bottomSheetVerseReference?.textContent).toEqual('Luke 1:1'); - const content = 'commenter leaving mobile note'; - env.component.mobileNoteControl.setValue(content); - env.saveMobileNoteButton!.click(); - env.wait(); - const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); - expect(noteThread.verseRef).toEqual(fromVerseRef(new VerseRef('LUK 1:1'))); - expect(noteThread.notes[0].content).toEqual(content); - env.dispose(); - })); - - it('can accept xml reserved symbols as note content', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - - // Allow check for mobile viewports to return TRUE - env.breakpointObserver.matchedResult = true; - env.clickSegmentRef('verse_1_1'); - env.wait(); - expect(env.insertNoteFabMobile).toBeTruthy(); - env.insertNoteFabMobile!.click(); - expect(env.bottomSheetVerseReference?.textContent).toEqual('Luke 1:1'); - const content = 'mobile with xml symbols'; - env.component.mobileNoteControl.setValue(content); - env.saveMobileNoteButton!.click(); - env.wait(); - const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); - expect(noteThread.verseRef).toEqual(fromVerseRef(new VerseRef('LUK 1:1'))); - expect(noteThread.notes[0].content).toEqual(XmlUtils.encodeForXml(content)); - env.dispose(); - })); - - it('can edit a note with xml reserved symbols as note content', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const projectId: string = 'project01'; - env.setSelectionAndInsertNote('verse_1_2'); - const content: string = 'content in the thread'; - env.mockNoteDialogRef.close({ noteContent: content }); - env.wait(); - verify(mockedSFProjectService.createNoteThread(projectId, anything())).once(); - const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); - let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc(projectId, noteThread.dataId); - expect(noteThreadDoc.data!.notes[0].content).toEqual(content); - - const iconElement: HTMLElement = env.getNoteThreadIconElementAtIndex('verse_1_2', 0)!; - iconElement.click(); - const editedContent = 'edited content & tags'; - env.mockNoteDialogRef.close({ noteDataId: noteThread.notes[0].dataId, noteContent: editedContent }); - env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); - noteThreadDoc = env.getNoteThreadDoc(projectId, noteThread.dataId); - expect(noteThreadDoc.data!.notes[0].content).toEqual(XmlUtils.encodeForXml(editedContent)); - env.dispose(); - })); - - it('shows SF note with default icon', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.addParatextNoteThread( - 6, - 'MAT 1:4', - 'target: chapter 1, verse 4.', - { start: 0, length: 0 }, - ['user01'], - NoteStatus.Todo, - undefined, - true, - true - ); - env.wait(); - - const sfNote = env.getNoteThreadIconElement('verse_1_4', 'dataid06')!; - expect(sfNote.getAttribute('style')).toEqual('--icon-file: url(/assets/icons/TagIcons/' + SF_TAG_ICON + '.png);'); - env.dispose(); - })); - - it('cannot insert a note when editor content unavailable', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.onlineStatus = false; - const textDoc: TextDoc = 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); - env.wait(); - env.insertNoteFab.nativeElement.click(); - env.wait(); - verify(mockedNoticeService.show(anything())).once(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).never(); - subject.next(); - subject.complete(); - env.wait(); - env.insertNoteFab.nativeElement.click(); - env.wait(); - verify(mockedNoticeService.show(anything())).once(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - expect().nothing(); - env.dispose(); - })); - - it('can insert note on verse at cursor position', fakeAsync(() => { - const projectId: string = 'project01'; - const userId: string = 'user01'; - const env = new TestEnvironment(); - env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); - env.wait(); - expect(env.component.target!.segment!.ref).toBe('verse_1_1'); - env.setSelectionAndInsertNote('verse_1_4'); - - const content: string = 'content in the thread'; - env.mockNoteDialogRef.close({ noteContent: content }); - env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const [, config] = capture(mockedMatDialog.open).last(); - const noteVerseRef: VerseRef = (config as MatDialogConfig).data!.verseRef; - expect(noteVerseRef.toString()).toEqual('MAT 1:4'); - - verify(mockedSFProjectService.createNoteThread(projectId, anything())).once(); - const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); - expect(noteThread.verseRef).toEqual(fromVerseRef(noteVerseRef)); - expect(noteThread.publishedToSF).toBe(true); - expect(noteThread.notes[0].ownerRef).toEqual(userId); - expect(noteThread.notes[0].content).toEqual(content); - expect(noteThread.notes[0].tagId).toEqual(2); - expect(env.isNoteIconHighlighted(noteThread.dataId)).toBeFalse(); - expect(env.component.target!.segment!.ref).toBe('verse_1_4'); - - env.dispose(); - })); - - it('allows adding a note to an existing thread', fakeAsync(() => { - 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); - expect(noteThread.data!.notes.length).toEqual(1); - - env.setProjectUserConfig(); - env.wait(); - const noteThreadIconElem: HTMLElement = env.getNoteThreadIconElement(segmentRef, threadDataId)!; - noteThreadIconElem.click(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const [, noteDialogData] = capture(mockedMatDialog.open).last(); - expect((noteDialogData!.data as NoteDialogData).threadDataId).toEqual(threadDataId); - env.mockNoteDialogRef.close({ noteContent: content }); - env.wait(); - noteThread = 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); - expect(noteThread.data!.notes[1].tagId).toBe(undefined); - env.dispose(); - })); - - it('allows resolving a note', fakeAsync(() => { - 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); - expect(noteThread.data!.notes.length).toEqual(3); - - env.setProjectUserConfig(); - env.wait(); - let noteThreadIconElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', threadDataId); - noteThreadIconElem!.click(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved }); - env.wait(); - noteThread = 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); - - // the icon should be hidden from the editor - noteThreadIconElem = env.getNoteThreadIconElement('verse_1_1', threadDataId); - expect(noteThreadIconElem).toBeNull(); - env.dispose(); - })); - - it('allows editing and 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); - expect(noteThread.data!.notes.length).toEqual(3); - // Mark the note as editable - await noteThread.submitJson0Op(op => op.set(nt => nt.notes[0].editable, true)); - env.setProjectUserConfig(); - env.wait(); - let noteThreadIconElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', threadDataId); - noteThreadIconElem!.click(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved, noteDataId: 'thread01_note0' }); - env.wait(); - noteThread = 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); - - // the icon should be hidden from the editor - noteThreadIconElem = env.getNoteThreadIconElement('verse_1_1', threadDataId); - expect(noteThreadIconElem).toBeNull(); - env.dispose(); - })); - - it('does not allow editing and resolving a non-editable note', fakeAsync(() => { - 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); - expect(noteThread.data!.notes.length).toEqual(3); - env.setProjectUserConfig(); - env.wait(); - const noteThreadIconElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', threadDataId); - noteThreadIconElem!.click(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved, noteDataId: 'thread01_note0' }); - env.wait(); - noteThread = env.getNoteThreadDoc(projectId, threadDataId); - expect(noteThread.data!.notes.length).toEqual(3); - expect(dialogMessage).toHaveBeenCalledTimes(1); - env.dispose(); - })); - - it('can open dialog of the second note on the verse', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - expect(env.getNoteThreadIconElement('verse_1_3', 'dataid02')).not.toBeNull(); - env.setSelectionAndInsertNote('verse_1_3'); - const noteDialogResult: NoteDialogResult = { noteContent: 'newly created comment', noteDataId: 'notenew01' }; - env.mockNoteDialogRef.close(noteDialogResult); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - env.wait(); - - const noteElement: HTMLElement = env.getNoteThreadIconElementAtIndex('verse_1_3', 1)!; - noteElement.click(); - tick(); - env.fixture.detectChanges(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); - - // can open note on existing verse - const existingNoteIcon: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid03')!; - existingNoteIcon.click(); - env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).thrice(); - env.mockNoteDialogRef.close(); - env.setSelectionAndInsertNote('verse_1_3'); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).times(4); - env.dispose(); - })); - - it('commenters can click to select verse', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - env.wait(); - - const hasSelectionAnchors = env.getSegmentElement('verse_1_1')!.querySelector('display-text-anchor'); - expect(hasSelectionAnchors).toBeNull(); - const verseElem: HTMLElement = env.getSegmentElement('verse_1_1')!; - expect(verseElem.classList).not.toContain('commenter-selection'); - - // select verse 1 - verseElem.click(); - env.wait(); - expect(verseElem.classList).toContain('commenter-selection'); - let verse2Elem: HTMLElement = env.getSegmentElement('verse_1_2')!; - - // select verse 2, deselect verse one - verse2Elem.click(); - env.wait(); - expect(verse2Elem.classList).toContain('commenter-selection'); - expect(verseElem.classList).not.toContain('commenter-selection'); - - // deselect verse 2 - verse2Elem.click(); - env.wait(); - expect(verse2Elem.classList).not.toContain('commenter-selection'); - - // reselect verse 2, check that it is not selected when moving to a new book - verse2Elem.click(); - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - verse2Elem = env.getSegmentElement('verse_1_2')!; - expect(verse2Elem.classList).not.toContain('commenter-selection'); - const verse3Elem: HTMLElement = env.getSegmentElement('verse_1_3')!; - verse3Elem.click(); - expect(verse3Elem.classList).toContain('commenter-selection'); - expect(verse2Elem.classList).not.toContain('commenter-selection'); - env.dispose(); - })); - - it('does not select verse when opening a note thread', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - env.addParatextNoteThread( - 6, - 'MAT 1:1', - '', - { start: 0, length: 0 }, - ['user01'], - NoteStatus.Todo, - undefined, - true - ); - env.wait(); - - const elem: HTMLElement = env.getNoteThreadIconElement('verse_1_1', 'dataid06')!; - elem.click(); - env.mockNoteDialogRef.close(); - env.wait(); - const verse1Elem: HTMLElement = env.getSegmentElement('verse_1_1')!; - expect(verse1Elem.classList).not.toContain('commenter-selection'); - - // select verse 3 after closing the dialog - const verse3Elem: HTMLElement = env.getSegmentElement('verse_1_3')!; - verse3Elem.click(); - expect(verse1Elem.classList).not.toContain('commenter-selection'); - env.dispose(); - })); - - it('updates verse selection when opening a note dialog', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const segmentRef = 'verse_1_1'; - env.clickSegmentRef(segmentRef); - env.wait(); - const verse1Elem: HTMLElement = env.getSegmentElement(segmentRef)!; - expect(verse1Elem.classList).toContain('commenter-selection'); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); - - // simulate clicking the note icon on verse 3 - const segmentRef3 = 'verse_1_3'; - const thread2Position: number = env.getNoteThreadEditorPosition('dataid02'); - env.targetEditor.setSelection(thread2Position, 'user'); - const noteElem: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; - noteElem.click(); - env.wait(); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - instance(mockedMatDialog).closeAll(); - env.wait(); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); - const verse3Elem: HTMLElement = env.getSegmentElement(segmentRef3)!; - expect(verse3Elem.classList).toContain('commenter-selection'); - expect(verse1Elem.classList).not.toContain('commenter-selection'); - env.dispose(); - })); - - it('deselects a verse when bottom sheet is open and chapter changed', fakeAsync(() => { - const env = new TestEnvironment(); - env.ngZone.run(() => { - env.setProjectUserConfig(); - env.breakpointObserver.matchedResult = true; - env.wait(); + env.dispose(); + })); + + it('select non-blank segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(false); + + resetCalls(env.mockedRemoteTranslationEngine); + const range = env.component.target!.getSegmentRange('verse_1_3'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_3'); + const selection = env.targetEditor.getSelection(); + // 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'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(false); - const segmentRef = 'verse_1_1'; - env.setSelectionAndInsertNote(segmentRef); - expect(env.mobileNoteTextArea).toBeTruthy(); - env.component.chapter = 2; - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); - env.wait(); - env.clickSegmentRef('verse_2_2'); - env.wait(); - const verse1Elem: HTMLElement = env.getSegmentElement('verse_2_1')!; - expect(verse1Elem.classList).not.toContain('commenter-selection'); - const verse2Elem: HTMLElement = env.getSegmentElement('verse_2_2')!; - expect(verse2Elem.classList).toContain('commenter-selection'); - }); - env.dispose(); - })); - - it('keeps insert note fab hidden for commenters on mobile devices', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.breakpointObserver.matchedResult = true; - env.addParatextNoteThread( - 6, - 'MAT 1:1', - '', - { start: 0, length: 0 }, - ['user01'], - NoteStatus.Todo, - undefined, - true - ); - env.setCommenterUser(); - env.wait(); - - env.clickSegmentRef('verse_1_3'); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); - const noteElem: HTMLElement = env.getNoteThreadIconElement('verse_1_1', 'dataid06')!; - noteElem.click(); - env.wait(); - env.mockNoteDialogRef.close(); - env.wait(); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); - // clean up - env.clickSegmentRef('verse_1_3'); - env.dispose(); - })); - - it('shows the correct combined verse ref for a new note', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - - const segmentRef = 'verse_1_2-3'; - env.setSelectionAndInsertNote(segmentRef); - const verseRef = new VerseRef('LUK', '1', '2-3'); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const [, config] = capture(mockedMatDialog.open).last(); - expect((config!.data! as NoteDialogData).verseRef!.equals(verseRef)).toBeTrue(); - env.dispose(); - })); - - it('does not allow selecting section headings', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - - let elem: HTMLElement = env.getSegmentElement('s_1')!; - expect(elem.classList).not.toContain('commenter-selection'); - env.clickSegmentRef('s_1'); - expect(elem.classList).not.toContain('commenter-selection'); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); - - elem = env.getSegmentElement('s_2')!; - expect(elem.classList).not.toContain('commenter-selection'); - env.clickSegmentRef('s_2'); - expect(elem.classList).not.toContain('commenter-selection'); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); - - const verseElem: HTMLElement = env.getSegmentElement('verse_1_2-3')!; - expect(verseElem.classList).not.toContain('commenter-selection'); - env.clickSegmentRef('verse_1_2-3'); - expect(verseElem.classList).toContain('commenter-selection'); - expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); - expect(elem.classList).not.toContain('commenter-selection'); - env.dispose(); - })); - - it('commenters can create note on selected verse with FAB', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.setCommenterUser(); - env.wait(); - - const verseSegment: HTMLElement = env.getSegmentElement('verse_1_5')!; - verseSegment.click(); - env.wait(); - expect(verseSegment.classList).toContain('commenter-selection'); - - // Change to a PT reviewer to assert they can also use the FAB - verseSegment.click(); - expect(verseSegment.classList).not.toContain('commenter-selection'); - env.setParatextReviewerUser(); - env.wait(); - verseSegment.click(); - expect(verseSegment.classList).toContain('commenter-selection'); - - // Click and open the dialog - env.insertNoteFab.nativeElement.click(); - env.wait(); - verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); - const [, arg2] = capture(mockedMatDialog.open).last(); - const verseRef: VerseRef = (arg2 as MatDialogConfig).data.verseRef!; - expect(verseRef.toString()).toEqual('MAT 1:5'); - env.dispose(); - })); - - it('should remove resolved notes after a remote update', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - let contents = env.targetEditor.getContents(); - let noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); - expect(noteThreadEmbedCount).toEqual(5); - - 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(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const threadId = 'dataid02'; - const segmentRef = 'verse_1_3'; - let thread2Elem: HTMLElement | null = env.getNoteThreadIconElement(segmentRef, threadId); - expect(thread2Elem).not.toBeNull(); - 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 originalPos: TextAnchor = { start: 8, length: 9 }; - expect(noteThreadDoc.data!.position).toEqual(originalPos); - env.typeCharacters('t'); - expect(noteThreadDoc.data!.position).toEqual({ start: originalPos.start, length: originalPos.length + 1 }); - env.dispose(); - })); - - it('should position FAB beside selected segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - const segmentRef = 'verse_1_1'; - - env.clickSegmentRef(segmentRef); - env.wait(); - - const segmentElRect = env.getSegmentElement(segmentRef)!.getBoundingClientRect(); - const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); - expect(segmentElRect.top).toBeCloseTo(fabRect.top, 0); - - env.dispose(); - })); - - it('should position FAB beside selected segment when scrolling segment in view', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - // Set window size to be narrow to test scrolling - const contentContainer: HTMLElement = document.getElementsByClassName('content')[0] as HTMLElement; - Object.assign(contentContainer.style, { width: '360px', height: '300px' }); - - const segmentRef = 'verse_1_1'; - - // Select segment - env.clickSegmentRef(segmentRef); - env.wait(); - - // Scroll, keeping selected segment in view - const scrollContainer: Element = env.component['targetScrollContainer'] as Element; - scrollContainer.scrollTop = 20; - scrollContainer.dispatchEvent(new Event('scroll')); - - const segmentElRect = env.getSegmentElement(segmentRef)!.getBoundingClientRect(); - const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); - expect(Math.ceil(fabRect.top)).toEqual(Math.ceil(segmentElRect.top)); - - env.dispose(); - })); - - it('should position FAB within scroll container when scrolling segment above view', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - // Set window size to be narrow to test scrolling - const contentContainer: HTMLElement = document.getElementsByClassName('content')[0] as HTMLElement; - Object.assign(contentContainer.style, { width: '360px', height: '300px' }); - - // Verse near top of scroll container - const segmentRef = 'verse_1_1'; - - // Select segment - env.clickSegmentRef(segmentRef); - env.wait(); - - const scrollContainer: Element = env.component['targetScrollContainer'] as Element; - const scrollContainerRect: DOMRect = scrollContainer.getBoundingClientRect(); - - // Scroll segment above view - scrollContainer.scrollTop = 200; - scrollContainer.dispatchEvent(new Event('scroll')); - - const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); - expect(Math.ceil(fabRect.top)).toEqual(Math.ceil(scrollContainerRect.top + env.component.fabVerticalCushion)); - - env.dispose(); - })); - - it('should position FAB within scroll container when scrolling segment below view', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); - - // Set window size to be narrow to test scrolling - const contentContainer: HTMLElement = document.getElementsByClassName('content')[0] as HTMLElement; - Object.assign(contentContainer.style, { width: '680px', height: '300px' }); - - // Verse near bottom of scroll container - const segmentRef = 'verse_1_6'; + env.dispose(); + })); + + it('select blank segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + + resetCalls(env.mockedRemoteTranslationEngine); + const range = env.component.target!.getSegmentRange('verse_1_2'); + env.targetEditor.setSelection(range!.index + 1, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_2'); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(33); + expect(selection!.length).toBe(0); + expect(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']); - // Select segment - env.clickSegmentRef(segmentRef); - env.wait(); + env.dispose(); + })); + + it('delete all text from non-verse paragraph segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'p_1' }); + env.wait(); + let segmentRange = env.component.target!.segment!.range; + let segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); + let op = segmentContents.ops![0]; + expect((op.insert as any).blank).toBe(modelHasBlanks); + expect(op.attributes!.segment).toEqual('p_1'); + + const index = env.typeCharacters('t'); + segmentRange = env.component.target!.segment!.range; + segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); + op = segmentContents.ops![0]; + expect((op.insert as any).blank).toBeUndefined(); + expect(op.attributes!.segment).toEqual('p_1'); + + env.targetEditor.setSelection(index - 2, 1, 'user'); + env.deleteCharacters(); + segmentRange = env.component.target!.segment!.range; + segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); + op = segmentContents.ops![0]; + expect((op.insert as any).blank).toBe(false); // Blank from view model + expect(op.attributes!.segment).toEqual('p_1'); - const scrollContainer: Element = env.component['targetScrollContainer'] as Element; - const scrollContainerRect: DOMRect = scrollContainer.getBoundingClientRect(); + env.dispose(); + })); + + it('delete all text from verse paragraph segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_4/p_1' }); + env.wait(); + let segmentRange = env.component.target!.segment!.range; + let segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); + let op = segmentContents.ops![0]; + expect(op.insert).toEqual({ + 'note-thread-embed': { + iconsrc: '--icon-file: url(/assets/icons/TagIcons/01flag1.png);', + preview: 'Note from user01', + threadid: 'dataid05' + } + }); + op = segmentContents.ops![1]; + expect((op.insert as any).blank).toBeUndefined(); + expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); + + let index = env.targetEditor.getSelection()!.index; + const length = 'Paragraph break.'.length; + env.targetEditor.setSelection(index - length, length, 'user'); + index = env.typeCharacters('t'); + segmentRange = env.component.target!.segment!.range; + segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); + + // The note remains, the blank is removed + op = segmentContents.ops![0]; + expect(op.insert).toEqual({ + 'note-thread-embed': { + iconsrc: '--icon-file: url(/assets/icons/TagIcons/01flag1.png);', + preview: 'Note from user01', + threadid: 'dataid05' + } + }); + op = segmentContents.ops![1]; + expect((op.insert as any).blank).toBeUndefined(); + expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); + + env.targetEditor.setSelection(index - 1, 1, 'user'); + env.deleteCharacters(); + segmentRange = env.component.target!.segment!.range; + segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); + + // The note remains, the blank returns + op = segmentContents.ops![0]; + expect(op.insert).toEqual({ + 'note-thread-embed': { + iconsrc: '--icon-file: url(/assets/icons/TagIcons/01flag1.png);', + preview: 'Note from user01', + threadid: 'dataid05' + } + }); + op = segmentContents.ops![1]; + expect((op.insert as any).blank).toBe(false); + expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); - // Scroll segment below view - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event('scroll')); + env.dispose(); + })); - const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); - expect(Math.ceil(fabRect.bottom)).toEqual( - Math.ceil(scrollContainerRect.bottom - env.component.fabVerticalCushion) - ); + it('selection not at end of incomplete segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.component.target!.segmentRef).toBe(''); - env.dispose(); - })); - }); + const range = env.component.target!.getSegmentRange('verse_1_5'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(false); - describe('Translation Suggestions disabled', () => { - it('start with no previous selection', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - env.dispose(); - })); - - it('start with previously selected segment', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2, selectedSegment: 'verse_2_1' }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(2); - expect(env.component.verse).toBe('1'); - expect(env.component.target!.segmentRef).toEqual('verse_2_1'); - const selection = env.targetEditor.getSelection(); - expect(selection!.index).toBe(50); - expect(selection!.length).toBe(0); - verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - expect(env.component.showSuggestions).toBe(false); - env.dispose(); - })); - - it('user cannot edit', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setCurrentUser('user02'); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(false); - env.dispose(); - })); - - it('user can edit a chapter with permission', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(2); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - env.dispose(); - })); - - it('translator cannot edit a chapter without edit permission on chapter', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.bookName).toEqual('Luke'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.userHasGeneralEditRight).toBe(true); - expect(env.component.hasChapterEditPermission).toBe(false); - expect(env.component.canEdit).toBe(false); - expect(env.isSourceAreaHidden).toBe(false); - expect(env.noChapterEditPermissionMessage).toBeTruthy(); - env.dispose(); - })); - - it('user has no resource access', fakeAsync(() => { - when(mockedSFProjectService.getProfile('resource01')).thenResolve({ - id: 'resource01', - data: createTestProjectProfile() - } as SFProjectProfileDoc); - - const env = new TestEnvironment(); - env.setupProject({ - translateConfig: { - translationSuggestionsEnabled: false, - source: { - paratextId: 'resource01', - name: 'Resource 1', - shortName: 'SRC', - projectRef: 'resource01', - writingSystem: { - tag: 'qaa' - } - } - } - }); - env.setCurrentUser('user01'); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); - env.wait(); - verify(mockedSFProjectService.get('resource01')).never(); - expect(env.bookName).toEqual('Acts'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - env.dispose(); - })); - - it('chapter is invalid', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); - env.wait(); - expect(env.bookName).toEqual('Mark'); - expect(env.component.chapter).toBe(1); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(false); - expect(env.invalidWarning).not.toBeNull(); - env.dispose(); - })); - - it('first chapter is missing', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setProjectUserConfig(); - env.routeWithParams({ projectId: 'project01', bookId: 'ROM' }); - env.wait(); - expect(env.bookName).toEqual('Romans'); - expect(env.component.chapter).toBe(2); - expect(env.component.sourceLabel).toEqual('SRC'); - expect(env.component.targetLabel).toEqual('TRG'); - expect(env.component.target!.segmentRef).toEqual(''); - const selection = env.targetEditor.getSelection(); - expect(selection).toBeNull(); - expect(env.component.canEdit).toBe(true); - env.dispose(); - })); - - it('prevents editing and informs user when text doc is corrupted', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig }); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 3 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); - expect(env.component.hasEditRight).toBe(true); - expect(env.component.canEdit).toBe(false); - expect(env.corruptedWarning).not.toBeNull(); - env.dispose(); - })); - - it('shows translator settings when suggestions are enabled for the project and user can edit project', fakeAsync(() => { - const projectConfig = { - translateConfig: { ...defaultTranslateConfig, translationSuggestionsEnabled: true } - }; - const navigationParams: Params = { projectId: 'project01', bookId: 'MRK' }; - - const env = new TestEnvironment(); - env.setupProject(projectConfig); - env.setProjectUserConfig(); - env.routeWithParams(navigationParams); - env.wait(); - expect(env.suggestionsSettingsButton).toBeTruthy(); - env.dispose(); - })); - - it('hides translator settings when suggestions are enabled for the project but user cant edit', fakeAsync(() => { - const projectConfig = { - translateConfig: { ...defaultTranslateConfig, translationSuggestionsEnabled: true } - }; - const navigationParams: Params = { projectId: 'project01', bookId: 'MRK' }; - - const env = new TestEnvironment(); - env.setCurrentUser('user06'); //has read but not edit - env.setupProject(projectConfig); - env.setProjectUserConfig(); - env.routeWithParams(navigationParams); - env.wait(); - expect(env.suggestionsSettingsButton).toBeFalsy(); - env.dispose(); - })); - - it('hides translator settings when suggestions are disabled for the project', fakeAsync(() => { - const projectConfig = { - translateConfig: { ...defaultTranslateConfig, translationSuggestionsEnabled: false } - }; - const navigationParams: Params = { projectId: 'project01', bookId: 'MRK' }; - - const env = new TestEnvironment(); - env.setupProject(projectConfig); - env.setProjectUserConfig(); - env.routeWithParams(navigationParams); - env.wait(); - expect(env.suggestionsSettingsButton).toBeFalsy(); - env.dispose(); - })); - - it('shows the copyright banner', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ translateConfig: defaultTranslateConfig, copyrightBanner: 'banner text' }); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 3 }); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); - env.wait(); - expect(env.copyrightBanner).not.toBeNull(); - env.dispose(); - })); - }); + env.dispose(); + })); + + it('selection at end of incomplete segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.component.target!.segmentRef).toBe(''); + + const range = env.component.target!.getSegmentRange('verse_1_5'); + env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(true); + expect(env.component.suggestions[0].words).toEqual(['verse', '5']); - it('sets book and chapter according to route', fakeAsync(() => { - const navigationParams: Params = { projectId: 'project01', bookId: 'MAT', chapter: '2' }; - const env = new TestEnvironment(); - - env.setProjectUserConfig(); - env.routeWithParams(navigationParams); - env.wait(); - - expect(env.bookName).toEqual('Matthew'); - expect(env.component.chapter).toBe(2); - - env.dispose(); - })); - - it('navigates to alternate chapter if specified chapter does not exist', fakeAsync(() => { - const env = new TestEnvironment(); - const nonExistentChapter = 3; - const routerSpy = spyOn(env.router, 'navigateByUrl').and.callThrough(); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: nonExistentChapter }); - env.wait(); - - expect(routerSpy).toHaveBeenCalledWith('/projects/project01/translate/MAT/1'); - env.dispose(); - })); - - it('should navigate to "projects" route if url book is not in project', fakeAsync(() => { - const navigationParams: Params = { projectId: 'project01', bookId: 'GEN', chapter: '2' }; - const env = new TestEnvironment(); - flush(); - const spyRouterNavigate = spyOn(env.router, 'navigateByUrl'); - - env.routeWithParams(navigationParams); - env.wait(); - - expect(spyRouterNavigate).toHaveBeenCalledWith('projects', jasmine.any(Object)); - discardPeriodicTasks(); - })); - - describe('tabs', () => { - describe('tab group consolidation', () => { - it('should call consolidateTabGroups for small screen widths once editor is loaded and tab state is initialized', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + env.dispose(); + })); + + it('should increment offered suggestion count when inserting suggestion', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.component.target!.segmentRef).toBe(''); + const range = env.component.target!.getSegmentRange('verse_1_5'); + env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.component.showSuggestions).toBe(true); + expect(env.component.suggestions[0].words).toEqual(['verse', '5']); + expect(env.component.metricsSession?.metrics.type).toEqual('navigate'); + + env.insertSuggestion(); + + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); + expect(env.component.showSuggestions).toBe(false); + expect(env.component.metricsSession?.metrics.type).toEqual('edit'); + expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); + tick(ACTIVE_EDIT_TIMEOUT); + expect(env.component.metricsSession?.metrics.type).toEqual('edit'); + expect(env.component.metricsSession?.metrics.suggestionAcceptedCount).toBe(1); + expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); + env.dispose(); + })); + + it("should not increment accepted suggestion if the content doesn't change", fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.component.target!.segmentRef).toBe(''); + const range = env.component.target!.getSegmentRange('verse_1_5'); + env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); + env.wait(); + env.typeCharacters('verse 5'); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); + expect(env.component.showSuggestions).toBe(true); + expect(env.component.suggestions[0].words).toEqual(['5']); + expect(env.component.metricsSession?.metrics.type).toEqual('edit'); + expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); + expect(env.component.metricsSession?.metrics.suggestionAcceptedCount).toBeUndefined(); + + env.insertSuggestion(); + + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); + expect(env.component.showSuggestions).toBe(false); + tick(ACTIVE_EDIT_TIMEOUT); + expect(env.component.metricsSession?.metrics.type).toEqual('edit'); + expect(env.component.metricsSession?.metrics.suggestionTotalCount).toBe(1); + expect(env.component.metricsSession?.metrics.suggestionAcceptedCount).toBeUndefined(); + env.dispose(); + })); - expect(spyConsolidate).not.toHaveBeenCalled(); - env.breakpointObserver.emitObserveValue(true); - env.component['tabStateInitialized$'].next(true); - expect(spyConsolidate).not.toHaveBeenCalled(); - env.component['targetEditorLoaded$'].next(); - env.wait(); - expect(spyConsolidate).toHaveBeenCalled(); - expect(env.component.source?.id?.toString()).toEqual('project02:MAT:1:target'); - discardPeriodicTasks(); - })); + it('should display the verse too long error', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); - it('should call deconsolidateTabGroups for large screen widths once editor is loaded and tab state is initialized', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - const spyDeconsolidate = spyOn(env.component.tabState, 'deconsolidateTabGroups'); - expect(spyDeconsolidate).not.toHaveBeenCalled(); - env.breakpointObserver.emitObserveValue(false); - env.component['tabStateInitialized$'].next(true); - expect(spyDeconsolidate).not.toHaveBeenCalled(); - env.component['targetEditorLoaded$'].next(); - env.wait(); - expect(spyDeconsolidate).toHaveBeenCalled(); - expect(env.component.source?.id?.toString()).toEqual('project02:MAT:1:target'); - discardPeriodicTasks(); - })); + // Change to the long verse + const range = env.component.target!.getSegmentRange('verse_1_6'); + env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); + env.wait(); - it('should not set id on source tab if user does not have permission', fakeAsync(() => { - const env = new TestEnvironment(env => { - env.setCurrentUser('user05'); - env.setupUsers(['project01']); - env.setupProject({ userRoles: { user05: SFProjectRole.None } }, 'project02'); - }); - expect(env.component.source?.id?.toString()).toBeUndefined(); - const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + // Verify an error displayed + expect(env.component.target!.segmentRef).toBe('verse_1_6'); + expect(env.component.showSuggestions).toBe(false); + verify(mockedNoticeService.show(anything())).once(); - expect(spyConsolidate).not.toHaveBeenCalled(); - env.component['tabStateInitialized$'].next(true); - expect(spyConsolidate).not.toHaveBeenCalled(); - env.component['targetEditorLoaded$'].next(); - env.wait(); - expect(spyConsolidate).not.toHaveBeenCalled(); - expect(env.component.source?.id?.toString()).toBeUndefined(); - discardPeriodicTasks(); - })); + env.dispose(); + })); + + it('should not display the verse too long error if user has suggestions disabled', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedBookNum: 40, + selectedChapterNum: 1, + selectedSegment: 'verse_1_5', + translationSuggestionsEnabled: false + }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(false); + + // Change to the long verse + const range = env.component.target!.getSegmentRange('verse_1_6'); + env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); + env.wait(); + + // Verify an error did not display + expect(env.component.target!.segmentRef).toBe('verse_1_6'); + expect(env.component.showSuggestions).toBe(false); + verify(mockedNoticeService.show(anything())).never(); - it('should not consolidate if showSource is false', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => false }); - }); - const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + env.dispose(); + })); + + it('should not call getWordGraph if user has suggestions disabled', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedBookNum: 40, + selectedChapterNum: 1, + selectedSegment: 'verse_1_5', + translationSuggestionsEnabled: false + }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(false); + + // Change to the long verse + const range = env.component.target!.getSegmentRange('verse_1_6'); + env.targetEditor.setSelection(range!.index + range!.length, 0, 'user'); + env.wait(); + + // Verify an error did not display + expect(env.component.target!.segmentRef).toBe('verse_1_6'); + expect(env.component.showSuggestions).toBe(false); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - env.component['tabStateInitialized$'].next(true); - env.component['targetEditorLoaded$'].next(); - expect(spyConsolidate).not.toHaveBeenCalled(); - flush(); - })); + env.dispose(); + })); - it('should not consolidate on second editor load', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); + it('insert suggestion in non-blank segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); - env.component['tabStateInitialized$'].next(true); - env.component['targetEditorLoaded$'].next(); + env.insertSuggestion(); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); + expect(env.component.showSuggestions).toBe(false); - const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + env.dispose(); + })); + + it('insert second suggestion in non-blank segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedBookNum: 40, + selectedChapterNum: 1, + selectedSegment: 'verse_1_5', + numSuggestions: 2 + }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); + + env.downArrow(); + env.insertSuggestion(); + expect(env.component.target!.segmentText).toBe('target: chapter 1, versa 5'); + expect(env.component.showSuggestions).toBe(false); - env.component['targetEditorLoaded$'].next(); - expect(spyConsolidate).not.toHaveBeenCalled(); - flush(); - })); - }); + env.dispose(); + })); + + it('insert space when typing character after inserting a suggestion', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); + + env.insertSuggestion(1); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse'); + expect(env.component.showSuggestions).toBe(true); + + const selectionIndex = env.typeCharacters('5.'); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5.'); + expect(env.component.showSuggestions).toBe(false); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(selectionIndex + 1); + expect(selection!.length).toBe(0); - describe('initEditorTabs', () => { - it('should add source tab when source is defined and viewable', fakeAsync(() => { - const env = new TestEnvironment(); - const projectDoc = env.getProjectDoc('project01'); - const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); - env.wait(); - expect(spyCreateTab).toHaveBeenCalledWith('project-source', { - projectId: projectDoc.data?.translateConfig.source?.projectRef, - headerText$: jasmine.any(Object), - tooltip: projectDoc.data?.translateConfig.source?.name - }); - discardPeriodicTasks(); - })); + env.dispose(); + })); + + it('insert space when inserting a suggestion after inserting a previous suggestion', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); + + env.insertSuggestion(1); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse'); + expect(env.component.showSuggestions).toBe(true); + + let selection = env.targetEditor.getSelection(); + const selectionIndex = selection!.index; + env.insertSuggestion(1); + expect(env.component.target!.segmentText).toEqual('target: chapter 1, verse 5'); + expect(env.component.showSuggestions).toBe(false); + selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(selectionIndex + 2); + expect(selection!.length).toBe(0); - it('should not add source tab when source is defined but not viewable', fakeAsync(() => { - const env = new TestEnvironment(); - when(mockedPermissionsService.isUserOnProject('project02')).thenResolve(false); - const projectDoc = env.getProjectDoc('project01'); - const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); - env.wait(); - expect(spyCreateTab).not.toHaveBeenCalledWith('project-source', { - projectId: projectDoc.data?.translateConfig.source?.projectRef, - headerText$: jasmine.any(Object), - tooltip: projectDoc.data?.translateConfig.source?.name - }); - discardPeriodicTasks(); - })); + env.dispose(); + })); + + it('do not insert space when typing punctuation after inserting a suggestion', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); + + env.insertSuggestion(1); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse'); + expect(env.component.showSuggestions).toBe(true); + + const selectionIndex = env.typeCharacters('.'); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse.'); + expect(env.component.showSuggestions).toBe(false); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(selectionIndex); + expect(selection!.length).toBe(0); - it('should not add source tab when source is undefined', fakeAsync(() => { - const env = new TestEnvironment(); - const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); - delete env.testProjectProfile.translateConfig.source; - env.setupProject(); - env.wait(); - expect(spyCreateTab).not.toHaveBeenCalledWith('project-source', jasmine.any(Object)); - discardPeriodicTasks(); - })); + env.dispose(); + })); - it('should add target tab', fakeAsync(() => { - const env = new TestEnvironment(); - const projectDoc = env.getProjectDoc('project01'); - const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); - env.wait(); - expect(spyCreateTab).toHaveBeenCalledWith('project-target', { - projectId: projectDoc.id, - headerText$: jasmine.any(Object), - tooltip: projectDoc.data?.name - }); - discardPeriodicTasks(); - })); + it('train a modified segment after selecting a different segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); - it('should add source and target groups', fakeAsync(() => { - const env = new TestEnvironment(); - const spyCreateTab = spyOn(env.component.tabState, 'setTabGroups').and.callThrough(); - env.wait(); - expect(spyCreateTab).toHaveBeenCalledWith( - jasmine.arrayWithExactContents([ - jasmine.any(TabGroup), - jasmine.any(TabGroup) - ]) - ); - discardPeriodicTasks(); - })); + env.insertSuggestion(); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - it('should not add the biblical terms tab if the project does not have biblical terms enabled', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: false } }); - env.setProjectUserConfig({ editorTabsOpen: [{ tabType: 'biblical-terms', groupId: 'source' }] }); - const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); - env.wait(); - expect(spyCreateTab).not.toHaveBeenCalledWith('biblical-terms', jasmine.any(Object)); - discardPeriodicTasks(); - })); + const range = env.component.target!.getSegmentRange('verse_1_1'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), 'target: chapter 1, verse 5', true)).once(); - it('should add the biblical terms tab if the project has biblical terms enabled', fakeAsync(() => { - const env = new TestEnvironment(); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); - env.setProjectUserConfig({ editorTabsOpen: [{ tabType: 'biblical-terms', groupId: 'source' }] }); - const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); - env.wait(); - expect(spyCreateTab).toHaveBeenCalledWith('biblical-terms', jasmine.any(Object)); - discardPeriodicTasks(); - })); + env.dispose(); + })); + + it('does not train a modified segment after selecting a different segment if offline', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedTask: 'translate', + selectedBookNum: 40, + selectedChapterNum: 1, + selectedSegment: 'verse_1_5', + projectRef: 'project01' + }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); + env.insertSuggestion(); + const text = 'target: chapter 1, verse 5'; + expect(env.component.target!.segmentText).toBe(text); + env.onlineStatus = false; + const range = env.component.target!.getSegmentRange('verse_1_1'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + verify( + mockedTranslationEngineService.storeTrainingSegment('project01', 'project02', 40, 1, 'verse_1_5') + ).once(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).never(); - it('should exclude deleted resource tabs (tabs that have "projectDoc" but not "projectDoc.data")', fakeAsync(async () => { - const absentProjectId = 'absentProjectId'; - when(mockedSFProjectService.getProfile(absentProjectId)).thenResolve({ - data: undefined - } as SFProjectProfileDoc); - const env = new TestEnvironment(); - env.setProjectUserConfig({ - editorTabsOpen: [{ tabType: 'project-resource', groupId: 'target', projectId: absentProjectId }] - }); - env.routeWithParams({ projectId: 'project01', bookId: 'GEN', chapter: '1' }); - env.wait(); + env.dispose(); + })); + + it('train a modified segment after switching to another text and back', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); + + env.insertSuggestion(); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); + + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + expect(env.bookName).toEqual('Mark'); + expect(env.component.target!.segmentRef).toEqual('verse_1_5'); + verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).never(); + + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.target!.segmentRef).toEqual('verse_1_5'); + const range = env.component.target!.getSegmentRange('verse_1_1'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), 'target: chapter 1, verse 5', true)).once(); - const tabs = await firstValueFrom(env.component.tabState.tabs$); - expect(tabs.find(t => t.projectId === absentProjectId)).toBeUndefined(); - env.dispose(); - })); - }); + env.dispose(); + })); + + it('train a modified segment after selecting a segment in a different text', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedTask: 'translate', + selectedBookNum: 40, + selectedChapterNum: 1, + selectedSegment: 'verse_1_5', + selectedSegmentChecksum: 0 + }); + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe(''); + expect(env.component.showSuggestions).toBe(false); + + const range = env.component.target!.getSegmentRange('verse_1_1'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(mockedTranslationEngineService.trainSelectedSegment(anything(), anything())).once(); - describe('updateAutoDraftTabVisibility', () => { - it('should add the draft preview tab to source when available and "showSource" is true', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + env.dispose(); + })); - const tabGroup = env.component.tabState.getTabGroup('source'); - expect(tabGroup?.tabs[1].type).toEqual('draft'); + it('do not train an unmodified segment after selecting a different segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_5' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_5'); + expect(env.component.showSuggestions).toBe(true); - const targetTabGroup = env.component.tabState.getTabGroup('target'); - expect(targetTabGroup?.tabs[1]).toBeUndefined(); + env.insertSuggestion(); + expect(env.component.target!.segmentText).toBe('target: chapter 1, verse 5'); - env.dispose(); - })); + const selection = env.targetEditor.getSelection(); + env.targetEditor.deleteText(selection!.index - 7, 7, 'user'); + env.wait(); + expect(env.component.target!.segmentText).toBe('target: chapter 1, '); - it('should add draft preview tab to target when available and "showSource" is false', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => false }); - }); - when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + const range = env.component.target!.getSegmentRange('verse_1_1'); + env.targetEditor.setSelection(range!.index, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.trainSegment(anything(), anything(), anything())).never(); - const targetTabGroup = env.component.tabState.getTabGroup('target'); - expect(targetTabGroup?.tabs[1].type).toEqual('draft'); + env.dispose(); + })); + + it('does not build machine project if no source books exists', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + when(mockedTranslationEngineService.checkHasSourceBooks(anything())).thenReturn(false); + env.wait(); + verify(mockedTranslationEngineService.createTranslationEngine(anything())).never(); + env.routeWithParams({ projectId: 'project02', bookId: 'MAT' }); + env.wait(); + verify(mockedTranslationEngineService.createTranslationEngine(anything())).never(); + expect().nothing(); + env.dispose(); + })); + + it('change texts', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.target!.segmentRef).toEqual('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + expect(env.bookName).toEqual('Mark'); + expect(env.component.target!.segmentRef).toEqual('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.target!.segmentRef).toEqual('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); - const sourceTabGroup = env.component.tabState.getTabGroup('source'); - expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + env.dispose(); + })); + + it('change chapters', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.ngZone.run(() => { + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.chapter).toBe(1); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.component.chapter = 2; + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); + env.wait(); + const verseText = env.component.target!.getSegmentText('verse_2_1'); + expect(verseText).toBe('target: chapter 2, verse 1.'); + expect(env.component.target!.segmentRef).toEqual('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.component.chapter = 1; + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + }); + env.dispose(); + })); + + it('selected segment checksum unset on server', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedBookNum: 40, + selectedChapterNum: 1, + selectedSegment: 'verse_1_1', + selectedSegmentChecksum: 0 + }); + env.wait(); + expect(env.component.chapter).toBe(1); + 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); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + expect(env.component.target!.segment!.initialChecksum).not.toBe(0); - env.dispose(); - })); + env.dispose(); + })); + + it('training status', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + expect(env.trainingProgress).toBeNull(); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.updateTrainingProgress(0.1); + expect(env.trainingProgress).not.toBeNull(); + expect(env.trainingProgressSpinner).not.toBeNull(); + env.updateTrainingProgress(1); + expect(env.trainingCompleteIcon).not.toBeNull(); + expect(env.trainingProgressSpinner).toBeNull(); + env.completeTrainingProgress(); + expect(env.trainingProgress).not.toBeNull(); + tick(5000); + env.wait(); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + expect(env.trainingProgress).toBeNull(); + env.updateTrainingProgress(0.1); + expect(env.trainingProgress).not.toBeNull(); + expect(env.trainingProgressSpinner).not.toBeNull(); - it('should hide source draft preview tab when switching to chapter with no draft', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + env.dispose(); + })); + + it('close training status', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + expect(env.trainingProgress).toBeNull(); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.updateTrainingProgress(0.1); + expect(env.trainingProgress).not.toBeNull(); + expect(env.trainingProgressSpinner).not.toBeNull(); + env.clickTrainingProgressCloseButton(); + expect(env.trainingProgress).toBeNull(); + env.updateTrainingProgress(1); + env.completeTrainingProgress(); + env.wait(); + verify(mockedNoticeService.show(anything())).once(); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + + env.updateTrainingProgress(0.1); + expect(env.trainingProgress).not.toBeNull(); + expect(env.trainingProgressSpinner).not.toBeNull(); - const sourceTabGroup = env.component.tabState.getTabGroup('source'); - expect(sourceTabGroup?.tabs[1].type).toEqual('draft'); - expect(env.component.chapter).toBe(1); + env.dispose(); + })); + + it('error in training status', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_1'); + expect(env.trainingProgress).toBeNull(); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).once(); + verify(env.mockedRemoteTranslationEngine.listenForTrainingStatus()).twice(); + + resetCalls(env.mockedRemoteTranslationEngine); + env.updateTrainingProgress(0.1); + expect(env.trainingProgress).not.toBeNull(); + expect(env.trainingProgressSpinner).not.toBeNull(); + env.throwTrainingProgressError(); + expect(env.trainingProgress).toBeNull(); + + tick(30000); + env.updateTrainingProgress(0.1); + expect(env.trainingProgress).not.toBeNull(); + expect(env.trainingProgressSpinner).not.toBeNull(); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); - env.wait(); + env.dispose(); + })); + + it('source is missing book/text', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual('verse_1_1'); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(50); + expect(selection!.length).toBe(0); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); + expect(env.component.showSuggestions).toBe(false); + env.dispose(); + })); - expect(sourceTabGroup?.tabs[1]).toBeUndefined(); - expect(env.component.chapter).toBe(2); + it('source correctly displays when text changes', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject( + { + texts: [ + { + bookNum: 44, + chapters: [ + { + number: 1, + lastVerse: 3, + isValid: true, + permissions: { + user01: TextInfoPermission.Read + } + } + ], + hasSource: false, + permissions: { + user01: TextInfoPermission.Read + } + } + ] + }, + 'project02' + ); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + let selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + let sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); + expect(sourceText).toEqual('This book does not exist.'); + + env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); + env.wait(); + expect(env.bookName).toEqual('Acts'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); + expect(sourceText).not.toEqual('This book does not exist.'); - env.dispose(); - })); + env.dispose(); + })); + + it('user cannot edit', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user02'); + env.setProjectUserConfig(); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(false); + env.dispose(); + })); + + it('user can edit a chapter with permission', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(2); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + expect(env.outOfSyncWarning).toBeNull(); + const sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); + expect(sourceText).toEqual('This book is empty. Add chapters in Paratext.'); + + env.setDataInSync('project01', false); + expect(env.component.canEdit).toBe(false); + expect(env.outOfSyncWarning).not.toBeNull(); + env.dispose(); + })); + + it('user cannot edit a chapter source text visible', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user03'); + env.setProjectUserConfig(); + env.wait(); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.canEdit).toBe(false); + expect(env.component.showSource).toBe(true); + env.dispose(); + })); + + it('user cannot edit a chapter with permission', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(false); + env.dispose(); + })); + + it('user cannot edit a text that is not editable', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ editingRequires: EditingRequires.ViewModelBlankSupport }); + env.setProjectUserConfig(); + env.wait(); + + expect(env.bookName).toEqual('Matthew'); + expect(env.component.projectTextNotEditable).toBe(true); + expect(env.component.canEdit).toBe(false); + expect(env.fixture.debugElement.query(By.css('.text-area .project-text-not-editable'))).not.toBeNull(); + env.dispose(); + })); + + it('user cannot edit a text if their permissions change', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject(); + env.setProjectUserConfig(); + env.wait(); + + const userId: string = 'user01'; + const projectId: string = 'project01'; + let projectDoc = env.getProjectDoc(projectId); + expect(projectDoc.data?.userRoles[userId]).toBe(SFProjectRole.ParatextTranslator); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.canEdit).toBe(true); + + let range = env.component.target!.getSegmentRange('verse_1_2'); + env.targetEditor.setSelection(range!.index + 1, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_2'); + 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); + env.wait(); + resetCalls(env.mockedRemoteTranslationEngine); + + projectDoc = env.getProjectDoc(projectId); + expect(projectDoc.data?.userRoles[userId]).toBe(SFProjectRole.Viewer); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.canEdit).toBe(false); + + range = env.component.target!.getSegmentRange('verse_1_3'); + env.targetEditor.setSelection(range!.index + 1, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_3'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); - it('should hide the draft preview tab when user is commenter', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - env.setCommenterUser(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + env.dispose(); + })); + + it('uses default font size', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ defaultFontSize: 18 }); + env.setProjectUserConfig(); + env.wait(); + + const ptToRem = 12; + expect(env.targetTextEditor.style.fontSize).toEqual(18 / ptToRem + 'rem'); + expect(env.sourceTextEditor.style.fontSize).toEqual(18 / ptToRem + 'rem'); + + 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); + expect(env.sourceTextEditor.style.fontSize).toEqual(24 / ptToRem + 'rem'); + env.dispose(); + })); + + it('user has no resource access', fakeAsync(() => { + when(mockedSFProjectService.getProfile('resource01')).thenResolve({ + id: 'resource01', + data: createTestProjectProfile() + } as SFProjectProfileDoc); + + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ + translateConfig: { + translationSuggestionsEnabled: true, + source: { + paratextId: 'resource01', + name: 'Resource 1', + shortName: 'SRC', + projectRef: 'resource01', + writingSystem: { + tag: 'qaa' + } + } + } + }); + env.setCurrentUser('user01'); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); + env.wait(); + verify(mockedSFProjectService.get('resource01')).never(); + expect(env.bookName).toEqual('Acts'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + expect(env.isSourceAreaHidden).toBe(false); + env.dispose(); + })); + + it('empty book', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'JHN' }); + env.wait(); + expect(env.bookName).toEqual('John'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); + expect(env.component.showSuggestions).toBe(false); + const sourceText = env.sourceTextEditorPlaceholder.getAttribute('data-placeholder'); + expect(sourceText).not.toEqual('This book does not exist.'); + expect(env.component.target!.readOnlyEnabled).toBe(true); + env.dispose(); + })); + + it('chapter is invalid', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + expect(env.bookName).toEqual('Mark'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(false); + expect(env.isSourceAreaHidden).toBe(false); + expect(env.invalidWarning).not.toBeNull(); + env.dispose(); + })); + + it('first chapter is missing', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject(); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'ROM' }); + env.wait(); + expect(env.bookName).toEqual('Romans'); + expect(env.component.chapter).toBe(2); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + env.dispose(); + })); - const targetTabGroup = env.component.tabState.getTabGroup('target'); - expect(targetTabGroup?.tabs[1]).toBeUndefined(); - expect(env.component.chapter).toBe(1); + it('ensure direction is RTL when project is to set to RTL', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ isRightToLeft: true }); + env.wait(); + expect(env.component.target!.isRtl).toBe(true); + env.dispose(); + })); + + it('does not highlight read-only text editor', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user02'); + env.wait(); + const segmentRange = env.component.target!.getSegmentRange('verse_1_1')!; + env.targetEditor.setSelection(segmentRange.index); + env.wait(); + let element: HTMLElement = env.targetTextEditor.querySelector('usx-segment[data-segment="verse_1_1"]')!; + expect(element.classList).not.toContain('highlight-segment'); + + env.setCurrentUser('user01'); + env.wait(); + element = env.targetTextEditor.querySelector('usx-segment[data-segment="verse_1_1"]')!; + expect(element.classList).toContain('highlight-segment'); + env.dispose(); + })); + + it('backspace and delete disabled for non-text elements and at segment boundaries', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.targetEditor.history['stack']['undo'].length).withContext('setup').toEqual(0); + let range = env.component.target!.getSegmentRange('verse_1_2')!; + let contents = env.targetEditor.getContents(range.index, 1); + expect((contents.ops![0].insert as any).blank).toBeDefined(); + + // set selection on a blank segment + env.targetEditor.setSelection(range.index, 'user'); + env.wait(); + // the selection is programmatically set to after the blank + expect(env.targetEditor.getSelection()!.index).toEqual(range.index + 1); + expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); + + env.pressKey('backspace'); + expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); + env.pressKey('delete'); + expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); + contents = env.targetEditor.getContents(range.index, 1); + expect((contents.ops![0].insert as any).blank).toBeDefined(); + + // set selection at segment boundaries + range = env.component.target!.getSegmentRange('verse_1_4')!; + env.targetEditor.setSelection(range.index + range.length, 'user'); + env.wait(); + env.pressKey('delete'); + expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); + env.targetEditor.setSelection(range.index, 'user'); + env.wait(); + env.pressKey('backspace'); + expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); + + // other non-text elements + range = env.component.target!.getSegmentRange('verse_1_1')!; + env.targetEditor.insertEmbed(range.index, 'note', { caller: 'a', style: 'ft' }, 'api'); + env.wait(); + contents = env.targetEditor.getContents(range.index, 1); + expect((contents.ops![0].insert as any).note).toBeDefined(); + env.targetEditor.setSelection(range.index + 1, 'user'); + env.pressKey('backspace'); + expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); + contents = env.targetEditor.getContents(range.index, 1); + expect((contents.ops![0].insert as any).note).toBeDefined(); + env.dispose(); + })); + + it('undo/redo', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_2'); + + const verse2SegmentIndex = 8; + const verse3EmbedIndex = 9; + env.typeCharacters('test'); + let contents = env.targetEditor.getContents(); + expect(contents.ops![verse2SegmentIndex].insert).toEqual('test'); + expect(contents.ops![verse2SegmentIndex].attributes) + .withContext('typeCharacters verse2SegmentIndex attributes') + .toEqual({ + 'para-contents': true, + segment: 'verse_1_2', + 'highlight-segment': true + }); + + expect(contents.ops![verse3EmbedIndex].insert).toEqual({ verse: { number: '3', style: 'v' } }); + expect(contents.ops![verse3EmbedIndex].attributes).toEqual({ 'para-contents': true }); + + env.triggerUndo(); + contents = env.targetEditor.getContents(); + // check that edit has been undone + expect(contents.ops![verse2SegmentIndex].insert).toEqual({ blank: modelHasBlanks }); + expect(contents.ops![verse2SegmentIndex].attributes) + .withContext('triggerUndo verse2SegmentIndex attributes') + .toEqual({ + 'para-contents': true, + segment: 'verse_1_2', + 'highlight-segment': true + }); + // check to make sure that data after the affected segment hasn't gotten corrupted + expect(contents.ops![verse3EmbedIndex].insert).toEqual({ verse: { number: '3', style: 'v' } }); + expect(contents.ops![verse3EmbedIndex].attributes).toEqual({ 'para-contents': true }); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(33); + expect(selection!.length).toBe(0); + + env.triggerRedo(); + contents = env.targetEditor.getContents(); + expect(contents.ops![verse2SegmentIndex].insert).toEqual('test'); + expect(contents.ops![verse2SegmentIndex].attributes) + .withContext('triggerRedo verse2SegmentIndex attributes') + .toEqual({ + 'para-contents': true, + segment: 'verse_1_2', + 'highlight-segment': true + }); + expect(contents.ops![verse3EmbedIndex].insert).toEqual({ verse: { number: '3', style: 'v' } }); + expect(contents.ops![verse3EmbedIndex].attributes).toEqual({ 'para-contents': true }); - env.dispose(); - })); + env.dispose(); + })); + + it('ensure resolved notes do not appear', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + const segment: HTMLElement = env.targetTextEditor.querySelector('usx-segment[data-segment=verse_1_5]')!; + expect(segment).not.toBeNull(); + const note = segment.querySelector('display-note')! as HTMLElement; + expect(note).toBeNull(); + env.dispose(); + })); + + it('ensure inserting in a blank segment only produces required delta ops', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.wait(); + + const range = env.component.target!.getSegmentRange('verse_1_2'); + env.targetEditor.setSelection(range!.index + 1, 0, 'user'); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_2'); + + let contents = env.targetEditor.getContents(); + const verse2SegmentIndex = 8; + expect(contents.ops![verse2SegmentIndex].insert).toEqual({ blank: modelHasBlanks }); + + // Keep track of operations triggered in Quill + let textChangeOps: RichText.DeltaOperation[] = []; + env.targetEditor.on('text-change', (delta: Delta, _oldContents: Delta, _source: EmitterSource) => { + if (delta.ops != null) { + textChangeOps = textChangeOps.concat( + delta.ops.map(op => { + delete op.attributes; + return op; + }) + ); + } + }); + + // Type a character and observe the correct operations are returned + env.typeCharacters('t', { 'commenter-selection': true }); + contents = env.targetEditor.getContents(); + expect(contents.ops![verse2SegmentIndex].insert).toEqual('t'); + const expectedOps = [ + { retain: 33 }, + { insert: 't' }, + { retain: 32 }, + { delete: 1 }, + { retain: 1 }, + { retain: 32 }, + { retain: 1 } + ]; + expect(textChangeOps).toEqual(expectedOps); + const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const opOffset = modelHasBlanks ? 1 : 0; // Account for the blank op if the model has blanks + const attributes: StringMap = textDoc.data!.ops![4 + opOffset].attributes!; + expect(Object.keys(attributes)).toEqual(['segment']); + env.dispose(); + })); + }); - it('should hide the target draft preview tab when switching to chapter with no draft', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => false }); - }); - when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + describe('Note threads', () => { + it('embeds note on verse segments', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.addParatextNoteThread(6, 'MAT 1:2', '', { start: 0, length: 0 }, ['user01']); + env.addParatextNoteThread( + 7, + 'LUK 1:0', + 'for chapter', + { start: 6, length: 11 }, + ['user01'], + NoteStatus.Todo, + 'user02' + ); + env.addParatextNoteThread(8, 'LUK 1:2-3', '', { start: 0, length: 0 }, ['user01'], NoteStatus.Todo, 'user01'); + env.addParatextNoteThread( + 9, + 'LUK 1:2-3', + 'section heading', + { start: 38, length: 15 }, + ['user01'], + NoteStatus.Todo, + AssignedUsers.TeamUser + ); + env.addParatextNoteThread(10, 'MAT 1:4', '', { start: 27, length: 0 }, ['user01']); + env.setProjectUserConfig(); + env.wait(); + const verse1Segment: HTMLElement = env.getSegmentElement('verse_1_1')!; + const verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; + expect(verse1Note).not.toBeNull(); + expect(verse1Note.getAttribute('style')).toEqual('--icon-file: url(/assets/icons/TagIcons/01flag1.png);'); + expect(verse1Note.getAttribute('title')).toEqual('Note from user01\n--- 2 more note(s) ---'); + const contents = env.targetEditor.getContents(); + expect(contents.ops![3].insert).toEqual('target: '); + expect(contents.ops![4].attributes!['iconsrc']).toEqual( + '--icon-file: url(/assets/icons/TagIcons/01flag1.png);' + ); - const targetTabGroup = env.component.tabState.getTabGroup('target'); - expect(targetTabGroup?.tabs[1].type).toEqual('draft'); - expect(env.component.chapter).toBe(1); + // three notes in the segment on verse 3 + const noteVerse3: NodeListOf = env.getSegmentElement('verse_1_3')!.querySelectorAll('display-note')!; + expect(noteVerse3.length).toEqual(3); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); - env.wait(); + const blankSegmentNote = env.getSegmentElement('verse_1_2')!.querySelector('display-note') as HTMLElement; + expect(blankSegmentNote.getAttribute('style')).toEqual( + '--icon-file: url(/assets/icons/TagIcons/01flag1.png);' + ); + expect(blankSegmentNote.getAttribute('title')).toEqual('Note from user01'); + + const segmentEndNote = env.getSegmentElement('verse_1_4')!.querySelector('display-note') as HTMLElement; + expect(segmentEndNote).not.toBeNull(); + + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + const redFlagIcon = '01flag1.png'; + const grayFlagIcon = '01flag4.png'; + const titleUsxSegment: HTMLElement = env.getSegmentElement('s_1')!; + expect(titleUsxSegment.classList).toContain('note-thread-segment'); + const titleUsxNote: HTMLElement | null = titleUsxSegment.querySelector('display-note'); + expect(titleUsxNote).not.toBeNull(); + // Note assigned to a different specific user + expect(titleUsxNote!.getAttribute('style')).toEqual( + `--icon-file: url(/assets/icons/TagIcons/${grayFlagIcon});` + ); - expect(targetTabGroup?.tabs[1]).toBeUndefined(); - expect(env.component.chapter).toBe(2); + const sectionHeadingUsxSegment: HTMLElement = env.getSegmentElement('s_2')!; + expect(sectionHeadingUsxSegment.classList).toContain('note-thread-segment'); + const sectionHeadingNote: HTMLElement | null = sectionHeadingUsxSegment.querySelector('display-note'); + expect(sectionHeadingNote).not.toBeNull(); + // Note assigned to team + expect(sectionHeadingNote!.getAttribute('style')).toEqual( + `--icon-file: url(/assets/icons/TagIcons/${redFlagIcon});` + ); + const combinedVerseUsxSegment: HTMLElement = env.getSegmentElement('verse_1_2-3')!; + const combinedVerseNote: HTMLElement | null = combinedVerseUsxSegment.querySelector('display-note'); + expect(combinedVerseNote!.getAttribute('data-thread-id')).toEqual('dataid08'); + // Note assigned to current user + expect(combinedVerseNote!.getAttribute('style')).toEqual( + `--icon-file: url(/assets/icons/TagIcons/${redFlagIcon});` + ); + env.dispose(); + })); + + it('handles text doc updates with note embed offset', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_2' }); + env.wait(); + expect(env.component.target!.segmentRef).toBe('verse_1_2'); + + const verse1EmbedIndex = 2; + const verse1SegmentIndex = 3; + const verse1NoteIndex = verse1SegmentIndex + 1; + const verse1NoteAnchorIndex = verse1SegmentIndex + 2; + const verse2SegmentIndex = 8; + env.typeCharacters('t'); + const contents = env.targetEditor.getContents(); + expect(contents.ops![verse2SegmentIndex].insert).toEqual('t'); + expect(contents.ops![verse2SegmentIndex].attributes).toEqual({ + 'para-contents': true, + segment: 'verse_1_2', + 'highlight-segment': true + }); + const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const textOps = textDoc.data!.ops!; + const opOffset = modelHasBlanks ? 1 : 0; // Account for the blank op if the model has blanks + expect(textOps[1 + opOffset].insert!['verse']['number']).toBe('1'); + expect(textOps[2 + opOffset].insert).toBe('target: chapter 1, verse 1.'); + expect(textOps[4 + opOffset].insert).toBe('t'); + expect(contents.ops![verse1EmbedIndex]!.insert!['verse']['number']).toBe('1'); + expect(contents.ops![verse1SegmentIndex].insert).toBe('target: '); + expect(contents.ops![verse1NoteIndex]!.attributes!['iconsrc']).toBe( + '--icon-file: url(/assets/icons/TagIcons/01flag1.png);' + ); + // text anchor for thread01 + expect(contents.ops![verse1NoteAnchorIndex]!.insert).toBe('chapter 1'); + expect(contents.ops![verse1NoteAnchorIndex]!.attributes).toEqual({ + 'para-contents': true, + 'text-anchor': true, + segment: 'verse_1_1', + 'note-thread-segment': true + }); + expect(contents.ops![verse2SegmentIndex]!.insert).toBe('t'); + env.dispose(); + })); + + it('correctly removes embedded elements', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + let contents = env.targetEditor.getContents(); + let noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); + expect(noteThreadEmbedCount).toEqual(5); + env.component.removeEmbeddedElements(); + env.wait(); + + contents = env.targetEditor.getContents(); + noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); + expect(noteThreadEmbedCount).toEqual(0); + env.dispose(); + })); + + it('uses note thread text anchor as anchor', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + let doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteStart1 = env.component.target!.getSegmentRange('verse_1_1')!.index + doc.data!.position.start; + doc = env.getNoteThreadDoc('project01', 'dataid02'); + const noteStart2 = env.component.target!.getSegmentRange('verse_1_3')!.index + doc.data!.position.start; + doc = 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'); + // 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'); + 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]; + expect(env.getNoteThreadEditorPositions()).toEqual(expected); + env.dispose(); + })); + + it('note position correctly accounts for footnote symbols', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; + const contents = env.targetEditor.getContents(range.index, range.length); + // The footnote starts after a note thread in the segment + expect(contents.ops![1].insert).toEqual({ note: { caller: '*', style: 'f' } }); + const note2Position = env.getNoteThreadEditorPosition('dataid02'); + expect(range.index).toEqual(note2Position); + const noteThreadDoc3 = env.getNoteThreadDoc('project01', 'dataid03'); + const noteThread3StartPosition = 20; + expect(noteThreadDoc3.data!.position).toEqual({ start: noteThread3StartPosition, length: 7 }); + const note3Position = env.getNoteThreadEditorPosition('dataid03'); + // plus 1 for the note icon embed at the beginning of the verse + expect(range.index + noteThread3StartPosition + 1).toEqual(note3Position); + env.dispose(); + })); + + it('correctly places note in subsequent segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.addParatextNoteThread(6, 'MAT 1:4', 'target', { start: 0, length: 6 }, ['user01']); + // Note 7 should be at position 0 on segment 1_4/p_1 + env.addParatextNoteThread(7, 'MAT 1:4', '', { start: 28, length: 0 }, ['user01']); + env.setProjectUserConfig(); + env.wait(); + + const note7Position = env.getNoteThreadEditorPosition('dataid07'); + const note4EmbedLength = 1; + expect(note7Position).toEqual( + env.component.target!.getSegmentRange('verse_1_4/p_1')!.index + note4EmbedLength + ); + env.dispose(); + })); + + it('shows reattached note in updated location', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + 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); + + // 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 note4Anchor: TextAnchor = note4Doc.data!.position; + expect(note4Anchor).toEqual(position); + expect(note4Position).toEqual(range.index + position.start); + // The original note thread was on verse 3 + expect(note4Doc.data!.verseRef.verseNum).toEqual(3); + env.dispose(); + })); + + it('shows an invalid reattached note in original location', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + // invalid reattachment string + 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')!; + expect(note4Position).toEqual(range.index + 1); + // The note thread is on verse 3 + expect(note4Doc.data!.verseRef.verseNum).toEqual(3); + env.dispose(); + })); - env.dispose(); - })); + it('does not display conflict notes', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.convertToConflictNote('project01', 'dataid02'); + env.wait(); - it('should not add draft tab if draft exists and draft tab is already present', fakeAsync(() => { - const env = new TestEnvironment(); - env.wait(); + expect(env.getNoteThreadIconElement('verse_1_3', 'dataid02')).toBeNull(); + env.dispose(); + })); + + it('shows note on verse with letter', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.addParatextNoteThread(6, 'LUK 1:6a', '', { start: 0, length: 0 }, ['user01']); + env.addParatextNoteThread(7, 'LUK 1:6b', '', { start: 0, length: 0 }, ['user01']); + env.wait(); + + expect(env.getNoteThreadIconElement('verse_1_6a', 'dataid06')).not.toBeNull(); + expect(env.getNoteThreadIconElement('verse_1_6b', 'dataid07')).not.toBeNull(); + env.dispose(); + })); + + it('highlights note icons when new content is unread', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user02'); + env.setProjectUserConfig({ noteRefsRead: ['thread01_note0', 'thread02_note0'] }); + env.wait(); + + expect(env.isNoteIconHighlighted('dataid01')).toBe(true); + expect(env.isNoteIconHighlighted('dataid02')).toBe(false); + expect(env.isNoteIconHighlighted('dataid03')).toBe(true); + expect(env.isNoteIconHighlighted('dataid04')).toBe(true); + expect(env.isNoteIconHighlighted('dataid05')).toBe(true); + + let puc: SFProjectUserConfigDoc = 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'); + expect(puc.data!.noteRefsRead).toContain('thread01_note1'); + expect(puc.data!.noteRefsRead).toContain('thread01_note2'); + expect(env.isNoteIconHighlighted('dataid01')).toBe(false); + + expect(puc.data!.noteRefsRead).toContain('thread02_note0'); + iconElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; + iconElement.click(); + env.wait(); + puc = 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(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + + let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); + + // edit before start position + env.targetEditor.setSelection(5, 0, 'user'); + const text = ' add text '; + const length = text.length; + env.typeCharacters(text); + expect(noteThreadDoc.data!.position).toEqual({ start: 8 + length, length: 9 }); + + // edit at note position + let notePosition = env.getNoteThreadEditorPosition('dataid01'); + env.targetEditor.setSelection(notePosition, 0, 'user'); + env.typeCharacters(text); + expect(noteThreadDoc.data!.position).toEqual({ start: length * 2 + 8, length: 9 }); + + // edit immediately after note + notePosition = env.getNoteThreadEditorPosition('dataid01'); + env.targetEditor.setSelection(notePosition + 1, 0, 'user'); + env.typeCharacters(text); + expect(noteThreadDoc.data!.position).toEqual({ start: length * 2 + 8, length: 9 + length }); + + // edit immediately after verse note + noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid02'); + notePosition = env.getNoteThreadEditorPosition('dataid02'); + expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); + env.targetEditor.setSelection(notePosition, 0, 'user'); + env.wait(); + expect(env.targetEditor.getSelection()!.index).toEqual(notePosition + 1); + env.typeCharacters(text); + expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); + env.dispose(); + })); + + it('should update note position when deleting text', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); + + // delete text before note + const length = 3; + const noteEmbedLength = 1; + let notePosition = env.getNoteThreadEditorPosition('dataid01'); + env.targetEditor.setSelection(notePosition - length, length, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 8 - length, length: 9 }); + + // delete text at the beginning of note text + notePosition = env.getNoteThreadEditorPosition('dataid01'); + env.targetEditor.setSelection(notePosition + noteEmbedLength, length, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 8 - length, length: 9 - length }); + + // delete text right after note text + notePosition = env.getNoteThreadEditorPosition('dataid01'); + const noteLength = noteThreadDoc.data!.position.length; + env.targetEditor.setSelection(notePosition + noteEmbedLength + noteLength, length, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 8 - length, length: 9 - length }); + env.dispose(); + })); + + it('does not try to update positions with an unchanged value', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + 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 origPriorThreadDocAnchorStart: number = priorThreadDoc.data!.position.start; + const origPriorThreadDocAnchorLength: number = priorThreadDoc.data!.position.length; + const origLaterThreadDocAnchorStart: number = laterThreadDoc.data!.position.start; + const origLaterThreadDocAnchorLength: number = laterThreadDoc.data!.position.length; + expect(laterThreadDoc.data!.position.start) + .withContext('setup: have some space between the anchorings') + .toBeGreaterThan(origPriorThreadDocAnchorStart + origPriorThreadDocAnchorLength); + + const insertedText = 'inserted text'; + const insertedTextLength = insertedText.length; + const priorThreadEditorPos = env.getNoteThreadEditorPosition(priorThreadId); + + // Edit between anchorings + env.targetEditor.setSelection(priorThreadEditorPos, 0, 'user'); + env.wait(); + const priorThreadDocSpy: jasmine.Spy = spyOn(priorThreadDoc, 'submitJson0Op').and.callThrough(); + const laterThreadDocSpy: jasmine.Spy = spyOn(laterThreadDoc, 'submitJson0Op').and.callThrough(); + // SUT + env.typeCharacters(insertedText); + expect(priorThreadDoc.data!.position) + .withContext('unchanged') + .toEqual({ start: origPriorThreadDocAnchorStart, length: origPriorThreadDocAnchorLength }); + expect(laterThreadDoc.data!.position) + .withContext('pushed over') + .toEqual({ + start: origLaterThreadDocAnchorStart + insertedTextLength, + length: origLaterThreadDocAnchorLength + }); + // It makes sense to update thread anchor position information when they changed, but we need not request + // position changes with unchanged information. + expect(priorThreadDocSpy.calls.count()) + .withContext('do not try to update position with an unchanged value') + .toEqual(0); + expect(laterThreadDocSpy.calls.count()).withContext('do update position where it changed').toEqual(1); - env.component.tabState.addTab('target', env.tabFactory.createTab('draft')); - const addTab = spyOn(env.component.tabState, 'addTab'); + env.dispose(); + })); + + it('re-embeds a note icon when a user deletes it', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.getNoteThreadEditorPositions()).toEqual([11, 34, 55, 56, 94]); + + // deletes just the note icon + env.targetEditor.setSelection(11, 1, 'user'); + env.deleteCharacters(); + expect(env.getNoteThreadEditorPositions()).toEqual([11, 34, 55, 56, 94]); + const textDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const opOffset = modelHasBlanks ? 1 : 0; // Account for the blank op if the model has blanks + expect(textDoc.data!.ops![2 + opOffset].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'); + expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); + env.typeCharacters('t'); + // 4 characters deleted and 1 character inserted + expect(env.getNoteThreadEditorPositions()).toEqual([10, 31, 52, 53, 91]); + expect(noteThreadDoc.data!.position).toEqual({ start: 7, length: 7 }); + expect(textDoc.data!.ops![2 + opOffset].insert).toBe('targettapter 1, verse 1.'); + + // switch to a different text + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + expect(noteThreadDoc.data!.position).toEqual({ start: 7, length: 7 }); + + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(env.getNoteThreadEditorPositions()).toEqual([10, 31, 52, 53, 91]); + env.dispose(); + })); + + it('should re-embed deleted note and allow user to open note dialog', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const position: number = env.getNoteThreadEditorPosition('dataid03'); + const length = 9; + // $target: chapter 1, |->$$verse 3<-|. + env.targetEditor.setSelection(position, length, 'api'); + env.deleteCharacters(); + const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; + expect(env.getNoteThreadEditorPosition('dataid02')).toEqual(range.index); + expect(env.getNoteThreadEditorPosition('dataid03')).toEqual(range.index + 1); + expect(env.getNoteThreadEditorPosition('dataid04')).toEqual(range.index + 2); + + for (let i = 0; i <= 2; i++) { + const noteThreadId: number = i + 2; + const note: HTMLElement = env.getNoteThreadIconElement('verse_1_3', `dataid0${noteThreadId}`)!; + note.click(); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).times(i + 1); + } + env.dispose(); + })); + + it('handles deleting parts of two notes text anchors', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.addParatextNoteThread(6, 'MAT 1:1', 'verse', { start: 19, length: 5 }, ['user01']); + env.setProjectUserConfig(); + env.wait(); + + // 1 target: $chapter|-> 1, $ve<-|rse 1. + env.targetEditor.setSelection(19, 7, 'user'); + env.deleteCharacters(); + const note1 = env.getNoteThreadDoc('project01', 'dataid01'); + expect(note1.data!.position).toEqual({ start: 8, length: 7 }); + const note2 = env.getNoteThreadDoc('project01', 'dataid06'); + expect(note2.data!.position).toEqual({ start: 15, length: 3 }); + const textDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); + const opOffset = modelHasBlanks ? 1 : 0; // Account for the blank op if the model has blanks + expect(textDoc.data!.ops![2 + opOffset].insert).toEqual('target: chapterrse 1.'); + env.dispose(); + })); + + it('updates notes anchors in subsequent verse segments', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.addParatextNoteThread(6, 'MAT 1:4', 'chapter 1', { start: 8, length: 9 }, ['user01']); + env.setProjectUserConfig(); + env.wait(); + + const noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid05'); + expect(noteThreadDoc.data!.position).toEqual({ start: 28, length: 9 }); + env.targetEditor.setSelection(86, 0, 'user'); + const text = ' new text '; + const length = text.length; + env.typeCharacters(text); + expect(noteThreadDoc.data!.position).toEqual({ start: 28 + length, length: 9 }); + env.dispose(); + })); + + it('should update note position if deleting across position end boundary', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const noteThreadDoc: NoteThreadDoc = 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'); + const deletionLength = 10; + const noteEmbedLength: number = 1; + // Arbitrary text position within thread anchoring, at which to start deleting. + const textPositionWithinAnchors = 4; + // Editor position to begin deleting. This should be in the note anchoring span. + const delStart: number = notePosition + noteEmbedLength + textPositionWithinAnchors; + const deletionLengthWithinTextAnchor = noteThreadDoc.data!.position.length - textPositionWithinAnchors; + env.targetEditor.setSelection(delStart, deletionLength, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 - deletionLengthWithinTextAnchor }); + env.dispose(); + })); + + it('handles insert at the last character position', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + 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 thread1Position = env.getNoteThreadEditorPosition('dataid01'); + expect(thread1Doc.data!.position).toEqual({ start: 8, length: 9 }); + + const embedLength = 1; + // Editor position immediately following the end of the anchoring. Note that both the thread1 and thread6 note + // icon embeds need to be accounted for. + const immediatelyAfter: number = thread1Position + embedLength * 2 + thread1Doc.data!.position.length; + // Test insert at index one character outside the text anchor. So not immediately after the anchoring, + // but another character past that. + env.targetEditor.setSelection(immediatelyAfter + 1, 0, 'user'); + env.typeCharacters('a'); + expect(thread1Doc.data!.position).toEqual({ start: 8, length: 9 }); + + // the insert should be included in the text anchor length if inserting immediately after last character + env.targetEditor.setSelection(immediatelyAfter, 0, 'user'); + env.typeCharacters('b'); + 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'); + 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`); + expect(noteThread7Doc.data!.position).toEqual({ start: 27, length: 1 + 'c'.length }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + env.dispose(); + })); + + it('should default a note to the beginning if all text is deleted', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); + + // delete the entire text anchor + let notePosition = env.getNoteThreadEditorPosition('dataid01'); + let length = 9; + env.targetEditor.setSelection(notePosition + 1, length, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); + + // delete text that includes the entire text anchor + noteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); + expect(noteThreadDoc.data!.position).toEqual({ start: 20, length: 7 }); + notePosition = env.getNoteThreadEditorPosition('dataid03'); + length = 8; + env.targetEditor.setSelection(notePosition + 1, length, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); + env.dispose(); + })); + + it('should update paratext notes position after editing verse with multiple notes', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + + const thread3Doc: NoteThreadDoc = 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'); + expect(otherNoteThreadDoc.data!.position).toEqual({ start: 20, length: thread4AnchorLength }); + const verseNoteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid02'); + expect(verseNoteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); + // edit before paratext note + let thread3Position = env.getNoteThreadEditorPosition('dataid03'); + env.targetEditor.setSelection(thread3Position, 0, 'user'); + env.wait(); + const textBeforeNote = 'add text before '; + const length1 = textBeforeNote.length; + env.typeCharacters(textBeforeNote); + expect(thread3Doc.data!.position).toEqual({ start: 20 + length1, length: thread3AnchorLength }); + expect(otherNoteThreadDoc.data!.position).toEqual({ start: 20 + length1, length: thread4AnchorLength }); + + // edit within note selection start + thread3Position = env.getNoteThreadEditorPosition('dataid03'); + env.targetEditor.setSelection(thread3Position + 1, 0, 'user'); + env.wait(); + const textWithinNote = 'edit within note '; + const length2 = textWithinNote.length; + env.typeCharacters(textWithinNote); + env.wait(); + let lengthChange: number = length2; + expect(thread3Doc.data!.position).toEqual({ + start: 20 + length1, + length: thread3AnchorLength + lengthChange + }); + expect(otherNoteThreadDoc.data!.position).toEqual({ + start: 20 + length1, + length: thread4AnchorLength + lengthChange + }); + + // edit within note selection end + const verse3Range = env.component.target!.getSegmentRange('verse_1_3')!; + // Verse 3 ends with "[...]ter 1, verse 3.". Thread 4 anchors to "verse". + const extraAmount: number = ` 3.`.length; + const editorPosImmediatelyFollowingThread4Anchoring = verse3Range.index + verse3Range.length - extraAmount; + env.targetEditor.setSelection(editorPosImmediatelyFollowingThread4Anchoring, 0, 'user'); + env.typeCharacters(textWithinNote); + lengthChange += length2; + expect(thread3Doc.data!.position).toEqual({ + start: 20 + length1, + length: thread3AnchorLength + lengthChange + }); + expect(otherNoteThreadDoc.data!.position).toEqual({ + start: 20 + length1, + length: thread4AnchorLength + lengthChange + }); + + // delete text within note selection + thread3Position = env.getNoteThreadEditorPosition('dataid03'); + const deleteLength = 5; + const lengthAfterNote = 2; + env.targetEditor.setSelection(thread3Position + lengthAfterNote, deleteLength, 'user'); + env.wait(); + env.typeCharacters(''); + lengthChange -= deleteLength; + expect(thread3Doc.data!.position).toEqual({ + start: 20 + length1, + length: thread3AnchorLength + lengthChange + }); + expect(otherNoteThreadDoc.data!.position).toEqual({ + start: 20 + length1, + length: thread4AnchorLength + lengthChange + }); + // the verse note thread position never changes + expect(verseNoteThreadDoc.data!.position).toEqual({ start: 0, length: 0 }); + + // delete text at the end of a note anchor + const thread4IconLength = 1; + const lastTextAnchorPosition: number = + thread3Position + thread4IconLength + thread3AnchorLength + lengthChange; + env.targetEditor.setSelection(lastTextAnchorPosition, 1, 'user'); + env.deleteCharacters(); + lengthChange--; + expect(thread3Doc.data!.position).toEqual({ + start: 20 + length1, + length: thread3AnchorLength + lengthChange + }); + env.dispose(); + })); + + it('update note thread anchors when multiple edits within a verse', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const origNoteAnchor: TextAnchor = { start: 8, length: 9 }; + expect(noteThreadDoc.data!.position).toEqual(origNoteAnchor); + + const notePosition: number = env.getNoteThreadEditorPosition('dataid01'); + const deleteStart: number = notePosition + 1; + const text = 'chap'; + + // target: $chapter 1, verse 1. + // move this ---- here ^ + const deleteOps: DeltaOperation[] = [{ retain: deleteStart }, { delete: text.length }]; + const deleteDelta: Delta = new Delta(deleteOps); + env.targetEditor.setSelection(deleteStart, text.length); + // simulate a drag and drop operation, which include a delete and an insert operation + env.targetEditor.updateContents(deleteDelta, 'user'); + tick(); + env.fixture.detectChanges(); + const insertStart: number = notePosition + 'ter 1, ver'.length; + const insertOps: DeltaOperation[] = [{ retain: insertStart }, { insert: text }]; + const insertDelta: Delta = new Delta(insertOps); + env.targetEditor.updateContents(insertDelta, 'user'); + + env.wait(); + const expectedNoteAnchor: TextAnchor = { + start: origNoteAnchor.start, + length: origNoteAnchor.length - text.length + }; + expect(noteThreadDoc.data!.position).toEqual(expectedNoteAnchor); + // SUT + env.triggerUndo(); + // this triggers undoing the drag and drop in one delta + expect(noteThreadDoc.data!.position).toEqual(origNoteAnchor); + env.dispose(); + })); + + it('updates note anchor for non-verse segments', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + const origThread06Pos: TextAnchor = { start: 38, length: 7 }; + env.addParatextNoteThread(6, 'LUK 1:2-3', 'section', origThread06Pos, ['user01']); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + const textBeforeNote = 'Text in '; + 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'); + let textAnchor: TextAnchor = thread06Doc.data!.position; + expect(textAnchor).toEqual(origThread06Pos); + + const verse2_3Range: Range = env.component.target!.getSegmentRange('verse_1_2-3')!; + env.targetEditor.setSelection(verse2_3Range.index + verse2_3Range.length); + env.wait(); + env.typeCharacters('T'); + env.wait(); + textAnchor = thread06Doc.data!.position; + expect(textAnchor).toEqual({ start: origThread06Pos.start + 1, length: origThread06Pos.length }); + env.dispose(); + })); + + it('can display note dialog', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const note = env.fixture.debugElement.query(By.css('display-note')); + expect(note).not.toBeNull(); + note.nativeElement.click(); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + env.dispose(); + })); + + it('note belongs to a segment after a blank', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + const noteThreadDoc: NoteThreadDoc = 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); + // user deletes all of the text in segment before + const range = env.component.target!.getSegmentRange('verse_1_4')!; + env.targetEditor.setSelection(range.index, range.length, 'user'); + env.deleteCharacters(); + expect(noteThreadDoc.data!.position).toEqual({ start: 1, length: 9 }); + + // switch to a new book and back + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + let note5Index: number = env.getNoteThreadEditorPosition('dataid05'); + verse4p1Index = env.component.target!.getSegmentRange('verse_1_4/p_1')!.index; + expect(note5Index).toEqual(verse4p1Index); + + // user inserts text in blank segment + const index = env.component.target!.getSegmentRange('verse_1_4')!.index; + env.targetEditor.setSelection(index + 1, 0, 'user'); + env.wait(); + const text = 'abc'; + env.typeCharacters(text); + const nextSegmentLength = 1; + expect(noteThreadDoc.data!.position).toEqual({ start: nextSegmentLength + text.length, length: 9 }); + + // switch to a new book and back + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(noteThreadDoc.data!.position).toEqual({ start: nextSegmentLength + text.length, length: 9 }); + verse4p1Index = env.component.target!.getSegmentRange('verse_1_4/p_1')!.index; + note5Index = env.getNoteThreadEditorPosition('dataid05'); + expect(note5Index).toEqual(verse4p1Index); + env.dispose(); + })); + + it('remote edits correctly applied to editor', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + const noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + expect(noteThreadDoc.data!.position).toEqual({ start: 8, length: 9 }); + + // The remote user inserts text after the thread01 note + let notePosition: number = env.getNoteThreadEditorPosition('dataid01'); + let remoteEditPositionAfterNote: number = 4; + let noteCountBeforePosition: number = 1; + // Text position in the text doc at which the remote user edits + let remoteEditTextPos: number = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -1 // Account for the blank op before verse 1 + ); + // $ represents a note thread embed + // target: $chap|ter 1, verse 1. + const textDoc: TextDoc = 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); + + // SUT 1 + env.wait(); + // The local editor was updated to apply the remote edit in the correct position locally + expect(env.component.target!.getSegmentText('verse_1_1')).toEqual('target: chap' + 'abc' + 'ter 1, verse 1.'); + const verse1Range = env.component.target!.getSegmentRange('verse_1_1')!; + const verse1Contents = env.targetEditor.getContents(verse1Range.index, verse1Range.length); + // ops are [0]target: , [1]$, [2]chapabcter 1, [3], verse 1. + expect(verse1Contents.ops!.length).withContext('has expected op structure').toEqual(4); + expect(verse1Contents.ops![2].attributes!['text-anchor']) + .withContext('inserted text has formatting') + .toBe(true); + + // The remote user selects some text and pastes in a replacement + notePosition = env.getNoteThreadEditorPosition('dataid02'); + // 1 note from verse 1, and 1 in verse 3 before the selection point + noteCountBeforePosition = 2; + remoteEditPositionAfterNote = 5; + remoteEditTextPos = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -2 // Account for the blank op before verse 1 and in verse 2 + ); + const originalNotePosInVerse: number = 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; + const insertDeleteDelta: Delta = new Delta(); + (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); + + // SUT 2 + env.wait(); + expect(env.component.target!.getSegmentText('verse_1_3')).toEqual('targ' + 'defgh' + 'pter 1, verse 3.'); + + // The remote user selects and deletes some text that includes a couple note embeds. + remoteEditPositionAfterNote = 15; + remoteEditTextPos = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -2 // Account for the blank op before verse 1 and in verse 2 + ); + // $*targdefghpter |->1, $$v<-|erse 3. + // ------ editor range deleted + const deleteDelta: Delta = new Delta(); + (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); + + // 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); + 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 + let notesBefore: number = 1; + expect(env.getNoteThreadEditorPosition('dataid03')).toEqual( + verse3Index + originalNotePosInVerse + notesBefore + ); + notesBefore = 2; + expect(env.getNoteThreadEditorPosition('dataid04')).toEqual( + verse3Index + originalNotePosInVerse + notesBefore + ); + env.dispose(); + })); + + it('remote edits do not affect note thread text anchors', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const noteThread1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const noteThread4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); + const originalNoteThread1TextPos: TextAnchor = noteThread1Doc.data!.position; + const originalNoteThread4TextPos: TextAnchor = noteThread4Doc.data!.position; + expect(originalNoteThread1TextPos).toEqual({ start: 8, length: 9 }); + expect(originalNoteThread4TextPos).toEqual({ start: 20, length: 5 }); + + // simulate text changes at current segment + let notePosition: number = env.getNoteThreadEditorPosition('dataid04'); + let remoteEditPositionAfterNote: number = 1; + // 1 note in verse 1, and 3 in verse 3 + let noteCountBeforePosition: number = 4; + // $target: chapter 1, $$v|erse 3. + let remoteEditTextPos: number = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -2 // Account for the blank op before verse 1 and in verse 2 + ); + env.targetEditor.setSelection(notePosition + remoteEditPositionAfterNote); + 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); + + // SUT 1 + env.wait(); + expect(env.component.target!.getSegmentText('verse_1_3')).toEqual( + 'target: chapter 1, v' + insert + 'erse 3.' + ); + expect(noteThread4Doc.data!.position).toEqual(originalNoteThread4TextPos); + + // simulate text changes at a different segment + notePosition = env.getNoteThreadEditorPosition('dataid01'); + noteCountBeforePosition = 1; + // target: $c|hapter 1, verse 1. + remoteEditTextPos = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -1 // Account for the blank op before verse 1 + ); + insert = 'def'; + deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; + const outOfSegmentDelta = new Delta(deltaOps); + textDoc.submit(outOfSegmentDelta); + + // SUT 2 + env.wait(); + expect(env.component.target!.getSegmentText('verse_1_1')).toEqual( + 'target: c' + insert + 'hapter 1, verse 1.' + ); + expect(noteThread1Doc.data!.position).toEqual(originalNoteThread1TextPos); + expect(noteThread4Doc.data!.position).toEqual(originalNoteThread4TextPos); + + // simulate text changes just before a note embed + remoteEditPositionAfterNote = -1; + noteCountBeforePosition = 0; + // target: |$cdefhapter 1, verse 1. + remoteEditTextPos = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -1 // Account for the blank op before verse 1 + ); + insert = 'before'; + deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; + const insertDelta = new Delta(deltaOps); + textDoc.submit(insertDelta); + const note1Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid01'); + const anchor: TextAnchor = { start: 8 + insert.length, length: 12 }; + note1Doc.submitJson0Op(op => op.set(nt => nt.position, anchor)); + + // SUT 3 + env.wait(); + expect(env.component.target!.getSegmentText('verse_1_1')).toEqual( + 'target: ' + insert + 'cdefhapter 1, verse 1.' + ); + const range: Range = env.component.target!.getSegmentRange('verse_1_1')!; + expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(range.index + anchor.start); + const contents = env.targetEditor.getContents(range.index, range.length); + expect(contents.ops![0].insert).toEqual('target: ' + insert); + expect(contents.ops![0].attributes!['text-anchor']).toBeUndefined(); + + // simulate text changes just after a note embed + notePosition = env.getNoteThreadEditorPosition('dataid01'); + remoteEditPositionAfterNote = 0; + noteCountBeforePosition = 1; + // target: before$|cdefhapter 1, verse 1. + remoteEditTextPos = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -1 // Account for the blank op before verse 1 + ); + insert = 'ghi'; + deltaOps = [{ retain: remoteEditTextPos }, { insert: insert }]; + const insertAfterNoteDelta = new Delta(deltaOps); + textDoc.submit(insertAfterNoteDelta); + + // SUT 4 + env.wait(); + expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(notePosition); + expect(env.component.target!.getSegmentText('verse_1_1')).toEqual( + 'target: before' + insert + 'cdefhapter 1, verse 1.' + ); + env.dispose(); + })); + + it('can backspace the last character in a segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const range: Range = env.component.target!.getSegmentRange('verse_1_2')!; + env.targetEditor.setSelection(range.index); + env.wait(); + env.typeCharacters('t'); + let contents: Delta = env.targetEditor.getContents(range.index, 3); + expect(contents.length()).toEqual(3); + expect(contents.ops![0].insert).toEqual('t'); + expect(contents.ops![1].insert!['verse']).toBeDefined(); + expect(contents.ops![2].insert!['note-thread-embed']).toBeDefined(); + + env.backspace(); + contents = env.targetEditor.getContents(range.index, 3); + expect(contents.length()).toEqual(3); + expect((contents.ops![0].insert as any).blank).toBeDefined(); + expect(contents.ops![1].insert!['verse']).toBeDefined(); + expect(contents.ops![2].insert!['note-thread-embed']).toBeDefined(); + env.dispose(); + })); + + it('remote edits next to note on verse applied correctly', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + let verse3Element: HTMLElement = env.getSegmentElement('verse_1_3')!; + let noteThreadIcon = verse3Element.querySelector('.note-thread-segment display-note'); + expect(noteThreadIcon).not.toBeNull(); + // Insert text next to thread02 icon + const notePosition: number = env.getNoteThreadEditorPosition('dataid02'); + const remoteEditPositionAfterNote: number = 0; + const noteCountBeforePosition = 2; + // $|*target: chapter 1, $$verse 3. + const remoteEditTextPos: number = env.getRemoteEditPosition( + notePosition, + remoteEditPositionAfterNote, + noteCountBeforePosition, + modelHasBlanks ? 0 : -2 // Account for the blank op before verse 1 and in verse 2 + ); + 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)); + + env.wait(); + expect(env.getNoteThreadEditorPosition('dataid02')).toEqual(notePosition); + verse3Element = env.getSegmentElement('verse_1_3')!; + noteThreadIcon = verse3Element.querySelector('.note-thread-segment display-note'); + expect(noteThreadIcon).not.toBeNull(); + // check that the note thread underline does not get applied + const insertTextDelta = env.targetEditor.getContents(notePosition + 1, 3); + expect(insertTextDelta.ops![0].insert).toEqual('abc'); + expect(insertTextDelta.ops![0].attributes!['text-anchor']).toBeUndefined(); + env.dispose(); + })); + + it('undo delete-a-note-icon removes the duplicate recreated icon', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + const noteThread6Anchor: TextAnchor = { start: 19, length: 5 }; + env.addParatextNoteThread(6, 'MAT 1:1', 'verse', noteThread6Anchor, ['user01']); + env.wait(); + + // undo deleting just the note + const noteThread1: NoteThreadDoc = 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)); + let opOffset = modelHasBlanks ? 1 : 0; // Account for the blank op before verse 1 + expect(textDoc.data!.ops![2 + opOffset].insert).toEqual('target: chapter 1, verse 1.'); + const note1Position: number = env.getNoteThreadEditorPosition('dataid01'); + // target: |->$<-|chapter 1, $verse 1. + env.targetEditor.setSelection(note1Position, 1, 'user'); + env.deleteCharacters(); + const positionAfterDelete: number = env.getNoteThreadEditorPosition('dataid01'); + expect(positionAfterDelete).toEqual(note1Position); + env.triggerUndo(); + expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(note1Position); + expect(env.component.target!.getSegmentText('verse_1_1')).toBe('target: chapter 1, verse 1.'); + expect(noteThread1.data!.position).toEqual(noteThread1Anchor); + + // undo deleting note and context + let deleteLength: number = 5; + const beforeNoteLength: number = 2; + // target|->: $ch<-|apter 1, $verse 1. + env.targetEditor.setSelection(note1Position - beforeNoteLength, deleteLength, 'user'); + env.deleteCharacters(); + let newNotePosition: number = env.getNoteThreadEditorPosition('dataid01'); + expect(newNotePosition).toEqual(note1Position - beforeNoteLength); + env.triggerUndo(); + expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(note1Position); + expect(noteThread1.data!.position).toEqual(noteThread1Anchor); + + // undo deleting just the note when note thread doc has history + // target: |->$<-|chapter 1, $verse 1. + env.targetEditor.setSelection(note1Position, 1, 'user'); + env.deleteCharacters(); + env.triggerUndo(); + expect(noteThread1.data!.position).toEqual(noteThread1Anchor); + + // undo deleting note and entire selection + const embedLength = 1; + deleteLength = beforeNoteLength + embedLength + noteThread1.data!.position.length; + // target|->: $chapter<-| 1: $verse 1. + env.targetEditor.setSelection(note1Position - beforeNoteLength, deleteLength, 'user'); + env.deleteCharacters(); + newNotePosition = env.getNoteThreadEditorPosition('dataid01'); + const range = env.component.target!.getSegmentRange('verse_1_1')!; + // note moves to the beginning of the verse + expect(newNotePosition).toEqual(range.index); + env.triggerUndo(); + expect(noteThread1.data!.position).toEqual({ start: 8, length: 9 }); + + // 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'); + deleteLength = 3; + const text = 'abc'; + // target: $chapter 1, |->$ve<-|rse 1. + env.targetEditor.setSelection(note6Position, deleteLength, 'api'); + env.typeCharacters(text); + newNotePosition = env.getNoteThreadEditorPosition('dataid06'); + expect(newNotePosition).toEqual(note6Position + text.length); + env.triggerUndo(); + expect(env.getNoteThreadEditorPosition('dataid06')).toEqual(note6Position); + expect(noteThread6.data!.position).toEqual(noteThread6Anchor); + expect(noteThread1.data!.position).toEqual(noteThread1Anchor); + expect(textDoc.data!.ops![2 + opOffset].insert).toEqual('target: chapter 1, verse 1.'); + + // undo deleting multiple notes + if (modelHasBlanks) opOffset++; // Account for the blank in verse 2 + const noteThread3: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid03'); + const noteThread4: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04'); + const noteThread3Anchor: TextAnchor = { start: 20, length: 7 }; + const noteThread4Anchor: TextAnchor = { start: 20, length: 5 }; + expect(noteThread3.data!.position).toEqual(noteThread3Anchor); + expect(noteThread4.data!.position).toEqual(noteThread4Anchor); + expect(textDoc.data!.ops![6 + opOffset].insert).toEqual('target: chapter 1, verse 3.'); + const note3Position: number = env.getNoteThreadEditorPosition('dataid03'); + const note4Position: number = env.getNoteThreadEditorPosition('dataid04'); + deleteLength = 6; + // $target: chapter 1|->, $$ve<-|rse 3. + env.targetEditor.setSelection(note3Position - beforeNoteLength, deleteLength, 'api'); + env.deleteCharacters(); + newNotePosition = env.getNoteThreadEditorPosition('dataid03'); + expect(newNotePosition).toEqual(note3Position - beforeNoteLength); + newNotePosition = env.getNoteThreadEditorPosition('dataid04'); + expect(newNotePosition).toEqual(note4Position - beforeNoteLength); + env.triggerUndo(); + env.wait(); + expect(env.getNoteThreadEditorPosition('dataid03')).toEqual(note3Position); + expect(env.getNoteThreadEditorPosition('dataid04')).toEqual(note4Position); + expect(noteThread3.data!.position).toEqual(noteThread3Anchor); + expect(noteThread4.data!.position).toEqual(noteThread4Anchor); + expect(textDoc.data!.ops![6 + opOffset].insert).toEqual('target: chapter 1, verse 3.'); + env.dispose(); + })); + + it('note icon is changed after remote update', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const threadDataId: string = 'dataid01'; + const projectId: string = 'project01'; + const currentIconTag: string = '01flag1'; + const newIconTag: string = '02tag1'; + + const verse1Segment: HTMLElement = env.getSegmentElement('verse_1_1')!; + let verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; + expect(verse1Note).not.toBeNull(); + expect(verse1Note.getAttribute('style')).toEqual( + `--icon-file: url(/assets/icons/TagIcons/${currentIconTag}.png);` + ); - expect(addTab).not.toHaveBeenCalled(); - env.dispose(); - })); + // Update the last note on the thread as that is the icon displayed + const noteThread: NoteThreadDoc = 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); + verse1Note = verse1Segment.querySelector('display-note') as HTMLElement; + expect(verse1Note.getAttribute('style')).toEqual( + `--icon-file: url(/assets/icons/TagIcons/${newIconTag}.png);` + ); + env.dispose(); + })); + + it('note dialog appears after undo delete-a-note', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + let iconElement02: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; + iconElement02.click(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + let iconElement03: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid03')!; + iconElement03.click(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); + + const notePosition: number = env.getNoteThreadEditorPosition('dataid02'); + const selectionIndex: number = notePosition + 1; + env.targetEditor.setSelection(selectionIndex, 'user'); + env.wait(); + env.backspace(); + + // SUT + env.triggerUndo(); + iconElement02 = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; + iconElement02.click(); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).thrice(); + expect(iconElement02.parentElement!.tagName.toLowerCase()).toBe('display-text-anchor'); + iconElement03 = env.getNoteThreadIconElement('verse_1_3', 'dataid03')!; + iconElement03.click(); + env.wait(); + // ensure that clicking subsequent notes in a verse still works + verify(mockedMatDialog.open(NoteDialogComponent, anything())).times(4); + env.dispose(); + })); + + it('selection position on editor is kept when note dialog is opened and editor loses focus', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + const segmentRef = 'verse_1_3'; + const segmentRange = env.component.target!.getSegmentRange(segmentRef)!; + env.targetEditor.setSelection(segmentRange.index); + expect(env.activeElementClasses).toContain('ql-editor'); + const iconElement: HTMLElement = env.getNoteThreadIconElement(segmentRef, 'dataid02')!; + iconElement.click(); + const element: HTMLElement = env.targetTextEditor.querySelector( + 'usx-segment[data-segment="' + segmentRef + '"]' + )!; + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + env.wait(); + expect(env.activeElementTagName).toBe('DIV'); + expect(element.classList).withContext('dialog opened').toContain('highlight-segment'); + mockedMatDialog.closeAll(); + expect(element.classList).withContext('dialog closed').toContain('highlight-segment'); + env.dispose(); + })); + + it('shows only note threads published in Scripture Forge', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + const threadId: string = 'thread06'; + env.addParatextNoteThread( + threadId, + 'MAT 1:4', + 'Paragraph break.', + { start: 0, length: 0 }, + ['user05'], + NoteStatus.Todo, + '', + true + ); + env.wait(); - it('should select the draft tab if url query param is set', fakeAsync(() => { - const env = new TestEnvironment(); - when(mockedActivatedRoute.snapshot).thenReturn({ - queryParams: { 'draft-active': 'true', 'draft-timestamp': new Date().toISOString() } - } as any); - when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + const noteThreadElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', 'dataid01'); + expect(noteThreadElem).toBeNull(); + const sfNoteElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_4', 'dataidthread06'); + expect(sfNoteElem).toBeTruthy(); + env.dispose(); + })); + + it('shows insert note button for users with permission', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + // user02 does not have read permission on the text + const usersWhoCanInsertNotes = ['user01', 'user03', 'user04', 'user05']; + for (const user of usersWhoCanInsertNotes) { + env.setCurrentUser(user); + tick(); + env.fixture.detectChanges(); + expect(env.insertNoteFab).toBeTruthy(); + } - env.component.tabState.tabs$.pipe(take(1)).subscribe(tabs => { - expect(tabs.find(tab => tab.type === 'draft')?.isSelected).toBe(true); + const usersWhoCannotInsertNotes = ['user06', 'user07']; + for (const user of usersWhoCannotInsertNotes) { + env.setCurrentUser(user); + tick(); + env.fixture.detectChanges(); + expect(env.insertNoteFab).toBeNull(); + } env.dispose(); - }); - })); + })); - it('should not select the draft tab if url query param is not set', fakeAsync(() => { - const env = new TestEnvironment(); - when(mockedActivatedRoute.snapshot).thenReturn({ queryParams: {} } as any); - when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); - env.wait(); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + it('shows insert note button using bottom sheet for mobile viewport', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.setCurrentUser('user05'); + env.wait(); + + // Initial setup will state FALSE when checking for mobile viewports + let verseSegment: HTMLElement = env.getSegmentElement('verse_1_3')!; + verseSegment.click(); + env.wait(); + expect(env.insertNoteFabMobile).toBeNull(); + + // Allow check for mobile viewports to return TRUE + env.breakpointObserver.matchedResult = true; + verseSegment = env.getSegmentElement('verse_1_2')!; + verseSegment.click(); + env.wait(); + expect(env.insertNoteFabMobile).toBeTruthy(); + expect(env.mobileNoteTextArea).toBeFalsy(); + env.insertNoteFabMobile!.click(); + env.wait(); + expect(env.mobileNoteTextArea).toBeTruthy(); + // Close the bottom sheet + verseSegment = env.getSegmentElement('verse_1_2')!; + verseSegment.click(); + env.wait(); - env.component.tabState.tabs$.pipe(take(1)).subscribe(tabs => { - expect(tabs.find(tab => tab.type === 'draft')?.isSelected).toBe(false); env.dispose(); - }); - })); + })); + + it('shows insert new note from mobile viewport', fakeAsync(() => { + const content: string = 'content in the thread'; + const userId: string = 'user05'; + const segmentRef: string = 'verse_1_2'; + const verseRef: VerseRef = new VerseRef('MAT', '1', '2'); + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + selectedBookNum: verseRef.bookNum, + selectedChapterNum: verseRef.chapterNum, + selectedSegment: 'verse_1_3' + }); + env.setCurrentUser(userId); + env.wait(); + + // Allow check for mobile viewports to return TRUE + env.breakpointObserver.matchedResult = true; + env.clickSegmentRef(segmentRef); + env.insertNoteFabMobile!.click(); + env.wait(); + env.component.mobileNoteControl.setValue(content); + env.saveMobileNoteButton!.click(); + env.wait(); + const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); + expect(noteThread.verseRef).toEqual(fromVerseRef(verseRef)); + expect(noteThread.publishedToSF).toBe(true); + expect(noteThread.notes[0].ownerRef).toEqual(userId); + expect(noteThread.notes[0].content).toEqual(content); - it('should not throw exception on remote change when source is undefined', fakeAsync(() => { - const env = new TestEnvironment(); - env.setProjectUserConfig(); - env.wait(); + env.dispose(); + })); + + it('shows fab for users with editing rights but uses bottom sheet for adding new notes on mobile viewport', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.setCurrentUser('user04'); + env.wait(); + + // Allow check for mobile viewports to return TRUE + env.breakpointObserver.matchedResult = true; + env.clickSegmentRef('verse_1_2'); + expect(env.insertNoteFabMobile).toBeFalsy(); + expect(env.insertNoteFab).toBeTruthy(); + env.insertNoteFab.nativeElement.click(); + env.wait(); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); + expect(env.mobileNoteTextArea).toBeTruthy(); + expect(env.component.currentSegmentReference).toEqual('Matthew 1:2'); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).never(); + // Close the bottom sheet + env.bottomSheetCloseButton!.click(); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); + env.wait(); - env.component.source = undefined; + env.dispose(); + })); + + it('shows current selected verse on bottom sheet', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + // Allow check for mobile viewports to return TRUE + env.breakpointObserver.matchedResult = true; + env.clickSegmentRef('verse_1_1'); + env.wait(); + expect(env.insertNoteFabMobile).toBeTruthy(); + env.insertNoteFabMobile!.click(); + expect(env.bottomSheetVerseReference?.textContent).toEqual('Luke 1:1'); + const content = 'commenter leaving mobile note'; + env.component.mobileNoteControl.setValue(content); + env.saveMobileNoteButton!.click(); + env.wait(); + const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); + expect(noteThread.verseRef).toEqual(fromVerseRef(new VerseRef('LUK 1:1'))); + expect(noteThread.notes[0].content).toEqual(content); + env.dispose(); + })); + + it('can accept xml reserved symbols as note content', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + // Allow check for mobile viewports to return TRUE + env.breakpointObserver.matchedResult = true; + env.clickSegmentRef('verse_1_1'); + env.wait(); + expect(env.insertNoteFabMobile).toBeTruthy(); + env.insertNoteFabMobile!.click(); + expect(env.bottomSheetVerseReference?.textContent).toEqual('Luke 1:1'); + const content = 'mobile with xml symbols'; + env.component.mobileNoteControl.setValue(content); + env.saveMobileNoteButton!.click(); + env.wait(); + const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); + expect(noteThread.verseRef).toEqual(fromVerseRef(new VerseRef('LUK 1:1'))); + expect(noteThread.notes[0].content).toEqual(XmlUtils.encodeForXml(content)); + env.dispose(); + })); + + it('can edit a note with xml reserved symbols as note content', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const projectId: string = 'project01'; + env.setSelectionAndInsertNote('verse_1_2'); + const content: string = 'content in the thread'; + env.mockNoteDialogRef.close({ noteContent: content }); + env.wait(); + verify(mockedSFProjectService.createNoteThread(projectId, anything())).once(); + const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); + let noteThreadDoc: NoteThreadDoc = env.getNoteThreadDoc(projectId, noteThread.dataId); + expect(noteThreadDoc.data!.notes[0].content).toEqual(content); + + const iconElement: HTMLElement = env.getNoteThreadIconElementAtIndex('verse_1_2', 0)!; + iconElement.click(); + const editedContent = 'edited content & tags'; + env.mockNoteDialogRef.close({ noteDataId: noteThread.notes[0].dataId, noteContent: editedContent }); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); + noteThreadDoc = env.getNoteThreadDoc(projectId, noteThread.dataId); + expect(noteThreadDoc.data!.notes[0].content).toEqual(XmlUtils.encodeForXml(editedContent)); + env.dispose(); + })); + + it('shows SF note with default icon', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.addParatextNoteThread( + 6, + 'MAT 1:4', + 'target: chapter 1, verse 4.', + { start: 0, length: 0 }, + ['user01'], + NoteStatus.Todo, + undefined, + true, + true + ); + env.wait(); - expect(() => env.updateFontSize('project01', 24)).not.toThrow(); + const sfNote = env.getNoteThreadIconElement('verse_1_4', 'dataid06')!; + expect(sfNote.getAttribute('style')).toEqual( + '--icon-file: url(/assets/icons/TagIcons/' + SF_TAG_ICON + '.png);' + ); + env.dispose(); + })); + + it('cannot insert a note when editor content unavailable', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.onlineStatus = false; + const textDoc: TextDoc = 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); + env.wait(); + env.insertNoteFab.nativeElement.click(); + env.wait(); + verify(mockedNoticeService.show(anything())).once(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).never(); + subject.next(); + subject.complete(); + env.wait(); + env.insertNoteFab.nativeElement.click(); + env.wait(); + verify(mockedNoticeService.show(anything())).once(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + expect().nothing(); + env.dispose(); + })); + + it('can insert note on verse at cursor position', fakeAsync(() => { + const projectId: string = 'project01'; + const userId: string = 'user01'; + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ selectedBookNum: 40, selectedChapterNum: 1, selectedSegment: 'verse_1_1' }); + env.wait(); + expect(env.component.target!.segment!.ref).toBe('verse_1_1'); + env.setSelectionAndInsertNote('verse_1_4'); + + const content: string = 'content in the thread'; + env.mockNoteDialogRef.close({ noteContent: content }); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + const [, config] = capture(mockedMatDialog.open).last(); + const noteVerseRef: VerseRef = (config as MatDialogConfig).data!.verseRef; + expect(noteVerseRef.toString()).toEqual('MAT 1:4'); + + verify(mockedSFProjectService.createNoteThread(projectId, anything())).once(); + const [, noteThread] = capture(mockedSFProjectService.createNoteThread).last(); + expect(noteThread.verseRef).toEqual(fromVerseRef(noteVerseRef)); + expect(noteThread.publishedToSF).toBe(true); + expect(noteThread.notes[0].ownerRef).toEqual(userId); + expect(noteThread.notes[0].content).toEqual(content); + expect(noteThread.notes[0].tagId).toEqual(2); + expect(env.isNoteIconHighlighted(noteThread.dataId)).toBeFalse(); + expect(env.component.target!.segment!.ref).toBe('verse_1_4'); - env.dispose(); - })); - }); + env.dispose(); + })); + + it('allows adding a note to an existing thread', fakeAsync(() => { + const projectId: string = 'project01'; + const threadDataId: string = 'dataid04'; + const threadId: string = 'thread04'; + const segmentRef: string = 'verse_1_3'; + const env = new TestEnvironment({ modelHasBlanks }); + const content: string = 'content in the thread'; + let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + expect(noteThread.data!.notes.length).toEqual(1); + + env.setProjectUserConfig(); + env.wait(); + const noteThreadIconElem: HTMLElement = env.getNoteThreadIconElement(segmentRef, threadDataId)!; + noteThreadIconElem.click(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + const [, noteDialogData] = capture(mockedMatDialog.open).last(); + expect((noteDialogData!.data as NoteDialogData).threadDataId).toEqual(threadDataId); + env.mockNoteDialogRef.close({ noteContent: content }); + env.wait(); + noteThread = 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); + expect(noteThread.data!.notes[1].tagId).toBe(undefined); + env.dispose(); + })); + + it('allows resolving a note', fakeAsync(() => { + const projectId: string = 'project01'; + const threadDataId: string = 'dataid01'; + const content: string = 'This thread is resolved.'; + const env = new TestEnvironment({ modelHasBlanks }); + let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + expect(noteThread.data!.notes.length).toEqual(3); + + env.setProjectUserConfig(); + env.wait(); + let noteThreadIconElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', threadDataId); + noteThreadIconElem!.click(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + env.mockNoteDialogRef.close({ noteContent: content, status: NoteStatus.Resolved }); + env.wait(); + noteThread = 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); + + // the icon should be hidden from the editor + noteThreadIconElem = env.getNoteThreadIconElement('verse_1_1', threadDataId); + expect(noteThreadIconElem).toBeNull(); + env.dispose(); + })); + + it('allows editing and resolving a note', fakeAsync(async () => { + const projectId: string = 'project01'; + const threadDataId: string = 'dataid01'; + const content: string = 'This thread is resolved.'; + const env = new TestEnvironment({ modelHasBlanks }); + let noteThread: NoteThreadDoc = 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)); + env.setProjectUserConfig(); + env.wait(); + let noteThreadIconElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', threadDataId); + noteThreadIconElem!.click(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + env.mockNoteDialogRef.close({ + noteContent: content, + status: NoteStatus.Resolved, + noteDataId: 'thread01_note0' + }); + env.wait(); + noteThread = 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); + + // the icon should be hidden from the editor + noteThreadIconElem = env.getNoteThreadIconElement('verse_1_1', threadDataId); + expect(noteThreadIconElem).toBeNull(); + env.dispose(); + })); + + it('does not allow editing and resolving a non-editable note', fakeAsync(() => { + const projectId: string = 'project01'; + const threadDataId: string = 'dataid01'; + const content: string = 'This thread is resolved.'; + const env = new TestEnvironment({ modelHasBlanks }); + const dialogMessage = spyOn((env.component as any).dialogService, 'message').and.stub(); + let noteThread: NoteThreadDoc = env.getNoteThreadDoc(projectId, threadDataId); + expect(noteThread.data!.notes.length).toEqual(3); + env.setProjectUserConfig(); + env.wait(); + const noteThreadIconElem: HTMLElement | null = env.getNoteThreadIconElement('verse_1_1', threadDataId); + noteThreadIconElem!.click(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + env.mockNoteDialogRef.close({ + noteContent: content, + status: NoteStatus.Resolved, + noteDataId: 'thread01_note0' + }); + env.wait(); + noteThread = env.getNoteThreadDoc(projectId, threadDataId); + expect(noteThread.data!.notes.length).toEqual(3); + expect(dialogMessage).toHaveBeenCalledTimes(1); + env.dispose(); + })); + + it('can open dialog of the second note on the verse', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + expect(env.getNoteThreadIconElement('verse_1_3', 'dataid02')).not.toBeNull(); + env.setSelectionAndInsertNote('verse_1_3'); + const noteDialogResult: NoteDialogResult = { noteContent: 'newly created comment', noteDataId: 'notenew01' }; + env.mockNoteDialogRef.close(noteDialogResult); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + env.wait(); + + const noteElement: HTMLElement = env.getNoteThreadIconElementAtIndex('verse_1_3', 1)!; + noteElement.click(); + tick(); + env.fixture.detectChanges(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).twice(); + + // can open note on existing verse + const existingNoteIcon: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid03')!; + existingNoteIcon.click(); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).thrice(); + env.mockNoteDialogRef.close(); + env.setSelectionAndInsertNote('verse_1_3'); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).times(4); + env.dispose(); + })); + + it('commenters can click to select verse', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + env.wait(); + + const hasSelectionAnchors = env.getSegmentElement('verse_1_1')!.querySelector('display-text-anchor'); + expect(hasSelectionAnchors).toBeNull(); + const verseElem: HTMLElement = env.getSegmentElement('verse_1_1')!; + expect(verseElem.classList).not.toContain('commenter-selection'); + + // select verse 1 + verseElem.click(); + env.wait(); + expect(verseElem.classList).toContain('commenter-selection'); + let verse2Elem: HTMLElement = env.getSegmentElement('verse_1_2')!; + + // select verse 2, deselect verse one + verse2Elem.click(); + env.wait(); + expect(verse2Elem.classList).toContain('commenter-selection'); + expect(verseElem.classList).not.toContain('commenter-selection'); + + // deselect verse 2 + verse2Elem.click(); + env.wait(); + expect(verse2Elem.classList).not.toContain('commenter-selection'); + + // reselect verse 2, check that it is not selected when moving to a new book + verse2Elem.click(); + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + verse2Elem = env.getSegmentElement('verse_1_2')!; + expect(verse2Elem.classList).not.toContain('commenter-selection'); + const verse3Elem: HTMLElement = env.getSegmentElement('verse_1_3')!; + verse3Elem.click(); + expect(verse3Elem.classList).toContain('commenter-selection'); + expect(verse2Elem.classList).not.toContain('commenter-selection'); + env.dispose(); + })); + + it('does not select verse when opening a note thread', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + env.addParatextNoteThread( + 6, + 'MAT 1:1', + '', + { start: 0, length: 0 }, + ['user01'], + NoteStatus.Todo, + undefined, + true + ); + env.wait(); + + const elem: HTMLElement = env.getNoteThreadIconElement('verse_1_1', 'dataid06')!; + elem.click(); + env.mockNoteDialogRef.close(); + env.wait(); + const verse1Elem: HTMLElement = env.getSegmentElement('verse_1_1')!; + expect(verse1Elem.classList).not.toContain('commenter-selection'); + + // select verse 3 after closing the dialog + const verse3Elem: HTMLElement = env.getSegmentElement('verse_1_3')!; + verse3Elem.click(); + expect(verse1Elem.classList).not.toContain('commenter-selection'); + env.dispose(); + })); + + it('updates verse selection when opening a note dialog', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const segmentRef = 'verse_1_1'; + env.clickSegmentRef(segmentRef); + env.wait(); + const verse1Elem: HTMLElement = env.getSegmentElement(segmentRef)!; + expect(verse1Elem.classList).toContain('commenter-selection'); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); + + // simulate clicking the note icon on verse 3 + const segmentRef3 = 'verse_1_3'; + const thread2Position: number = env.getNoteThreadEditorPosition('dataid02'); + env.targetEditor.setSelection(thread2Position, 'user'); + const noteElem: HTMLElement = env.getNoteThreadIconElement('verse_1_3', 'dataid02')!; + noteElem.click(); + env.wait(); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + instance(mockedMatDialog).closeAll(); + env.wait(); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); + const verse3Elem: HTMLElement = env.getSegmentElement(segmentRef3)!; + expect(verse3Elem.classList).toContain('commenter-selection'); + expect(verse1Elem.classList).not.toContain('commenter-selection'); + env.dispose(); + })); + + it('deselects a verse when bottom sheet is open and chapter changed', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.ngZone.run(() => { + env.setProjectUserConfig(); + env.breakpointObserver.matchedResult = true; + env.wait(); + + const segmentRef = 'verse_1_1'; + env.setSelectionAndInsertNote(segmentRef); + expect(env.mobileNoteTextArea).toBeTruthy(); + env.component.chapter = 2; + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); + env.wait(); + env.clickSegmentRef('verse_2_2'); + env.wait(); + const verse1Elem: HTMLElement = env.getSegmentElement('verse_2_1')!; + expect(verse1Elem.classList).not.toContain('commenter-selection'); + const verse2Elem: HTMLElement = env.getSegmentElement('verse_2_2')!; + expect(verse2Elem.classList).toContain('commenter-selection'); + }); + env.dispose(); + })); + + it('keeps insert note fab hidden for commenters on mobile devices', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.breakpointObserver.matchedResult = true; + env.addParatextNoteThread( + 6, + 'MAT 1:1', + '', + { start: 0, length: 0 }, + ['user01'], + NoteStatus.Todo, + undefined, + true + ); + env.setCommenterUser(); + env.wait(); + + env.clickSegmentRef('verse_1_3'); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); + const noteElem: HTMLElement = env.getNoteThreadIconElement('verse_1_1', 'dataid06')!; + noteElem.click(); + env.wait(); + env.mockNoteDialogRef.close(); + env.wait(); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); + // clean up + env.clickSegmentRef('verse_1_3'); + env.dispose(); + })); + + it('shows the correct combined verse ref for a new note', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + const segmentRef = 'verse_1_2-3'; + env.setSelectionAndInsertNote(segmentRef); + const verseRef = new VerseRef('LUK', '1', '2-3'); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + const [, config] = capture(mockedMatDialog.open).last(); + expect((config!.data! as NoteDialogData).verseRef!.equals(verseRef)).toBeTrue(); + env.dispose(); + })); + + it('does not allow selecting section headings', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + let elem: HTMLElement = env.getSegmentElement('s_1')!; + expect(elem.classList).not.toContain('commenter-selection'); + env.clickSegmentRef('s_1'); + expect(elem.classList).not.toContain('commenter-selection'); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); + + elem = env.getSegmentElement('s_2')!; + expect(elem.classList).not.toContain('commenter-selection'); + env.clickSegmentRef('s_2'); + expect(elem.classList).not.toContain('commenter-selection'); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('hidden'); + + const verseElem: HTMLElement = env.getSegmentElement('verse_1_2-3')!; + expect(verseElem.classList).not.toContain('commenter-selection'); + env.clickSegmentRef('verse_1_2-3'); + expect(verseElem.classList).toContain('commenter-selection'); + expect(window.getComputedStyle(env.insertNoteFab.nativeElement)['visibility']).toBe('visible'); + expect(elem.classList).not.toContain('commenter-selection'); + env.dispose(); + })); + + it('commenters can create note on selected verse with FAB', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.setCommenterUser(); + env.wait(); + + const verseSegment: HTMLElement = env.getSegmentElement('verse_1_5')!; + verseSegment.click(); + env.wait(); + expect(verseSegment.classList).toContain('commenter-selection'); + + // Change to a PT reviewer to assert they can also use the FAB + verseSegment.click(); + expect(verseSegment.classList).not.toContain('commenter-selection'); + env.setParatextReviewerUser(); + env.wait(); + verseSegment.click(); + expect(verseSegment.classList).toContain('commenter-selection'); + + // Click and open the dialog + env.insertNoteFab.nativeElement.click(); + env.wait(); + verify(mockedMatDialog.open(NoteDialogComponent, anything())).once(); + const [, arg2] = capture(mockedMatDialog.open).last(); + const verseRef: VerseRef = (arg2 as MatDialogConfig).data.verseRef!; + expect(verseRef.toString()).toEqual('MAT 1:5'); + env.dispose(); + })); - describe('updateBiblicalTermsTabVisibility', () => { - it('should add biblical terms tab to source when enabled and "showSource" is true', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); - env.setProjectUserConfig({ biblicalTermsEnabled: true }); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); - env.wait(); + it('should remove resolved notes after a remote update', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); - const sourceTabGroup = env.component.tabState.getTabGroup('source'); - expect(sourceTabGroup?.tabs[1].type).toEqual('biblical-terms'); + let contents = env.targetEditor.getContents(); + let noteThreadEmbedCount = env.countNoteThreadEmbeds(contents.ops!); + expect(noteThreadEmbedCount).toEqual(5); - const targetTabGroup = env.component.tabState.getTabGroup('target'); - expect(targetTabGroup?.tabs[1]).toBeUndefined(); + 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(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + const threadId = 'dataid02'; + const segmentRef = 'verse_1_3'; + let thread2Elem: HTMLElement | null = env.getNoteThreadIconElement(segmentRef, threadId); + expect(thread2Elem).not.toBeNull(); + 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 originalPos: TextAnchor = { start: 8, length: 9 }; + expect(noteThreadDoc.data!.position).toEqual(originalPos); + env.typeCharacters('t'); + expect(noteThreadDoc.data!.position).toEqual({ start: originalPos.start, length: originalPos.length + 1 }); + env.dispose(); + })); - env.dispose(); - })); + it('should position FAB beside selected segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); - it('should add biblical terms tab to target when available and "showSource" is false', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => false }); - }); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); - env.setProjectUserConfig({ biblicalTermsEnabled: true }); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); - env.wait(); + const segmentRef = 'verse_1_1'; - const targetTabGroup = env.component.tabState.getTabGroup('target'); - expect(targetTabGroup?.tabs[1].type).toEqual('biblical-terms'); + env.clickSegmentRef(segmentRef); + env.wait(); - const sourceTabGroup = env.component.tabState.getTabGroup('source'); - expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + const segmentElRect = env.getSegmentElement(segmentRef)!.getBoundingClientRect(); + const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); + expect(segmentElRect.top).toBeCloseTo(fabRect.top, 0); - env.dispose(); - })); + env.dispose(); + })); - it('should not add the biblical terms tab when opening project with biblical terms disabled', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: false } }); - env.setProjectUserConfig({ biblicalTermsEnabled: true }); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); - env.wait(); + it('should position FAB beside selected segment when scrolling segment in view', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); - const sourceTabGroup = env.component.tabState.getTabGroup('source'); - expect(sourceTabGroup?.tabs[1]).toBeUndefined(); - expect(env.component.chapter).toBe(1); + // Set window size to be narrow to test scrolling + const contentContainer: HTMLElement = document.getElementsByClassName('content')[0] as HTMLElement; + Object.assign(contentContainer.style, { width: '360px', height: '300px' }); - env.dispose(); - })); + const segmentRef = 'verse_1_1'; - it('should not add the biblical terms tab if the user had biblical terms disabled', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => true }); - }); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); - env.setProjectUserConfig({ biblicalTermsEnabled: false }); - env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); - env.wait(); + // Select segment + env.clickSegmentRef(segmentRef); + env.wait(); - const sourceTabGroup = env.component.tabState.getTabGroup('source'); - expect(sourceTabGroup?.tabs[1]).toBeUndefined(); - expect(env.component.chapter).toBe(1); + // Scroll, keeping selected segment in view + const scrollContainer: Element = env.component['targetScrollContainer'] as Element; + scrollContainer.scrollTop = 20; + scrollContainer.dispatchEvent(new Event('scroll')); - env.dispose(); - })); + const segmentElRect = env.getSegmentElement(segmentRef)!.getBoundingClientRect(); + const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); + expect(Math.ceil(fabRect.top)).toEqual(Math.ceil(segmentElRect.top)); - it('should keep source pane open if biblical tab has been opened in it', fakeAsync(() => { - const env = new TestEnvironment(env => { - Object.defineProperty(env.component, 'showSource', { get: () => false }); - }); - env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); - env.setProjectUserConfig({ - biblicalTermsEnabled: true, - editorTabsOpen: [{ tabType: 'biblical-terms', groupId: 'source' }] - }); - env.routeWithParams({ projectId: 'project01', bookId: 'GEN', chapter: '1' }); - env.wait(); + env.dispose(); + })); - expect(env.component.showSource).toBe(false); - expect(env.component.showPersistedTabsOnSource).toBe(true); - expect(env.fixture.debugElement.query(By.css('.biblical-terms'))).not.toBeNull(); + it('should position FAB within scroll container when scrolling segment above view', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); - env.dispose(); - })); - }); + // Set window size to be narrow to test scrolling + const contentContainer: HTMLElement = document.getElementsByClassName('content')[0] as HTMLElement; + Object.assign(contentContainer.style, { width: '360px', height: '300px' }); - describe('tab header tooltips', () => { - it('should show source tab header tooltip', fakeAsync(async () => { - const env = new TestEnvironment(); - const tooltipHarness = await env.harnessLoader.getHarness( - MatTooltipHarness.with({ selector: '#source-text-area .tab-header-content' }) - ); - const sourceProjectDoc = env.getProjectDoc('project02'); - env.wait(); - await tooltipHarness.show(); - expect(await tooltipHarness.getTooltipText()).toBe(sourceProjectDoc.data?.translateConfig.source?.name!); - tooltipHarness.hide(); - env.dispose(); - })); + // Verse near top of scroll container + const segmentRef = 'verse_1_1'; - it('should show target tab header tooltip', fakeAsync(async () => { - const env = new TestEnvironment(); - const tooltipHarness = await env.harnessLoader.getHarness( - MatTooltipHarness.with({ selector: '#target-text-area .tab-header-content' }) - ); + // Select segment + env.clickSegmentRef(segmentRef); + env.wait(); - const targetProjectDoc = env.getProjectDoc('project01'); - env.wait(); - await tooltipHarness.show(); - expect(await tooltipHarness.getTooltipText()).toBe(targetProjectDoc.data?.name!); - tooltipHarness.hide(); - env.dispose(); - })); - }); + const scrollContainer: Element = env.component['targetScrollContainer'] as Element; + const scrollContainerRect: DOMRect = scrollContainer.getBoundingClientRect(); - describe('lynx features', () => { - it('should not show lynx features when feature flag is not enabled', fakeAsync(async () => { - const env = new TestEnvironment(() => { - when(mockedFeatureFlagService.enableLynxInsights).thenReturn(createTestFeatureFlag(false)); - }); + // Scroll segment above view + scrollContainer.scrollTop = 200; + scrollContainer.dispatchEvent(new Event('scroll')); - const textDocService = TestBed.inject(TextDocService); - spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); + const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); + expect(Math.ceil(fabRect.top)).toEqual(Math.ceil(scrollContainerRect.top + env.component.fabVerticalCushion)); - expect(env.component.hasChapterEditPermission).toBe(true); - expect(env.component.isUsfmValid).toBe(true); - expect(env.component.showInsights).toBe(false); + env.dispose(); + })); - env.dispose(); - })); + it('should position FAB within scroll container when scrolling segment below view', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); - it('should not show lynx features if user has no chapter edit permissions', fakeAsync(async () => { - const env = new TestEnvironment(() => { - when(mockedFeatureFlagService.enableLynxInsights).thenReturn(createTestFeatureFlag(true)); - }); + // Set window size to be narrow to test scrolling + const contentContainer: HTMLElement = document.getElementsByClassName('content')[0] as HTMLElement; + Object.assign(contentContainer.style, { width: '680px', height: '300px' }); - const textDocService = TestBed.inject(TextDocService); - spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + // Verse near bottom of scroll container + const segmentRef = 'verse_1_6'; + + // Select segment + env.clickSegmentRef(segmentRef); + env.wait(); + + const scrollContainer: Element = env.component['targetScrollContainer'] as Element; + const scrollContainerRect: DOMRect = scrollContainer.getBoundingClientRect(); + + // Scroll segment below view + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event('scroll')); + + const fabRect = env.insertNoteFab.nativeElement.getBoundingClientRect(); + expect(Math.ceil(fabRect.bottom)).toEqual( + Math.ceil(scrollContainerRect.bottom - env.component.fabVerticalCushion) + ); + + env.dispose(); + })); + }); + + describe('Translation Suggestions disabled', () => { + it('start with no previous selection', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + env.dispose(); + })); + + it('start with previously selected segment', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2, selectedSegment: 'verse_2_1' }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(2); + expect(env.component.verse).toBe('1'); + expect(env.component.target!.segmentRef).toEqual('verse_2_1'); + const selection = env.targetEditor.getSelection(); + expect(selection!.index).toBe(50); + expect(selection!.length).toBe(0); + verify(env.mockedRemoteTranslationEngine.getWordGraph(anything())).never(); + expect(env.component.showSuggestions).toBe(false); + env.dispose(); + })); + + it('user cannot edit', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setCurrentUser('user02'); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(false); + env.dispose(); + })); + + it('user can edit a chapter with permission', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(2); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + env.dispose(); + })); + + it('translator cannot edit a chapter without edit permission on chapter', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.bookName).toEqual('Luke'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.userHasGeneralEditRight).toBe(true); + expect(env.component.hasChapterEditPermission).toBe(false); + expect(env.component.canEdit).toBe(false); + expect(env.isSourceAreaHidden).toBe(false); + expect(env.noChapterEditPermissionMessage).toBeTruthy(); + env.dispose(); + })); + + it('user has no resource access', fakeAsync(() => { + when(mockedSFProjectService.getProfile('resource01')).thenResolve({ + id: 'resource01', + data: createTestProjectProfile() + } as SFProjectProfileDoc); + + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ + translateConfig: { + translationSuggestionsEnabled: false, + source: { + paratextId: 'resource01', + name: 'Resource 1', + shortName: 'SRC', + projectRef: 'resource01', + writingSystem: { + tag: 'qaa' + } + } + } + }); + env.setCurrentUser('user01'); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'ACT' }); + env.wait(); + verify(mockedSFProjectService.get('resource01')).never(); + expect(env.bookName).toEqual('Acts'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + env.dispose(); + })); + + it('chapter is invalid', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'MRK' }); + env.wait(); + expect(env.bookName).toEqual('Mark'); + expect(env.component.chapter).toBe(1); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(false); + expect(env.invalidWarning).not.toBeNull(); + env.dispose(); + })); + + it('first chapter is missing', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'ROM' }); + env.wait(); + expect(env.bookName).toEqual('Romans'); + expect(env.component.chapter).toBe(2); + expect(env.component.sourceLabel).toEqual('SRC'); + expect(env.component.targetLabel).toEqual('TRG'); + expect(env.component.target!.segmentRef).toEqual(''); + const selection = env.targetEditor.getSelection(); + expect(selection).toBeNull(); + expect(env.component.canEdit).toBe(true); + env.dispose(); + })); + + it('prevents editing and informs user when text doc is corrupted', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig }); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 3 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.component.hasEditRight).toBe(true); + expect(env.component.canEdit).toBe(false); + expect(env.corruptedWarning).not.toBeNull(); + env.dispose(); + })); + + it('prevents editing and informs user when app requires an update', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig, editingRequires: Number.MAX_SAFE_INTEGER }); + env.setProjectUserConfig(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + expect(env.component.hasEditRight).toBe(true); + expect(env.component.canEdit).toBe(false); + expect(env.component.updateRequired).toBe(true); + expect(env.updateRequiredWarning).not.toBeNull(); + env.dispose(); + })); + + it('shows translator settings when suggestions are enabled for the project and user can edit project', fakeAsync(() => { + const projectConfig = { + translateConfig: { ...defaultTranslateConfig, translationSuggestionsEnabled: true } + }; + const navigationParams: Params = { projectId: 'project01', bookId: 'MRK' }; + + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject(projectConfig); + env.setProjectUserConfig(); + env.routeWithParams(navigationParams); + env.wait(); + expect(env.suggestionsSettingsButton).toBeTruthy(); + env.dispose(); + })); + + it('hides translator settings when suggestions are enabled for the project but user cant edit', fakeAsync(() => { + const projectConfig = { + translateConfig: { ...defaultTranslateConfig, translationSuggestionsEnabled: true } + }; + const navigationParams: Params = { projectId: 'project01', bookId: 'MRK' }; + + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user06'); //has read but not edit + env.setupProject(projectConfig); + env.setProjectUserConfig(); + env.routeWithParams(navigationParams); + env.wait(); + expect(env.suggestionsSettingsButton).toBeFalsy(); + env.dispose(); + })); + + it('hides translator settings when suggestions are disabled for the project', fakeAsync(() => { + const projectConfig = { + translateConfig: { ...defaultTranslateConfig, translationSuggestionsEnabled: false } + }; + const navigationParams: Params = { projectId: 'project01', bookId: 'MRK' }; + + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject(projectConfig); + env.setProjectUserConfig(); + env.routeWithParams(navigationParams); + env.wait(); + expect(env.suggestionsSettingsButton).toBeFalsy(); + env.dispose(); + })); + + it('shows the copyright banner', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ translateConfig: defaultTranslateConfig, copyrightBanner: 'banner text' }); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 3 }); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); + env.wait(); + expect(env.copyrightBanner).not.toBeNull(); + env.dispose(); + })); + }); + + it('sets book and chapter according to route', fakeAsync(() => { + const navigationParams: Params = { projectId: 'project01', bookId: 'MAT', chapter: '2' }; + const env = new TestEnvironment({ modelHasBlanks }); + + env.setProjectUserConfig(); + env.routeWithParams(navigationParams); env.wait(); - expect(env.component.hasChapterEditPermission).toBe(false); - expect(env.component.isUsfmValid).toBe(true); - expect(env.component.showInsights).toBe(false); + expect(env.bookName).toEqual('Matthew'); + expect(env.component.chapter).toBe(2); env.dispose(); })); - it('should not show lynx features when USFM is invalid', fakeAsync(async () => { - const env = new TestEnvironment(() => { - when(mockedFeatureFlagService.enableLynxInsights).thenReturn(createTestFeatureFlag(true)); - }); - - const textDocService = TestBed.inject(TextDocService); - spyOn(textDocService, 'isUsfmValidForText').and.returnValue(false); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + it('navigates to alternate chapter if specified chapter does not exist', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + const nonExistentChapter = 3; + const routerSpy = spyOn(env.router, 'navigateByUrl').and.callThrough(); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: nonExistentChapter }); env.wait(); - expect(env.component.hasChapterEditPermission).toBe(true); - expect(env.component.isUsfmValid).toBe(false); - expect(env.component.showInsights).toBe(false); - + expect(routerSpy).toHaveBeenCalledWith('/projects/project01/translate/MAT/1'); env.dispose(); })); - it('should not show lynx features when changing chapter from USFM valid to chapter USFM invalid', fakeAsync(async () => { - const env = new TestEnvironment(() => { - when(mockedFeatureFlagService.enableLynxInsights).thenReturn(createTestFeatureFlag(true)); - }); + it('should navigate to "projects" route if url book is not in project', fakeAsync(() => { + const navigationParams: Params = { projectId: 'project01', bookId: 'GEN', chapter: '2' }; + const env = new TestEnvironment({ modelHasBlanks }); + flush(); + const spyRouterNavigate = spyOn(env.router, 'navigateByUrl'); - const textDocService = TestBed.inject(TextDocService); - const textDocService_isUsfmValidForText_Spy = spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); - spyOn(textDocService, 'hasChapterEditPermissionForText').and.returnValue(true); // Force edit permission true - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.routeWithParams(navigationParams); env.wait(); - expect(env.component.hasChapterEditPermission).toBe(true); - expect(env.component.isUsfmValid).toBe(true); - expect(env.component.showInsights).toBe(true); + expect(spyRouterNavigate).toHaveBeenCalledWith('projects', jasmine.any(Object)); + discardPeriodicTasks(); + })); - // Change chapters to one with invalid USFM - textDocService_isUsfmValidForText_Spy.and.returnValue(false); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); - env.wait(); + describe('tabs', () => { + describe('tab group consolidation', () => { + it('should call consolidateTabGroups for small screen widths once editor is loaded and tab state is initialized', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + + expect(spyConsolidate).not.toHaveBeenCalled(); + env.breakpointObserver.emitObserveValue(true); + env.component['tabStateInitialized$'].next(true); + expect(spyConsolidate).not.toHaveBeenCalled(); + env.component['targetEditorLoaded$'].next(); + env.wait(); + expect(spyConsolidate).toHaveBeenCalled(); + expect(env.component.source?.id?.toString()).toEqual('project02:MAT:1:target'); + discardPeriodicTasks(); + })); + + it('should call deconsolidateTabGroups for large screen widths once editor is loaded and tab state is initialized', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + const spyDeconsolidate = spyOn(env.component.tabState, 'deconsolidateTabGroups'); + expect(spyDeconsolidate).not.toHaveBeenCalled(); + env.breakpointObserver.emitObserveValue(false); + env.component['tabStateInitialized$'].next(true); + expect(spyDeconsolidate).not.toHaveBeenCalled(); + env.component['targetEditorLoaded$'].next(); + env.wait(); + expect(spyDeconsolidate).toHaveBeenCalled(); + expect(env.component.source?.id?.toString()).toEqual('project02:MAT:1:target'); + discardPeriodicTasks(); + })); + + it('should not set id on source tab if user does not have permission', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setCurrentUser('user05'); + env.setupUsers(['project01']); + env.setupProject({ userRoles: { user05: SFProjectRole.None } }, 'project02'); + expect(env.component.source?.id?.toString()).toBeUndefined(); + const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + + expect(spyConsolidate).not.toHaveBeenCalled(); + env.component['tabStateInitialized$'].next(true); + expect(spyConsolidate).not.toHaveBeenCalled(); + env.component['targetEditorLoaded$'].next(); + env.wait(); + expect(spyConsolidate).not.toHaveBeenCalled(); + expect(env.component.source?.id?.toString()).toBeUndefined(); + discardPeriodicTasks(); + })); + + it('should not consolidate if showSource is false', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: false }); + const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + + env.component['tabStateInitialized$'].next(true); + env.component['targetEditorLoaded$'].next(); + expect(spyConsolidate).not.toHaveBeenCalled(); + flush(); + })); + + it('should not consolidate on second editor load', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + + env.component['tabStateInitialized$'].next(true); + env.component['targetEditorLoaded$'].next(); + + const spyConsolidate = spyOn(env.component.tabState, 'consolidateTabGroups'); + + env.component['targetEditorLoaded$'].next(); + expect(spyConsolidate).not.toHaveBeenCalled(); + flush(); + })); + }); - expect(env.component.hasChapterEditPermission).toBe(true); - expect(env.component.isUsfmValid).toBe(false); - expect(env.component.showInsights).toBe(false); + describe('initEditorTabs', () => { + it('should add source tab when source is defined and viewable', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + const projectDoc = env.getProjectDoc('project01'); + const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); + env.wait(); + expect(spyCreateTab).toHaveBeenCalledWith('project-source', { + projectId: projectDoc.data?.translateConfig.source?.projectRef, + headerText$: jasmine.any(Object), + tooltip: projectDoc.data?.translateConfig.source?.name + }); + discardPeriodicTasks(); + })); + + it('should not add source tab when source is defined but not viewable', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + when(mockedPermissionsService.isUserOnProject('project02')).thenResolve(false); + const projectDoc = env.getProjectDoc('project01'); + const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); + env.wait(); + expect(spyCreateTab).not.toHaveBeenCalledWith('project-source', { + projectId: projectDoc.data?.translateConfig.source?.projectRef, + headerText$: jasmine.any(Object), + tooltip: projectDoc.data?.translateConfig.source?.name + }); + discardPeriodicTasks(); + })); + + it('should not add source tab when source is undefined', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); + delete env.testProjectProfile.translateConfig.source; + env.setupProject(); + env.wait(); + expect(spyCreateTab).not.toHaveBeenCalledWith('project-source', jasmine.any(Object)); + discardPeriodicTasks(); + })); + + it('should add target tab', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + const projectDoc = env.getProjectDoc('project01'); + const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); + env.wait(); + expect(spyCreateTab).toHaveBeenCalledWith('project-target', { + projectId: projectDoc.id, + headerText$: jasmine.any(Object), + tooltip: projectDoc.data?.name + }); + discardPeriodicTasks(); + })); + + it('should add source and target groups', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + const spyCreateTab = spyOn(env.component.tabState, 'setTabGroups').and.callThrough(); + env.wait(); + expect(spyCreateTab).toHaveBeenCalledWith( + jasmine.arrayWithExactContents([ + jasmine.any(TabGroup), + jasmine.any(TabGroup) + ]) + ); + discardPeriodicTasks(); + })); + + it('should not add the biblical terms tab if the project does not have biblical terms enabled', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: false } }); + env.setProjectUserConfig({ editorTabsOpen: [{ tabType: 'biblical-terms', groupId: 'source' }] }); + const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); + env.wait(); + expect(spyCreateTab).not.toHaveBeenCalledWith('biblical-terms', jasmine.any(Object)); + discardPeriodicTasks(); + })); + + it('should add the biblical terms tab if the project has biblical terms enabled', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); + env.setProjectUserConfig({ editorTabsOpen: [{ tabType: 'biblical-terms', groupId: 'source' }] }); + const spyCreateTab = spyOn(env.tabFactory, 'createTab').and.callThrough(); + env.wait(); + expect(spyCreateTab).toHaveBeenCalledWith('biblical-terms', jasmine.any(Object)); + discardPeriodicTasks(); + })); + + it('should exclude deleted resource tabs (tabs that have "projectDoc" but not "projectDoc.data")', fakeAsync(async () => { + const absentProjectId = 'absentProjectId'; + when(mockedSFProjectService.getProfile(absentProjectId)).thenResolve({ + data: undefined + } as SFProjectProfileDoc); + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig({ + editorTabsOpen: [{ tabType: 'project-resource', groupId: 'target', projectId: absentProjectId }] + }); + env.routeWithParams({ projectId: 'project01', bookId: 'GEN', chapter: '1' }); + env.wait(); + + const tabs = await firstValueFrom(env.component.tabState.tabs$); + expect(tabs.find(t => t.projectId === absentProjectId)).toBeUndefined(); + env.dispose(); + })); + }); - env.dispose(); - })); + describe('updateAutoDraftTabVisibility', () => { + it('should add the draft preview tab to source when available and "showSource" is true', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); - it('should show lynx features when feature flag is enabled and user has chapter edit permissions and USFM is valid', fakeAsync(async () => { - const env = new TestEnvironment(() => { - when(mockedFeatureFlagService.enableLynxInsights).thenReturn(createTestFeatureFlag(true)); + const tabGroup = env.component.tabState.getTabGroup('source'); + expect(tabGroup?.tabs[1].type).toEqual('draft'); + + const targetTabGroup = env.component.tabState.getTabGroup('target'); + expect(targetTabGroup?.tabs[1]).toBeUndefined(); + + env.dispose(); + })); + + it('should add draft preview tab to target when available and "showSource" is false', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: false }); + when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + const targetTabGroup = env.component.tabState.getTabGroup('target'); + expect(targetTabGroup?.tabs[1].type).toEqual('draft'); + + const sourceTabGroup = env.component.tabState.getTabGroup('source'); + expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + + env.dispose(); + })); + + it('should hide source draft preview tab when switching to chapter with no draft', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + const sourceTabGroup = env.component.tabState.getTabGroup('source'); + expect(sourceTabGroup?.tabs[1].type).toEqual('draft'); + expect(env.component.chapter).toBe(1); + + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); + env.wait(); + + expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + expect(env.component.chapter).toBe(2); + + env.dispose(); + })); + + it('should hide the draft preview tab when user is commenter', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + env.setCommenterUser(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + const targetTabGroup = env.component.tabState.getTabGroup('target'); + expect(targetTabGroup?.tabs[1]).toBeUndefined(); + expect(env.component.chapter).toBe(1); + + env.dispose(); + })); + + it('should hide the target draft preview tab when switching to chapter with no draft', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: false }); + when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + const targetTabGroup = env.component.tabState.getTabGroup('target'); + expect(targetTabGroup?.tabs[1].type).toEqual('draft'); + expect(env.component.chapter).toBe(1); + + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); + env.wait(); + + expect(targetTabGroup?.tabs[1]).toBeUndefined(); + expect(env.component.chapter).toBe(2); + + env.dispose(); + })); + + it('should not add draft tab if draft exists and draft tab is already present', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.wait(); + + env.component.tabState.addTab('target', env.tabFactory.createTab('draft')); + const addTab = spyOn(env.component.tabState, 'addTab'); + + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + expect(addTab).not.toHaveBeenCalled(); + env.dispose(); + })); + + it('should select the draft tab if url query param is set', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + when(mockedActivatedRoute.snapshot).thenReturn({ + queryParams: { 'draft-active': 'true', 'draft-timestamp': new Date().toISOString() } + } as any); + when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + env.component.tabState.tabs$.pipe(take(1)).subscribe(tabs => { + expect(tabs.find(tab => tab.type === 'draft')?.isSelected).toBe(true); + env.dispose(); + }); + })); + + it('should not select the draft tab if url query param is not set', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + when(mockedActivatedRoute.snapshot).thenReturn({ queryParams: {} } as any); + when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + env.wait(); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + env.component.tabState.tabs$.pipe(take(1)).subscribe(tabs => { + expect(tabs.find(tab => tab.type === 'draft')?.isSelected).toBe(false); + env.dispose(); + }); + })); + + it('should not throw exception on remote change when source is undefined', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks }); + env.setProjectUserConfig(); + env.wait(); + + env.component.source = undefined; + + expect(() => env.updateFontSize('project01', 24)).not.toThrow(); + + env.dispose(); + })); }); - const textDocService = TestBed.inject(TextDocService); - spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); - env.setCurrentUser('user03'); - env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); - env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); - env.wait(); + describe('updateBiblicalTermsTabVisibility', () => { + it('should add biblical terms tab to source when enabled and "showSource" is true', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); + env.setProjectUserConfig({ biblicalTermsEnabled: true }); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); + env.wait(); + + const sourceTabGroup = env.component.tabState.getTabGroup('source'); + expect(sourceTabGroup?.tabs[1].type).toEqual('biblical-terms'); + + const targetTabGroup = env.component.tabState.getTabGroup('target'); + expect(targetTabGroup?.tabs[1]).toBeUndefined(); + + env.dispose(); + })); + + it('should add biblical terms tab to target when available and "showSource" is false', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: false }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); + env.setProjectUserConfig({ biblicalTermsEnabled: true }); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); + env.wait(); + + const targetTabGroup = env.component.tabState.getTabGroup('target'); + expect(targetTabGroup?.tabs[1].type).toEqual('biblical-terms'); + + const sourceTabGroup = env.component.tabState.getTabGroup('source'); + expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + + env.dispose(); + })); + + it('should not add the biblical terms tab when opening project with biblical terms disabled', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: false } }); + env.setProjectUserConfig({ biblicalTermsEnabled: true }); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); + env.wait(); + + const sourceTabGroup = env.component.tabState.getTabGroup('source'); + expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + expect(env.component.chapter).toBe(1); + + env.dispose(); + })); + + it('should not add the biblical terms tab if the user had biblical terms disabled', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: true }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); + env.setProjectUserConfig({ biblicalTermsEnabled: false }); + env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '1' }); + env.wait(); + + const sourceTabGroup = env.component.tabState.getTabGroup('source'); + expect(sourceTabGroup?.tabs[1]).toBeUndefined(); + expect(env.component.chapter).toBe(1); + + env.dispose(); + })); + + it('should keep source pane open if biblical tab has been opened in it', fakeAsync(() => { + const env = new TestEnvironment({ modelHasBlanks, showSource: false }); + env.setupProject({ biblicalTermsConfig: { biblicalTermsEnabled: true } }); + env.setProjectUserConfig({ + biblicalTermsEnabled: true, + editorTabsOpen: [{ tabType: 'biblical-terms', groupId: 'source' }] + }); + env.routeWithParams({ projectId: 'project01', bookId: 'GEN', chapter: '1' }); + env.wait(); + + expect(env.component.showSource).toBe(false); + expect(env.component.showPersistedTabsOnSource).toBe(true); + expect(env.fixture.debugElement.query(By.css('.biblical-terms'))).not.toBeNull(); + + env.dispose(); + })); + }); - expect(env.component.hasChapterEditPermission).toBe(true); - expect(env.component.isUsfmValid).toBe(true); - expect(env.component.showInsights).toBe(true); + describe('tab header tooltips', () => { + it('should show source tab header tooltip', fakeAsync(async () => { + const env = new TestEnvironment({ modelHasBlanks }); + const tooltipHarness = await env.harnessLoader.getHarness( + MatTooltipHarness.with({ selector: '#source-text-area .tab-header-content' }) + ); + const sourceProjectDoc = env.getProjectDoc('project02'); + env.wait(); + await tooltipHarness.show(); + expect(await tooltipHarness.getTooltipText()).toBe(sourceProjectDoc.data?.translateConfig.source?.name!); + tooltipHarness.hide(); + env.dispose(); + })); + + it('should show target tab header tooltip', fakeAsync(async () => { + const env = new TestEnvironment({ modelHasBlanks }); + const tooltipHarness = await env.harnessLoader.getHarness( + MatTooltipHarness.with({ selector: '#target-text-area .tab-header-content' }) + ); + + const targetProjectDoc = env.getProjectDoc('project01'); + env.wait(); + await tooltipHarness.show(); + expect(await tooltipHarness.getTooltipText()).toBe(targetProjectDoc.data?.name!); + tooltipHarness.hide(); + env.dispose(); + })); + }); - env.dispose(); - })); + describe('lynx features', () => { + it('should not show lynx features when feature flag is not enabled', fakeAsync(async () => { + const env = new TestEnvironment({ enableLynxInsights: false, modelHasBlanks }); + + const textDocService = TestBed.inject(TextDocService); + spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + expect(env.component.hasChapterEditPermission).toBe(true); + expect(env.component.isUsfmValid).toBe(true); + expect(env.component.showInsights).toBe(false); + + env.dispose(); + })); + + it('should not show lynx features if user has no chapter edit permissions', fakeAsync(async () => { + const env = new TestEnvironment({ enableLynxInsights: true, modelHasBlanks }); + + const textDocService = TestBed.inject(TextDocService); + spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 1 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + expect(env.component.hasChapterEditPermission).toBe(false); + expect(env.component.isUsfmValid).toBe(true); + expect(env.component.showInsights).toBe(false); + + env.dispose(); + })); + + it('should not show lynx features when USFM is invalid', fakeAsync(async () => { + const env = new TestEnvironment({ enableLynxInsights: true, modelHasBlanks }); + + const textDocService = TestBed.inject(TextDocService); + spyOn(textDocService, 'isUsfmValidForText').and.returnValue(false); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + expect(env.component.hasChapterEditPermission).toBe(true); + expect(env.component.isUsfmValid).toBe(false); + expect(env.component.showInsights).toBe(false); + + env.dispose(); + })); + + it('should not show lynx features when changing chapter from USFM valid to chapter USFM invalid', fakeAsync(async () => { + const env = new TestEnvironment({ enableLynxInsights: true, modelHasBlanks }); + + const textDocService = TestBed.inject(TextDocService); + const textDocService_isUsfmValidForText_Spy = spyOn(textDocService, 'isUsfmValidForText').and.returnValue( + true + ); + spyOn(textDocService, 'hasChapterEditPermissionForText').and.returnValue(true); // Force edit permission true + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + expect(env.component.hasChapterEditPermission).toBe(true); + expect(env.component.isUsfmValid).toBe(true); + expect(env.component.showInsights).toBe(true); + + // Change chapters to one with invalid USFM + textDocService_isUsfmValidForText_Spy.and.returnValue(false); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); + env.wait(); + + expect(env.component.hasChapterEditPermission).toBe(true); + expect(env.component.isUsfmValid).toBe(false); + expect(env.component.showInsights).toBe(false); + + env.dispose(); + })); + + it('should show lynx features when feature flag is enabled and user has chapter edit permissions and USFM is valid', fakeAsync(async () => { + const env = new TestEnvironment({ enableLynxInsights: true, modelHasBlanks }); + + const textDocService = TestBed.inject(TextDocService); + spyOn(textDocService, 'isUsfmValidForText').and.returnValue(true); + env.setCurrentUser('user03'); + env.setProjectUserConfig({ selectedBookNum: 42, selectedChapterNum: 2 }); + env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); + env.wait(); + + expect(env.component.hasChapterEditPermission).toBe(true); + expect(env.component.isUsfmValid).toBe(true); + expect(env.component.showInsights).toBe(true); + + env.dispose(); + })); + }); + }); }); }); }); @@ -4354,6 +4420,12 @@ const defaultTranslateConfig = { translationSuggestionsEnabled: false }; +interface TestEnvironmentOptions { + enableLynxInsights?: boolean; + modelHasBlanks: boolean; + showSource?: boolean; +} + class TestEnvironment { readonly component: EditorComponent; readonly fixture: ComponentFixture; @@ -4528,7 +4600,7 @@ class TestEnvironment { noteTags: this.noteTags }); - constructor(preInit?: (env: TestEnvironment) => void) { + constructor(options: TestEnvironmentOptions) { this.params$ = new BehaviorSubject({ projectId: 'project01', bookId: 'MAT' }); when(mockedActivatedRoute.params).thenReturn(this.params$); @@ -4648,21 +4720,23 @@ class TestEnvironment { when(mockedDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn(of([])); when(mockedDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true)); when(mockedPermissionsService.isUserOnProject(anything())).thenResolve(true); - when(mockedFeatureFlagService.enableLynxInsights).thenReturn(createTestFeatureFlag(false)); + when(mockedFeatureFlagService.enableLynxInsights).thenReturn( + createTestFeatureFlag(options.enableLynxInsights ?? false) + ); when(mockedFeatureFlagService.newDraftHistory).thenReturn(createTestFeatureFlag(false)); when(mockedLynxWorkspaceService.rawInsightSource$).thenReturn(of([])); this.realtimeService = TestBed.inject(TestRealtimeService); - this.addTextDoc(new TextDocId('project02', 40, 1, 'target'), 'source', false, true); - this.addTextDoc(new TextDocId('project01', 40, 1, 'target'), 'target', false, true); - this.addTextDoc(new TextDocId('project02', 40, 2, 'target'), 'source'); - this.addTextDoc(new TextDocId('project01', 40, 2, 'target')); - this.addTextDoc(new TextDocId('project02', 41, 1, 'target'), 'source'); - this.addTextDoc(new TextDocId('project01', 41, 1, 'target')); - this.addCombinedVerseTextDoc(new TextDocId('project01', 42, 1, 'target')); - this.addCombinedVerseTextDoc(new TextDocId('project01', 42, 2, 'target')); - this.addTextDoc(new TextDocId('project01', 42, 3, 'target'), 'target', true); + this.addTextDoc(new TextDocId('project02', 40, 1, 'target'), options.modelHasBlanks, 'source', false, true); + this.addTextDoc(new TextDocId('project01', 40, 1, 'target'), options.modelHasBlanks, 'target', false, true); + this.addTextDoc(new TextDocId('project02', 40, 2, 'target'), options.modelHasBlanks, 'source'); + this.addTextDoc(new TextDocId('project01', 40, 2, 'target'), options.modelHasBlanks); + this.addTextDoc(new TextDocId('project02', 41, 1, 'target'), options.modelHasBlanks, 'source'); + this.addTextDoc(new TextDocId('project01', 41, 1, 'target'), options.modelHasBlanks); + this.addCombinedVerseTextDoc(new TextDocId('project01', 42, 1, 'target'), options.modelHasBlanks); + this.addCombinedVerseTextDoc(new TextDocId('project01', 42, 2, 'target'), options.modelHasBlanks); + this.addTextDoc(new TextDocId('project01', 42, 3, 'target'), options.modelHasBlanks, 'target', true); this.addEmptyTextDoc(new TextDocId('project01', 43, 1, 'target')); this.setupUsers(); @@ -4687,8 +4761,8 @@ class TestEnvironment { this.mockNoteDialogRef = new MockNoteDialogRef(this.fixture.nativeElement); this.component = this.fixture.componentInstance; - if (preInit) { - preInit(this); + if (options.showSource != null) { + Object.defineProperty(this.component, 'showSource', { get: () => options.showSource }); } this.routeWithParams({ projectId: 'project01', bookId: 'MAT' }); @@ -4785,6 +4859,10 @@ class TestEnvironment { return this.fixture.debugElement.query(By.css('.out-of-sync-warning')); } + get updateRequiredWarning(): DebugElement { + return this.fixture.debugElement.query(By.css('.update-required-warning')); + } + get noChapterEditPermissionMessage(): DebugElement { return this.fixture.debugElement.query(By.css('.no-edit-permission-message')); } @@ -4897,8 +4975,8 @@ class TestEnvironment { if (data.isRightToLeft != null) { projectProfileData.isRightToLeft = data.isRightToLeft; } - if (data.editable != null) { - projectProfileData.editable = data.editable; + if (data.editingRequires != null) { + projectProfileData.editingRequires = data.editingRequires; } if (data.defaultFontSize != null) { projectProfileData.defaultFontSize = data.defaultFontSize; @@ -5006,8 +5084,14 @@ class TestEnvironment { return this.component.target!.embeddedElements.get(threadDataId)!; } - getRemoteEditPosition(notePosition: number, positionAfter: number, noteCount: number): number { - return notePosition + 1 + positionAfter - noteCount; + getNoteThreadEditorPositions(): number[] { + return Array.from(this.component.target!.embeddedElements.entries()) + .filter(([key, _]) => !key.startsWith('blank_')) + .map(([_, value]) => value); + } + + getRemoteEditPosition(notePosition: number, positionAfter: number, noteCount: number, offset: number): number { + return notePosition + 1 + positionAfter - noteCount + offset; } isNoteIconHighlighted(threadDataId: string): boolean { @@ -5184,10 +5268,26 @@ class TestEnvironment { this.waitForPresenceTimer(); } - addTextDoc(id: TextDocId, textType: TextType = 'target', corrupt: boolean = false, tooLong: boolean = false): void { + addTextDoc( + id: TextDocId, + modelHasBlanks: boolean, + textType: TextType = 'target', + corrupt: boolean = false, + tooLong: boolean = false + ): void { + /* USFM: + \c 1 + \p + \v 1 target: chapter 1, verse 1. + \v 2 + \v 3 \f * \f* target: chapter 1, verse 3. + \v 4 target: chapter 1, verse 4. + \p Paragraph break. + \v 5 target: chapter 1, + */ const delta = new Delta(); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 1.`, { segment: `verse_${id.chapterNum}_1` }); delta.insert({ verse: { number: '2', style: 'v' } }); @@ -5196,11 +5296,11 @@ class TestEnvironment { delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 2.`, { segment: `verse_${id.chapterNum}_2` }); break; case 'target': - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_2` }); + if (modelHasBlanks) delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_2` }); break; } delta.insert({ verse: { number: '3', style: 'v' } }); - delta.insert({ note: { caller: '*' } }); + delta.insert({ note: { caller: '*', style: 'f' } }, { segment: `verse_${id.chapterNum}_3` }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 3.`, { segment: `verse_${id.chapterNum}_3` }); delta.insert({ verse: { number: '4', style: 'v' } }); delta.insert(`${id.textType}: chapter ${id.chapterNum}, verse 4.`, { segment: `verse_${id.chapterNum}_4` }); @@ -5383,11 +5483,11 @@ class TestEnvironment { this.wait(); } - private addCombinedVerseTextDoc(id: TextDocId): void { + private addCombinedVerseTextDoc(id: TextDocId, modelHasBlanks: boolean): void { this.realtimeService.addSnapshot(TextDoc.COLLECTION, { id: id.toString(), type: RichText.type.name, - data: getCombinedVerseTextDoc(id) + data: getCombinedVerseTextDoc(id, modelHasBlanks) }); } 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 cab405ae93d..e9d246f5cb9 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 @@ -541,6 +541,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, return this.textDocService.isDataInSync(this.projectDoc?.data); } + get updateRequired(): boolean { + return this.textDocService.isUpdateRequired(this.projectDoc?.data); + } + get issueEmailLink(): string { return getLinkHTML(environment.issueEmail, issuesEmailTemplate()); } @@ -987,8 +991,12 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, } } - async onSourceUpdated(textChange: boolean): Promise { - if (!textChange) { + async onSourceUpdated(delta: Delta | undefined): Promise { + // We do not count insertion of blank ops by the view model + if ( + delta == null || + (delta.ops?.some(op => (op.insert as any)?.blank === false) && delta.ops.some(op => op.retain != null)) + ) { return; } @@ -1897,6 +1905,13 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, if (this.translator == null) { return; } + + // If we have no prefix, ensure it is present, otherwise the segment will not be translated + if (this.target != null && this.translator.prefixWordRanges.length === 0) { + const text = this.target.getSegmentText(segment.ref); + this.translator?.setPrefix(text); + } + await this.translator.approve(true); segment.acceptChanges(); this.console.log( @@ -2348,6 +2363,9 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, let length = 0; if (typeof insertOp === 'string') { length = insertOp.length; + } else if (insertOp['blank'] === false) { + // Ignore blanks in the view model + continue; } else if (insertOp['note-thread-embed'] != null) { const embedId = insertOp['note-thread-embed']['threadid']; if (embedId != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts index 18171f8232a..eb2a6ac9394 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts @@ -143,11 +143,9 @@ class TestEnvironment { private addTextDoc(id: TextDocId): void { const delta = new Delta(); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); delta.insert(`chapter ${id.chapterNum}, verse 1.`, { segment: `verse_${id.chapterNum}_1` }); delta.insert({ verse: { number: '2', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_2` }); delta.insert({ verse: { number: '3', style: 'v' } }); delta.insert(`chapter ${id.chapterNum}, verse 3.`, { segment: `verse_${id.chapterNum}_3` }); this.realtimeService.addSnapshot(TextDoc.COLLECTION, { 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 9e58737f2f8..65b16492ed2 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 @@ -565,47 +565,31 @@ class TestEnvironment { private addTextDoc(id: TextDocId, allSegmentsBlank?: boolean): void { const delta = new Delta(); delta.insert({ chapter: { number: id.chapterNum.toString(), style: 'c' } }); - delta.insert({ blank: true }, { segment: 'p_1' }); delta.insert({ verse: { number: '1', style: 'v' } }); - if (allSegmentsBlank) { - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_1` }); - } else { + if (!allSegmentsBlank) { delta.insert(`chapter ${id.chapterNum}, verse 1.`, { segment: `verse_${id.chapterNum}_1` }); } delta.insert({ verse: { number: '2', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_2` }); delta.insert({ verse: { number: '3', style: 'v' } }); - if (allSegmentsBlank) { - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_3` }); - } else { + if (!allSegmentsBlank) { delta.insert(`chapter ${id.chapterNum}, verse 3.`, { segment: `verse_${id.chapterNum}_3` }); } delta.insert({ verse: { number: '4', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_4` }); delta.insert({ verse: { number: '5', style: 'v' } }); - if (allSegmentsBlank) { - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_5` }); - } else { + if (!allSegmentsBlank) { delta.insert(`chapter ${id.chapterNum}, verse 5.`, { segment: `verse_${id.chapterNum}_5` }); } delta.insert({ verse: { number: '6', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_6` }); delta.insert({ verse: { number: '7', style: 'v' } }); - if (allSegmentsBlank) { - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_7` }); - } else { + if (!allSegmentsBlank) { delta.insert(`chapter ${id.chapterNum}, verse 7.`, { segment: `verse_${id.chapterNum}_7` }); } delta.insert({ verse: { number: '8', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_8` }); delta.insert({ verse: { number: '9', style: 'v' } }); - if (allSegmentsBlank) { - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_9` }); - } else { + if (!allSegmentsBlank) { delta.insert(`chapter ${id.chapterNum}, verse 9.`, { segment: `verse_${id.chapterNum}_9` }); } delta.insert({ verse: { number: '10', style: 'v' } }); - delta.insert({ blank: true }, { segment: `verse_${id.chapterNum}_10` }); delta.insert('\n', { para: { style: 'p' } }); this.realtimeService.addSnapshot(TextDoc.COLLECTION, { id: id.toString(), diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index 90e698a471d..bb852d7b3a4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -372,6 +372,7 @@ "text_doc_corrupted": "This chapter cannot be edited because the data has become corrupted.", "text_has_been_deleted": "The book has been deleted or is no longer accessible.", "to_report_issue_email": "To report an issue, email {{ issueEmailLink }}.", + "update_required": "You are running an older version of Scripture Forge. Please refresh this page to edit this text.", "verse_too_long_for_suggestions": "This verse is too long to generate suggestions.", "your_comment": "Your comment" }, diff --git a/src/SIL.XForge.Scripture/Controllers/ParatextController.cs b/src/SIL.XForge.Scripture/Controllers/ParatextController.cs index df865174c9c..9c63bbe3ba3 100644 --- a/src/SIL.XForge.Scripture/Controllers/ParatextController.cs +++ b/src/SIL.XForge.Scripture/Controllers/ParatextController.cs @@ -216,6 +216,12 @@ DateTime timestamp { return Ok(await _paratextService.GetSnapshotAsync(userSecret, projectId, book, chapter, timestamp)); } + catch (ArgumentException e) + { + // We want to report this exception for further investigation ("The delta is not a document.") + _exceptionHandler.ReportException(e); + return Conflict(); + } catch (DataNotFoundException) { return NotFound(); diff --git a/src/SIL.XForge.Scripture/Models/EditingRequires.cs b/src/SIL.XForge.Scripture/Models/EditingRequires.cs new file mode 100644 index 00000000000..4b1c344c3b9 --- /dev/null +++ b/src/SIL.XForge.Scripture/Models/EditingRequires.cs @@ -0,0 +1,11 @@ +using System; + +namespace SIL.XForge.Scripture.Models; + +[Flags] +public enum EditingRequires +{ + None = 0, + ParatextEditingEnabled = 1 << 0, // 1 + ViewModelBlankSupport = 1 << 1, // 2 +} diff --git a/src/SIL.XForge.Scripture/Models/SFProject.cs b/src/SIL.XForge.Scripture/Models/SFProject.cs index 59366d923ab..84d9b5c4717 100644 --- a/src/SIL.XForge.Scripture/Models/SFProject.cs +++ b/src/SIL.XForge.Scripture/Models/SFProject.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using SIL.XForge.Models; @@ -20,7 +21,10 @@ public class SFProject : Project /// Paratext users on this SF project that are associated with a project component (e.g. a note) /// public List ParatextUsers { get; set; } = []; - public bool Editable { get; set; } = true; + + [Obsolete("To disable editing on older frontend clients. Deprecated August 2025.")] + public bool Editable { get; set; } + public EditingRequires EditingRequires { get; set; } public int? DefaultFontSize { get; set; } public string? DefaultFont { get; set; } public List NoteTags { get; set; } = []; diff --git a/src/SIL.XForge.Scripture/Services/DeltaUsxExtensions.cs b/src/SIL.XForge.Scripture/Services/DeltaUsxExtensions.cs index ef661aeb1cf..7575b1080fd 100644 --- a/src/SIL.XForge.Scripture/Services/DeltaUsxExtensions.cs +++ b/src/SIL.XForge.Scripture/Services/DeltaUsxExtensions.cs @@ -22,12 +22,6 @@ public static Delta InsertText(this Delta delta, string text, string? segRef = n return delta.Insert(text, attributes); } - public static Delta InsertBlank(this Delta delta, string segRef) - { - var attrs = new JObject(new JProperty("segment", segRef)); - return delta.Insert(new { blank = true }, attrs); - } - public static Delta InsertEmpty(this Delta delta, string segRef, JObject? attributes = null) { attributes = (JObject)attributes?.DeepClone() ?? []; diff --git a/src/SIL.XForge.Scripture/Services/DeltaUsxMapper.cs b/src/SIL.XForge.Scripture/Services/DeltaUsxMapper.cs index 8558b0210d7..6a5b821079a 100644 --- a/src/SIL.XForge.Scripture/Services/DeltaUsxMapper.cs +++ b/src/SIL.XForge.Scripture/Services/DeltaUsxMapper.cs @@ -63,8 +63,6 @@ private static XmlSchemaSet CreateSchemaSet() "lim", // Should not contain verse text, but sometimes do "b", - // Book - "id", ]; private class ParseState @@ -182,7 +180,6 @@ public IEnumerable ToChapterDeltas(XDocument usxDoc) state.CurRef = GetParagraphRef(nextIds, style, style); } ProcessChildNodes(elem, chapterDelta, invalidNodes, state); - SegmentEnded(chapterDelta, state.CurRef); if (!canContainVerseText) state.CurRef = null; InsertPara(elem, chapterDelta, invalidNodes, state); @@ -321,7 +318,6 @@ private void ProcessChildNode( { state.CurRef = $"cell_{state.TableIndex}_{rowIndex}_{cellIndex}"; ProcessChildNode(cell, newDelta, invalidNodes, state); - SegmentEnded(newDelta, state.CurRef); var attrs = new JObject( new JProperty("table", tableAttributes), new JProperty("row", rowAttributes) @@ -363,7 +359,6 @@ private static void ChapterEnded(List chapterDeltas, Delta chapter { if (state.ImpliedParagraph) { - SegmentEnded(chapterDelta, state.CurRef); chapterDelta.Insert('\n'); state.ImpliedParagraph = false; } @@ -376,7 +371,6 @@ private static void ChapterEnded(List chapterDeltas, Delta chapter private static void InsertVerse(XElement elem, Delta newDelta, HashSet invalidNodes, ParseState state) { var verse = (string)elem.Attribute("number"); - SegmentEnded(newDelta, state.CurRef); state.CurRef = $"verse_{state.CurChapter}_{verse}"; newDelta.InsertEmbed("verse", GetAttributes(elem), attributes: AddInvalidInlineAttribute(invalidNodes, elem)); } @@ -421,34 +415,6 @@ private static void InsertPara(XElement elem, Delta newDelta, HashSet inv newDelta.Insert("\n", attributes); } - private static void SegmentEnded(Delta newDelta, string? segRef) - { - if (segRef == null) - return; - - if (newDelta.Ops.Count == 0) - { - newDelta.InsertBlank(segRef); - } - else - { - JToken lastOp = newDelta.Ops[^1]; - string lastOpText = ""; - if (lastOp[Delta.InsertType].Type == JTokenType.String) - lastOpText = (string)lastOp[Delta.InsertType]; - var embed = lastOp[Delta.InsertType] as JObject; - var attrs = (JObject)lastOp[Delta.Attributes]; - if ( - (embed != null && (embed["verse"] != null || embed["chapter"] != null)) - || (attrs != null && (attrs["book"] != null || attrs["para"] != null || attrs["table"] != null)) - || lastOpText.EndsWith('\n') - ) - { - newDelta.InsertBlank(segRef); - } - } - } - private static string GetParagraphRef(Dictionary nextIds, string key, string prefix) { if (!nextIds.ContainsKey(key)) @@ -751,7 +717,9 @@ private static List ProcessDelta(Delta delta) case "para": // end of a book or para block for (int j = 0; j < text.Length; j++) - content.Add(CreateContainerElement(prop.Name, prop.Value, childNodes.Peek())); + content.Add( + CreateContainerElement(prop.Name, prop.Value, j == 0 ? childNodes.Peek() : null) + ); childNodes.Peek().Clear(); break; diff --git a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs index 76f5cad6cfe..89df48c5596 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs @@ -1770,7 +1770,11 @@ await _projectDoc.SubmitJson0OpAsync(op => // Set the right-to-left language flag op.Set(pd => pd.IsRightToLeft, settings.IsRightToLeft); - op.Set(pd => pd.Editable, settings.Editable); + op.Set( + pd => pd.EditingRequires, + EditingRequires.ViewModelBlankSupport + | (settings.Editable ? EditingRequires.ParatextEditingEnabled : EditingRequires.None) + ); op.Set(pd => pd.DefaultFont, settings.DefaultFont); op.Set(pd => pd.DefaultFontSize, settings.DefaultFontSize); if (settings.NoteTags != null) diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs index c98e9d2d980..5bd2abbac62 100644 --- a/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs @@ -315,6 +315,21 @@ public async Task GetRevisionHistoryAsync_Success() Assert.IsTrue(historyExists); } + [Test] + public async Task GetSnapshotAsync_DeltaIsNotADocument() + { + // Set up test environment + var env = new TestEnvironment(); + env.ParatextService.GetSnapshotAsync(Arg.Any(), Project01, Book, Chapter, Timestamp) + .Throws(new ArgumentException(@"The delta is not a document.")); + + // SUT + ActionResult actual = await env.Controller.GetSnapshotAsync(Project01, Book, Chapter, Timestamp); + + env.ExceptionHandler.Received(1).ReportException(Arg.Any()); + Assert.IsInstanceOf(actual.Result); + } + [Test] public async Task GetSnapshotAsync_Forbidden() { @@ -565,14 +580,14 @@ private class TestEnvironment public TestEnvironment() { - IExceptionHandler exceptionHandler = Substitute.For(); + ExceptionHandler = Substitute.For(); UserAccessor = Substitute.For(); UserAccessor.UserId.Returns(User01); UserAccessor.SystemRoles.Returns([SystemRole.ServalAdmin]); MemoryRepository userSecrets = new MemoryRepository( - new[] { new UserSecret { Id = User01 } } + [new UserSecret { Id = User01 }] ); MachineProjectService = Substitute.For(); @@ -600,7 +615,7 @@ public TestEnvironment() ProjectService = Substitute.For(); Controller = new ParatextController( - exceptionHandler, + ExceptionHandler, MachineProjectService, ParatextService, ProjectService, @@ -610,6 +625,7 @@ public TestEnvironment() } public ParatextController Controller { get; } + public IExceptionHandler ExceptionHandler { get; } public IMachineProjectService MachineProjectService { get; } public IParatextService ParatextService { get; } public ISFProjectService ProjectService { get; } diff --git a/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxMapperTests.cs b/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxMapperTests.cs index d2d5b882766..b9bcf79d265 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxMapperTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxMapperTests.cs @@ -65,7 +65,6 @@ public void ToUsx_VerseText() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("p") @@ -92,19 +91,12 @@ public void ToUsx_EmptySegments() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p") - .InsertBlank("verse_1_2/li_1") .InsertPara("li") - .InsertBlank("verse_1_2/li_2") .InsertPara("li") - .InsertBlank("verse_1_2/p_3") .InsertVerse("3") - .InsertBlank("verse_1_3") .InsertPara("p") .Insert("\n") ); @@ -146,14 +138,8 @@ public void ToUsx_InvalidChapters() // Get the chapter deltas, which will be the valid chapters List chapterDeltas = [.. mapper.ToChapterDeltas(usxDoc)]; - Delta expected1 = Delta - .New() - .InsertBook("RUT") - .InsertChapter("1") - .InsertVerse("1") - .InsertBlank("verse_1_1") - .InsertText("\n"); - Delta expected2 = Delta.New().InsertChapter("3").InsertVerse("1").InsertBlank("verse_3_1").InsertText("\n"); + Delta expected1 = Delta.New().InsertBook("RUT").InsertChapter("1").InsertVerse("1").InsertText("\n"); + Delta expected2 = Delta.New().InsertChapter("3").InsertVerse("1").InsertText("\n"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); Assert.That(chapterDeltas[0].LastVerse, Is.EqualTo(1)); @@ -180,7 +166,6 @@ public void ToUsx_CharText() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is some ", "verse_1_1") .InsertChar("bold", "bd", _testGuidService.Generate(), "verse_1_1") @@ -210,7 +195,6 @@ public void ToUsx_EmptyChar() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is some ", "verse_1_1") .InsertEmptyChar("bd", _testGuidService.Generate(), "verse_1_1") @@ -241,7 +225,6 @@ public void ToUsx_AdjacentChar_SpaceBetweenRetained() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is some ", "verse_1_1") .InsertChar("bold", "bd", _testGuidService.Generate(), "verse_1_1") @@ -274,7 +257,6 @@ public void ToUsx_Note() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -333,7 +315,6 @@ public void ToUsx_Figure() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a figure", "verse_1_1") .InsertFigure("file.jpg", "col", "PHM 1:1", "Caption", "verse_1_1") @@ -373,7 +354,6 @@ public void ToUsx_NestedChars() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -438,7 +418,6 @@ public void ToUsx_NestedAdjacentChars() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -503,7 +482,6 @@ public void ToUsx_DoubleNestedAdjacentChars() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -591,7 +569,6 @@ public void ToUsx_AdjacentChars() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar("1", "sup", _testGuidService.Generate(), "verse_1_1") .InsertChar("2", "sup", _testGuidService.Generate(), "verse_1_1") @@ -621,7 +598,6 @@ public void ToUsx_Ref() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -682,7 +658,6 @@ public void ToUsx_EmptyRef() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -737,7 +712,6 @@ public void ToUsx_OptBreak() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a line break", "verse_1_1") .InsertOptBreak("verse_1_1") @@ -766,7 +740,6 @@ public void ToUsx_Milestone() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a milestone", "verse_1_1") .InsertMilestone("ts", "verse_1_1") @@ -801,13 +774,10 @@ public void ToUsx_TableAtEnd() .InsertChar("1", "it", _testGuidService.Generate(), "verse_1_1") .InsertText(".", "verse_1_1") .InsertCell(1, 1, "tc1", "start") - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") - .InsertBlank("cell_1_2_1") .InsertCell(1, 2, "tc1", "start") - .InsertBlank("cell_1_2_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc2", "start") @@ -851,21 +821,17 @@ public void ToUsx_TableInMiddle() .InsertText(".", "verse_1_1") .InsertCell(1, 1, "tc1", "start") // Cell 2 begins - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") // Row 2 begins // Cell 1 begins - .InsertBlank("cell_1_2_1") .InsertCell(1, 2, "tc1", "start") // Cell 2 begins - .InsertBlank("cell_1_2_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc2", "start") // Post-table content - .InsertBlank("p_1") .InsertVerse("4") .InsertText("This is verse 4.", "verse_1_4") .InsertPara("p") @@ -903,35 +869,27 @@ public void ToUsx_AdjacentTables() Delta .New() .InsertChapter("1") - .InsertBlank("cell_1_1_1") .InsertVerse("1") .InsertText("This is verse 1.", "verse_1_1") .InsertCell(1, 1, "tc1", "start") - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") - .InsertBlank("cell_1_2_1") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc1", "start") - .InsertBlank("cell_1_2_2") .InsertVerse("4") .InsertText("This is verse 4.", "verse_1_4") .InsertCell(1, 2, "tc2", "start") - .InsertBlank("cell_2_1_1") .InsertVerse("5") .InsertText("This is verse 5.", "verse_1_5") .InsertCell(2, 1, "tc1", "start") - .InsertBlank("cell_2_1_2") .InsertVerse("6") .InsertText("This is verse 6.", "verse_1_6") .InsertCell(2, 1, "tc2", "start") - .InsertBlank("cell_2_2_1") .InsertVerse("7") .InsertText("This is verse 7.", "verse_1_7") .InsertCell(2, 2, "tc1", "start") - .InsertBlank("cell_2_2_2") .InsertVerse("8") .InsertText("This is verse 8.", "verse_1_8") .InsertCell(2, 2, "tc2", "start") @@ -978,7 +936,6 @@ public void ToUsx_CollapsesAdjacentNewlines() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("p") @@ -993,13 +950,7 @@ public void ToUsx_CollapsesAdjacentNewlines() 1, 1, true, - Delta - .New() - .InsertChapter("1") - .InsertBlank("p_1") - .InsertVerse("1") - .InsertText("Verse text.", "verse_1_1") - .InsertPara("p") + Delta.New().InsertChapter("1").InsertVerse("1").InsertText("Verse text.", "verse_1_1").InsertPara("p") ); var mapper = new DeltaUsxMapper(_mapperGuidService, _logger, _exceptionHandler); @@ -1015,12 +966,7 @@ public void ToUsx_CollapsesAdjacentNewlines() [Test] public void ToUsx_ConsecutiveSameStyleEmptyParas() { - var chapterDelta = new ChapterDelta( - 1, - 0, - true, - Delta.New().InsertBlank("p_1").InsertPara("p").InsertBlank("p_2").InsertPara("p") - ); + var chapterDelta = new ChapterDelta(1, 0, true, Delta.New().InsertPara("p").InsertPara("p")); var mapper = new DeltaUsxMapper(_mapperGuidService, _logger, _exceptionHandler); XDocument newUsxDoc = mapper.ToUsx(Usx("PHM"), [chapterDelta]); @@ -1045,24 +991,11 @@ public void ToUsx_NoParagraphs() .InsertVerse("1") .InsertText("This is verse 1.", "verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .Insert("\n") ), - new ChapterDelta( - 2, - 2, - true, - Delta - .New() - .InsertChapter("2") - .InsertVerse("1") - .InsertBlank("verse_2_1") - .InsertVerse("2") - .InsertBlank("verse_2_2") - .Insert("\n") - ), + new ChapterDelta(2, 2, true, Delta.New().InsertChapter("2").InsertVerse("1").InsertVerse("2").Insert("\n")), ]; var mapper = new DeltaUsxMapper(_mapperGuidService, _logger, _exceptionHandler); @@ -1100,9 +1033,7 @@ public void ToUsx_ImpliedParagraph() .InsertChapter("1") .Insert("This is an implied paragraph before the first verse.") .Insert("\n") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertPara("p") ); @@ -1132,9 +1063,7 @@ public void ToUsx_ImpliedParagraphTwice() .Insert("\n") .Insert(" This is actually part of the first implied paragraph.") .Insert("\n") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertPara("p") ); @@ -1165,7 +1094,6 @@ public void ToUsx_ImpliedParagraphInVerse() .Insert("\n") .InsertText("This is actually an implied paragraph as part of the verse.", "p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertPara("p") ); @@ -1197,24 +1125,11 @@ public void ToUsx_NoParagraphsImpliedParagraph() .InsertVerse("1") .InsertText("This is verse 1.", "verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .Insert("\n") ), - new ChapterDelta( - 2, - 2, - true, - Delta - .New() - .InsertChapter("2") - .InsertVerse("1") - .InsertBlank("verse_2_1") - .InsertVerse("2") - .InsertBlank("verse_2_2") - .Insert("\n") - ), + new ChapterDelta(2, 2, true, Delta.New().InsertChapter("2").InsertVerse("1").InsertVerse("2").Insert("\n")), ]; var mapper = new DeltaUsxMapper(_mapperGuidService, _logger, _exceptionHandler); @@ -1236,6 +1151,30 @@ public void ToUsx_NoParagraphsImpliedParagraph() Assert.IsTrue(XNode.DeepEquals(newUsxDoc, expected)); } + [Test] + public void ToUsx_CombinedConsecutiveParagraph() + { + // This corresponds to the following USFM + /* + * \c 1 + * \v 1 + * \p + * \p + */ + var chapterDelta = new ChapterDelta( + 1, + 1, + true, + Delta.New().InsertChapter("1").Insert("\n\n", new { para = new { style = "p" } }).InsertVerse("1") + ); + + var mapper = new DeltaUsxMapper(_mapperGuidService, _logger, _exceptionHandler); + XDocument newUsxDoc = mapper.ToUsx(Usx("PHM"), [chapterDelta]); + + XDocument expected = Usx("PHM", Chapter("1"), Para("p"), Para("p"), Verse("1")); + Assert.IsTrue(XNode.DeepEquals(newUsxDoc, expected)); + } + [Test] public void ToUsx_EmptyBook() { @@ -1446,16 +1385,11 @@ public void ToUsx_BlankLine() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p") .InsertPara("b") - .InsertBlank("p_2") .InsertVerse("3") - .InsertBlank("verse_1_3") .InsertPara("p") ); @@ -1482,7 +1416,6 @@ public void ToUsx_MultipleBookElements() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("p") @@ -1510,7 +1443,6 @@ public void ToUsx_InvalidParaInFirstChapter() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("New verse text.", "verse_1_1") .InsertPara("bad", true) @@ -1522,7 +1454,6 @@ public void ToUsx_InvalidParaInFirstChapter() Delta .New() .InsertChapter("2") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("New verse text.", "verse_2_1") .InsertPara("p") @@ -1534,7 +1465,6 @@ public void ToUsx_InvalidParaInFirstChapter() Delta .New() .InsertChapter("3") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("New verse text.", "verse_3_1") .InsertPara("p") @@ -1582,7 +1512,6 @@ public void ToUsx_InvalidParaInMiddleChapter() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("New verse text.", "verse_1_1") .InsertPara("p") @@ -1594,7 +1523,6 @@ public void ToUsx_InvalidParaInMiddleChapter() Delta .New() .InsertChapter("2") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("New verse text.", "verse_2_1") .InsertPara("bad", true) @@ -1606,7 +1534,6 @@ public void ToUsx_InvalidParaInMiddleChapter() Delta .New() .InsertChapter("3") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("New verse text.", "verse_3_1") .InsertPara("p") @@ -1654,7 +1581,6 @@ public void ToUsx_InvalidParaInLastChapter() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("New verse text.", "verse_1_1") .InsertPara("p") @@ -1666,7 +1592,6 @@ public void ToUsx_InvalidParaInLastChapter() Delta .New() .InsertChapter("2") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("New verse text.", "verse_2_1") .InsertPara("p") @@ -1678,7 +1603,6 @@ public void ToUsx_InvalidParaInLastChapter() Delta .New() .InsertChapter("3") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("New verse text.", "verse_3_1") .InsertPara("bad", true) @@ -1722,7 +1646,6 @@ public void ToUsx_Unmatched() Delta .New() .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with an unmatched marker", "verse_1_1") .InsertEmbed("unmatched", new JObject(new JProperty("marker", "bad")), "verse_1_1") @@ -1748,15 +1671,7 @@ public void ToUsx_InvalidChapterNumber() 2, 2, true, - Delta - .New() - .InsertChapter("2") - .InsertBlank("p_1") - .InsertVerse("1") - .InsertBlank("verse_2_1") - .InsertVerse("2") - .InsertBlank("verse_2_2") - .InsertPara("p") + Delta.New().InsertChapter("2").InsertVerse("1").InsertVerse("2").InsertPara("p") ); XDocument oldUsxDoc = Usx("PHM", Chapter("bad"), Para("p", Verse("1"), Verse("2")), Chapter("2")); @@ -1784,19 +1699,12 @@ public void ToDelta_EmptySegments() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p") - .InsertBlank("verse_1_2/li_1") .InsertPara("li") - .InsertBlank("verse_1_2/li_2") .InsertPara("li") - .InsertBlank("verse_1_2/p_3") .InsertVerse("3") - .InsertBlank("verse_1_3") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -1819,15 +1727,7 @@ public void ToDelta_InvalidChapterNumber() var mapper = new DeltaUsxMapper(_mapperGuidService, _logger, _exceptionHandler); List chapterDeltas = [.. mapper.ToChapterDeltas(usxDoc)]; - var expected = Delta - .New() - .InsertChapter("2") - .InsertBlank("p_1") - .InsertVerse("1") - .InsertBlank("verse_2_1") - .InsertVerse("2") - .InsertBlank("verse_2_2") - .InsertPara("p"); + var expected = Delta.New().InsertChapter("2").InsertVerse("1").InsertVerse("2").InsertPara("p"); Assert.That(chapterDeltas.Count, Is.EqualTo(1)); Assert.That(chapterDeltas[0].Number, Is.EqualTo(2)); @@ -1848,11 +1748,8 @@ public void ToDelta_InvalidChapter() .New() .InsertBook("PHM") .InsertChapter("1", "bad", true) - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -1873,11 +1770,8 @@ public void ToDelta_InvalidVerse() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1", "bad", true) - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -1904,7 +1798,6 @@ public void ToDelta_DoublyInvalidInline() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "blah", @@ -1917,7 +1810,6 @@ public void ToDelta_DoublyInvalidInline() invalid: true ) .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -1938,11 +1830,8 @@ public void ToDelta_InvalidLastVerse() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2bad", "v", true) - .InsertBlank("verse_1_2bad") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -1976,33 +1865,21 @@ public void ToDelta_SectionHeader() .InsertText("Philemon", "mt_1") .InsertPara("mt") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p") - .InsertBlank("s_1") .InsertPara("s") - .InsertBlank("p_2") .InsertVerse("3") - .InsertBlank("verse_1_3") .InsertPara("p"); var expectedChapter2 = Delta .New() .InsertChapter("2") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_2_1") .InsertVerse("2") - .InsertBlank("verse_2_2") .InsertPara("p") - .InsertBlank("s_1") .InsertPara("s") - .InsertBlank("p_2") .InsertVerse("3") - .InsertBlank("verse_2_3") .InsertPara("p"); Assert.That(chapterDeltas.Count, Is.EqualTo(2)); @@ -2048,7 +1925,6 @@ public void ToDelta_Note() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -2106,7 +1982,6 @@ public void ToDelta_InvalidNote() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -2165,7 +2040,6 @@ public void ToDelta_Note_InvalidContent() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -2213,7 +2087,6 @@ public void ToDelta_Figure() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a figure", "verse_1_1") .InsertFigure("file.jpg", "col", "PHM 1:1", "Caption", "verse_1_1") @@ -2248,7 +2121,6 @@ public void ToDelta_InvalidFigure() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a figure", "verse_1_1") .InsertFigure("file.jpg", "col", null, "Caption", "verse_1_1", true) @@ -2277,7 +2149,6 @@ public void ToDelta_CharText() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is some ", "verse_1_1") .InsertChar("bold", "bd", _testGuidService.Generate(), "verse_1_1") @@ -2302,7 +2173,6 @@ public void ToDelta_EmptyChar() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is some ", "verse_1_1") .InsertEmptyChar("bd", _testGuidService.Generate(), "verse_1_1") @@ -2340,7 +2210,6 @@ public void ToDelta_NestedChars() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -2405,7 +2274,6 @@ public void ToDelta_NestedAdjacentChars() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -2477,7 +2345,6 @@ public void ToDelta_DoubleNestedAdjacentChars() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -2562,7 +2429,6 @@ public void ToDelta_InvalidChars() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar( "1", @@ -2621,7 +2487,6 @@ public void ToDelta_AdjacentChars() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertChar("1", "sup", _testGuidService.Generate(), "verse_1_1") .InsertChar("2", "sup", _testGuidService.Generate(), "verse_1_1") @@ -2668,7 +2533,6 @@ public void ToDelta_Ref() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -2725,7 +2589,6 @@ public void ToDelta_EmptyRef() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -2782,7 +2645,6 @@ public void ToDelta_InvalidRef() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a footnote", "verse_1_1") .InsertNote( @@ -2825,7 +2687,6 @@ public void ToDelta_OptBreak() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a line break", "verse_1_1") .InsertOptBreak("verse_1_1") @@ -2854,7 +2715,6 @@ public void ToDelta_Milestone() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a line break", "verse_1_1") .InsertMilestone("ts", "verse_1_1") @@ -2883,7 +2743,6 @@ public void ToDelta_InvalidMilestone() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with a line break", "verse_1_1") .InsertMilestone("bad", "verse_1_1", true) @@ -2924,13 +2783,10 @@ public void ToDelta_TableAtEnd() .InsertChar("1", "it", _testGuidService.Generate(), "verse_1_1") .InsertText(".", "verse_1_1") .InsertCell(1, 1, "tc1", "start") - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") - .InsertBlank("cell_1_2_1") .InsertCell(1, 2, "tc1", "start") - .InsertBlank("cell_1_2_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc2", "start"); @@ -2974,17 +2830,13 @@ public void ToDelta_TableInMiddle() .InsertChar("1", "it", _testGuidService.Generate(), "verse_1_1") .InsertText(".", "verse_1_1") .InsertCell(1, 1, "tc1", "start") - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") - .InsertBlank("cell_1_2_1") .InsertCell(1, 2, "tc1", "start") - .InsertBlank("cell_1_2_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc2", "start") - .InsertBlank("p_1") .InsertVerse("4") .InsertText("This is verse 4.", "verse_1_4") .InsertPara("p"); @@ -3044,7 +2896,6 @@ public void ToDelta_TableInMiddleFollowedByCharStyle() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("B", "verse_1_1") .InsertPara("p") @@ -3103,35 +2954,27 @@ public void ToDelta_AdjacentTables() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("cell_1_1_1") .InsertVerse("1") .InsertText("This is verse 1.", "verse_1_1") .InsertCell(1, 1, "tc1", "start") - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") - .InsertBlank("cell_1_2_1") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc1", "start") - .InsertBlank("cell_1_2_2") .InsertVerse("4") .InsertText("This is verse 4.", "verse_1_4") .InsertCell(1, 2, "tc2", "start") - .InsertBlank("cell_2_1_1") .InsertVerse("5") .InsertText("This is verse 5.", "verse_1_5") .InsertCell(2, 1, "tc1", "start") - .InsertBlank("cell_2_1_2") .InsertVerse("6") .InsertText("This is verse 6.", "verse_1_6") .InsertCell(2, 1, "tc2", "start") - .InsertBlank("cell_2_2_1") .InsertVerse("7") .InsertText("This is verse 7.", "verse_1_7") .InsertCell(2, 2, "tc1", "start") - .InsertBlank("cell_2_2_2") .InsertVerse("8") .InsertText("This is verse 8.", "verse_1_8") .InsertCell(2, 2, "tc2", "start"); @@ -3174,13 +3017,10 @@ public void ToDelta_InvalidTable() .InsertChar("1", "it", _testGuidService.Generate(), "verse_1_1") .InsertText(".", "verse_1_1") .InsertCell(1, 1, "bad", "start", true) - .InsertBlank("cell_1_1_2") .InsertVerse("2") .InsertText("This is verse 2.", "verse_1_2") .InsertCell(1, 1, "tc2", "start") - .InsertBlank("cell_1_2_1") .InsertCell(1, 2, "tc1", "start") - .InsertBlank("cell_1_2_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .InsertCell(1, 2, "tc2", "start"); @@ -3217,18 +3057,10 @@ public void ToDelta_NoParagraphs() .InsertVerse("1") .InsertText("This is verse 1.", "verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .Insert("\n"); - var expected2 = Delta - .New() - .InsertChapter("2") - .InsertVerse("1") - .InsertBlank("verse_2_1") - .InsertVerse("2-3") - .InsertBlank("verse_2_2-3") - .Insert("\n"); + var expected2 = Delta.New().InsertChapter("2").InsertVerse("1").InsertVerse("2-3").Insert("\n"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); Assert.That(chapterDeltas[0].LastVerse, Is.EqualTo(3)); @@ -3259,9 +3091,7 @@ public void ToDelta_ImpliedParagraph() .InsertChapter("1") .Insert("This is an implied paragraph before the first verse.") .Insert("\n") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -3291,7 +3121,6 @@ public void ToDelta_ImpliedParagraphInVerse() .Insert("\n") .InsertText("This is actually an implied paragraph as part of the verse.", "p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -3328,18 +3157,10 @@ public void ToDelta_NoParagraphsImpliedParagraph() .InsertVerse("1") .InsertText("This is verse 1.", "verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertVerse("3") .InsertText("This is verse 3.", "verse_1_3") .Insert("\n"); - var expected2 = Delta - .New() - .InsertChapter("2") - .InsertVerse("1") - .InsertBlank("verse_2_1") - .InsertVerse("2-3") - .InsertBlank("verse_2_2-3") - .Insert("\n"); + var expected2 = Delta.New().InsertChapter("2").InsertVerse("1").InsertVerse("2-3").Insert("\n"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); Assert.That(chapterDeltas[0].LastVerse, Is.EqualTo(3)); @@ -3387,19 +3208,12 @@ public void ToDelta_EmptyStyle() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p") - .InsertBlank("verse_1_2/_1") .InsertPara("") - .InsertBlank("verse_1_2/li_2") .InsertPara("li") - .InsertBlank("verse_1_2/_3") .InsertVerse("3") - .InsertBlank("verse_1_3") .InsertPara(""); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -3426,17 +3240,11 @@ public void ToDelta_BlankLine() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") - .InsertBlank("verse_1_1") .InsertVerse("2") - .InsertBlank("verse_1_2") .InsertPara("p") - .InsertBlank("verse_1_2/b_1") .InsertPara("b") - .InsertBlank("verse_1_2/p_2") .InsertVerse("3") - .InsertBlank("verse_1_3") .InsertPara("p"); Assert.That(chapterDeltas[0].Number, Is.EqualTo(1)); @@ -3463,7 +3271,6 @@ public void ToDelta_BlankLineContainsText() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("p") @@ -3499,21 +3306,18 @@ public void ToDelta_InvalidParaInFirstChapter() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("bad", true); var expectedChapter2 = Delta .New() .InsertChapter("2") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_2_1") .InsertPara("p"); var expectedChapter3 = Delta .New() .InsertChapter("3") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_3_1") .InsertPara("p"); @@ -3556,21 +3360,18 @@ public void ToDelta_InvalidParaInMiddleChapter() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("p"); var expectedChapter2 = Delta .New() .InsertChapter("2") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("Verse text.", "verse_2_1") .InsertPara("bad", true); var expectedChapter3 = Delta .New() .InsertChapter("3") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_3_1") .InsertPara("p"); @@ -3613,21 +3414,18 @@ public void ToDelta_InvalidParaInLastChapter() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_1_1") .InsertPara("p"); var expectedChapter2 = Delta .New() .InsertChapter("2") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("Verse text.", "verse_2_1") .InsertPara("p"); var expectedChapter3 = Delta .New() .InsertChapter("3") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("Verse text.", "verse_3_1") .InsertPara("bad", true); @@ -3663,7 +3461,6 @@ public void ToDelta_InvalidParaContainingVerse() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("s_1") .InsertVerse("1") .InsertText("This verse should not exist within this paragraph style", "verse_1_1") .InsertPara("s", true); @@ -3693,14 +3490,12 @@ public void ToDelta_SecondChapterInInvalidBook() .New() .InsertBook("TDX", invalid: true) .InsertChapter("1") - .InsertBlank("q_1") .InsertVerse("1") .InsertText("This verse is valid, but in an invalid book", "verse_1_1") .InsertPara("q"), Delta .New() .InsertChapter("2") - .InsertBlank("q_1") .InsertVerse("1") .InsertText("This verse is also valid, but in an invalid book", "verse_2_1") .InsertPara("q"), @@ -3733,13 +3528,11 @@ public void ToDelta_LineBreakWithinVerse() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("q_1") .InsertVerse("1") .InsertText("Poetry first line", "verse_1_1") .InsertPara("q") .InsertText("Poetry second line", "verse_1_1/q_1") .InsertPara("q") - .InsertBlank("verse_1_1/b_2") .InsertPara("b") .InsertText("Poetry third line", "verse_1_1/q_3") .InsertPara("q") @@ -3766,7 +3559,6 @@ public void ToDelta_Unmatched() .New() .InsertBook("PHM") .InsertChapter("1") - .InsertBlank("p_1") .InsertVerse("1") .InsertText("This is a verse with an unmatched marker", "verse_1_1") .InsertEmbed("unmatched", new JObject(new JProperty("marker", "bad")), "verse_1_1") @@ -3983,7 +3775,7 @@ public void Roundtrip_TextWithBlankLines() """ \id PSA - A \c 1 - \q \v 1 + \q \v 1 \b Blessed is the man... \b @@ -4016,7 +3808,6 @@ public void IsDeltaValid_InvalidPara_ReturnsFalse() .InsertText("Book title", "imt_1") .InsertPara("imt") .InsertChapter("1") - .InsertBlank("bad_1") .InsertVerse("1") .InsertText("New verse text.", "verse_1_1") .InsertPara("bad", true); @@ -4029,15 +3820,7 @@ public void IsDeltaValid_InvalidPara_ReturnsFalse() [Test] public void IsDeltaValid_InvalidVerse_ReturnsFalse() { - var delta = Delta - .New() - .InsertChapter("1") - .InsertBlank("p_1") - .InsertVerse("1", "bad", true) - .InsertBlank("verse_1_1") - .InsertVerse("2") - .InsertBlank("verse_1_2") - .InsertPara("p"); + var delta = Delta.New().InsertChapter("1").InsertVerse("1", "bad", true).InsertVerse("2").InsertPara("p"); bool result = DeltaUsxMapper.IsDeltaValid(delta); diff --git a/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxTestExtensions.cs b/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxTestExtensions.cs index 2ddbf5a4b84..596f283d276 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxTestExtensions.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/DeltaUsxTestExtensions.cs @@ -150,7 +150,7 @@ public static Delta InsertBook(this Delta delta, string code, string style = "id if (invalid) attrs = new JObject(new JProperty("invalid-block", true)); attrs.Add(new JProperty("book", obj)); - return delta.InsertBlank($"{style}_1").Insert("\n", attrs); + return delta.Insert("\n", attrs); } public static Delta InsertChapter(this Delta delta, string number, string style = "c", bool invalid = false) diff --git a/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs b/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs index 410b59d75ee..d986a51d50b 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs @@ -697,7 +697,7 @@ public async Task SyncAsync_ProjectTextSetToNotEditable() env.SetupSFData(true, true, true, false, books); env.SetupPTData(books); SFProject project = env.GetProject("project01"); - Assert.That(project.Editable, Is.True, "setup"); + Assert.That(project.EditingRequires.HasFlag(EditingRequires.ParatextEditingEnabled), Is.True, "setup"); env.ParatextService.GetParatextUsersAsync( Arg.Any(), Arg.Is((SFProject project) => project.ParatextId == "target"), @@ -712,7 +712,7 @@ public async Task SyncAsync_ProjectTextSetToNotEditable() await env.Runner.RunAsync("project01", "user01", "project01", false, CancellationToken.None); await env.ParatextService.DidNotReceiveWithAnyArgs().PutBookText(default, default, default, default, default); project = env.VerifyProjectSync(true); - Assert.That(project.Editable, Is.False); + Assert.That(project.EditingRequires.HasFlag(EditingRequires.ParatextEditingEnabled), Is.False); } [Test] @@ -3295,7 +3295,7 @@ public async Task CoreRoundtrip_NoUnexpectedDataChanges() // Modify the text a bit so it will need written back to Paratext. const string newText = "In the beginning"; - ops[5]["insert"] = newText; + ops[4]["insert"] = newText; // Make text docs out of the chapter deltas. var chapterDeltasAsSortedList = new SortedList>( @@ -3742,6 +3742,7 @@ params Book[] books IsRightToLeft = false, DefaultFontSize = 10, DefaultFont = ProjectSettings.defaultFontName, + EditingRequires = EditingRequires.ParatextEditingEnabled | EditingRequires.ViewModelBlankSupport, TranslateConfig = new TranslateConfig { TranslationSuggestionsEnabled = translationSuggestionsEnabled,