Skip to content

Commit b9a5295

Browse files
SF-3617 Warn user when training source books are blank (#3562)
1 parent 197f24b commit b9a5295

File tree

6 files changed

+217
-53
lines changed

6 files changed

+217
-53
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const mockNoticeService = mock(NoticeService);
2020
const mockPermissionService = mock(PermissionsService);
2121
const mockProjectService = mock(ActivatedProjectService);
2222

23+
const defaultChaptersNum = 20;
24+
const defaultTranslatedNum = 9;
25+
const defaultBlankNum = 5;
26+
2327
describe('progress service', () => {
2428
configureTestingModule(() => ({
2529
providers: [
@@ -161,17 +165,63 @@ describe('progress service', () => {
161165
expect(env.service.canTrainSuggestions).toBeFalsy();
162166
discardPeriodicTasks();
163167
}));
168+
169+
it('returns text progress for texts on a project', fakeAsync(async () => {
170+
const booksWithTexts = 5;
171+
const totalTranslated = booksWithTexts * defaultChaptersNum * defaultTranslatedNum;
172+
const totalBlank = booksWithTexts * defaultChaptersNum * defaultBlankNum;
173+
const env = new TestEnvironment(totalTranslated, totalBlank);
174+
tick();
175+
176+
const texts: TextInfo[] = env.createTexts();
177+
const projectDoc: SFProjectProfileDoc = {
178+
id: 'sourceId',
179+
data: createTestProjectProfile({ texts })
180+
} as SFProjectProfileDoc;
181+
when(mockSFProjectService.getProfile(projectDoc.id)).thenResolve(projectDoc);
182+
when(mockPermissionService.isUserOnProject(anything())).thenResolve(true);
183+
184+
// SUT
185+
const progressList = await env.service.getTextProgressForProject(projectDoc.id);
186+
tick();
187+
expect(progressList.length).toEqual(texts.length);
188+
for (let i = 0; i < progressList.length; i++) {
189+
const progress = progressList[i];
190+
if (i < booksWithTexts) {
191+
expect(progress.translated).toEqual(defaultTranslatedNum * defaultChaptersNum);
192+
expect(progress.blank).toEqual(defaultBlankNum * defaultChaptersNum);
193+
} else {
194+
expect(progress.translated).toEqual(0);
195+
expect(progress.blank).toEqual(0);
196+
}
197+
}
198+
}));
199+
200+
it('returns empty text progress if user does not have permission', fakeAsync(async () => {
201+
const env = new TestEnvironment(1000, 500);
202+
tick();
203+
const texts: TextInfo[] = env.createTexts();
204+
const projectDoc: SFProjectProfileDoc = {
205+
id: 'sourceId',
206+
data: createTestProjectProfile({ texts })
207+
} as SFProjectProfileDoc;
208+
when(mockPermissionService.isUserOnProject(anything())).thenResolve(false);
209+
210+
// SUT
211+
const progressList = await env.service.getTextProgressForProject(projectDoc.id);
212+
tick();
213+
expect(progressList.length).toEqual(0);
214+
}));
164215
});
165216

166217
class TestEnvironment {
167218
readonly ngZone: NgZone = TestBed.inject(NgZone);
168219
readonly service: ProgressService;
169-
private readonly numBooks = 20;
170-
private readonly numChapters = 20;
171220

172221
readonly mockProject = mock(SFProjectProfileDoc);
173222
readonly project$ = new BehaviorSubject(instance(this.mockProject));
174223

224+
private readonly numBooks = 20;
175225
// Store all text data in a single map to avoid repeated deepEqual calls
176226
private readonly allTextData = new Map<string, { translated: number; blank: number }>();
177227

@@ -242,10 +292,10 @@ class TestEnvironment {
242292

243293
private populateTextData(projectId: string, translatedSegments: number, blankSegments: number): void {
244294
for (let book = 1; book <= this.numBooks; book++) {
245-
for (let chapter = 0; chapter < this.numChapters; chapter++) {
246-
const translated = translatedSegments >= 9 ? 9 : translatedSegments;
295+
for (let chapter = 0; chapter < defaultChaptersNum; chapter++) {
296+
const translated = translatedSegments >= defaultTranslatedNum ? defaultTranslatedNum : translatedSegments;
247297
translatedSegments -= translated;
248-
const blank = blankSegments >= 5 ? 5 : blankSegments;
298+
const blank = blankSegments >= defaultBlankNum ? defaultBlankNum : blankSegments;
249299
blankSegments -= blank;
250300

251301
const key = `${projectId}:${book}:${chapter}:target`;
@@ -283,7 +333,7 @@ class TestEnvironment {
283333
const texts: TextInfo[] = [];
284334
for (let book = 1; book <= this.numBooks; book++) {
285335
const chapters: Chapter[] = [];
286-
for (let chapter = 0; chapter < this.numChapters; chapter++) {
336+
for (let chapter = 0; chapter < defaultChaptersNum; chapter++) {
287337
chapters.push({ isValid: true, lastVerse: 1, number: chapter, permissions: {}, hasAudio: false });
288338
}
289339
texts.push({ bookNum: book, chapters: chapters, hasSource: true, permissions: {} });

src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,45 @@ export class ProgressService extends DataLoadingComponent implements OnDestroy {
230230
this._allChaptersChangeSub?.unsubscribe();
231231
}
232232

233+
/** Calculate the text progress for a project by reading every text doc for each book. */
234+
async getTextProgressForProject(projectId: string): Promise<TextProgress[]> {
235+
if (!(await this.permissionsService.isUserOnProject(projectId))) return [];
236+
237+
const projectDoc = await this.projectService.getProfile(projectId);
238+
if (projectDoc.data == null) {
239+
return [];
240+
}
241+
const chapterPromises: Promise<TextDoc[]>[] = [];
242+
const chaptersByBook: Map<number, TextDoc[]> = new Map();
243+
244+
// for every book that exists in the project calculate the translated verses
245+
for (const book of projectDoc.data.texts) {
246+
const bookChapters: TextDocId[] = book.chapters.map(
247+
c => new TextDocId(projectDoc.id, book.bookNum, c.number, 'target')
248+
);
249+
const chapterTextDocPromises = Promise.all(bookChapters.map(c => this.projectService.getText(c)));
250+
// set the map of books to text docs
251+
void chapterTextDocPromises.then(textDocs => {
252+
chaptersByBook.set(book.bookNum, textDocs);
253+
});
254+
chapterPromises.push(chapterTextDocPromises);
255+
}
256+
257+
await Promise.all(chapterPromises);
258+
const textProgressList = projectDoc.data.texts.map(t => new TextProgress(t));
259+
for (const textProgress of textProgressList) {
260+
const chapterTextDocs: TextDoc[] = chaptersByBook.get(textProgress.text.bookNum) ?? [];
261+
for (const chapterTextDoc of chapterTextDocs) {
262+
// get the translated and blank segments from the chapter docs
263+
const { translated, blank } = chapterTextDoc.getSegmentCount();
264+
textProgress.translated += translated;
265+
textProgress.blank += blank;
266+
}
267+
}
268+
269+
return textProgressList;
270+
}
271+
233272
private async initialize(projectId: string): Promise<void> {
234273
this._canTrainSuggestions = false;
235274
this._projectDoc = await this.projectService.getProfile(projectId);

src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ <h2>{{ t("translated_books") }}</h2>
133133
(bookSelect)="onTranslatedBookSelect($event)"
134134
data-test-id="draft-stepper-training-books"
135135
></app-book-multi-select>
136-
@if (unusableTrainingSourceBooks.length) {
136+
@if (unusableTrainingSourceBooks.length > 0 || emptyTrainingSourceBooks.length > 0) {
137137
<app-notice icon="info" mode="basic" type="light" class="unusable-training-books">
138138
<div class="notice-container">
139139
<span
@@ -142,22 +142,33 @@ <h2>{{ t("translated_books") }}</h2>
142142
[innerHtml]="
143143
!expandUnusableTrainingBooks
144144
? i18n.translateAndInsertTags('draft_generation_steps.books_are_hidden_show_why', {
145-
numBooks: unusableTrainingSourceBooks.length
145+
numBooks: unusableTrainingSourceBooks.length + emptyTrainingSourceBooks.length
146146
})
147147
: i18n.translateAndInsertTags('draft_generation_steps.books_are_hidden_hide_explanation', {
148-
numBooks: unusableTrainingSourceBooks.length
148+
numBooks: unusableTrainingSourceBooks.length + emptyTrainingSourceBooks.length
149149
})
150150
"
151151
>
152152
</span>
153153
@if (expandUnusableTrainingBooks) {
154-
<h4 class="explanation">
155-
<transloco
156-
key="draft_generation_steps.these_source_books_cannot_be_used_for_training"
157-
[params]="{ firstTrainingSource }"
158-
></transloco>
159-
</h4>
160-
<span class="book-names">{{ bookNames(unusableTrainingSourceBooks) }}</span>
154+
@if (emptyTrainingSourceBooks.length > 0) {
155+
<h4 class="explanation">
156+
<transloco
157+
key="draft_generation_steps.these_training_source_books_are_blank"
158+
[params]="{ firstTrainingSource }"
159+
></transloco>
160+
</h4>
161+
<span class="book-names">{{ bookNames(emptyTrainingSourceBooks) }}</span>
162+
}
163+
@if (unusableTrainingSourceBooks.length > 0) {
164+
<h4 class="explanation">
165+
<transloco
166+
key="draft_generation_steps.these_source_books_cannot_be_used_for_training"
167+
[params]="{ firstTrainingSource }"
168+
></transloco>
169+
</h4>
170+
<span class="book-names">{{ bookNames(unusableTrainingSourceBooks) }}</span>
171+
}
161172
}
162173
</div>
163174
</app-notice>

src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ describe('DraftGenerationStepsComponent', () => {
7979
{ text: { bookNum: 6 }, translated: 20, blank: 2, percentage: 90 } as TextProgress,
8080
{ text: { bookNum: 7 }, translated: 0 } as TextProgress
8181
]);
82+
const defaultTextProgress = [
83+
{ text: { bookNum: 1 }, translated: 100, percentage: 100 } as TextProgress,
84+
{ text: { bookNum: 2 }, translated: 100, percentage: 100 } as TextProgress,
85+
{ text: { bookNum: 3 }, translated: 100, percentage: 100 } as TextProgress,
86+
{ text: { bookNum: 4 }, translated: 100, percentage: 100 } as TextProgress,
87+
{ text: { bookNum: 5 }, translated: 100, percentage: 100 } as TextProgress,
88+
{ text: { bookNum: 6 }, translated: 100, percentage: 100 } as TextProgress,
89+
{ text: { bookNum: 7 }, translated: 100, percentage: 100 } as TextProgress,
90+
{ text: { bookNum: 8 }, translated: 100, percentage: 100 } as TextProgress,
91+
{ text: { bookNum: 9 }, translated: 100, percentage: 100 } as TextProgress,
92+
{ text: { bookNum: 10 }, translated: 100, percentage: 100 } as TextProgress
93+
];
94+
when(mockProgressService.getTextProgressForProject(anything())).thenResolve(defaultTextProgress);
8295
when(mockOnlineStatusService.isOnline).thenReturn(true);
8396
}));
8497

@@ -291,6 +304,7 @@ describe('DraftGenerationStepsComponent', () => {
291304
when(mockActivatedProjectService.projectDoc).thenReturn(mockTargetProjectDoc);
292305
when(mockActivatedProjectService.projectDoc$).thenReturn(targetProjectDoc$);
293306
when(mockActivatedProjectService.changes$).thenReturn(targetProjectDoc$);
307+
// setup mock source with empty book
294308
setupProjectProfileMock(
295309
sourceProjectId,
296310
sourceBooks.map(b => b.bookNum),
@@ -331,14 +345,12 @@ describe('DraftGenerationStepsComponent', () => {
331345
project01: [
332346
{ number: 1, selected: true },
333347
{ number: 2, selected: true },
334-
{ number: 3, selected: false },
335-
{ number: 5, selected: false }
348+
{ number: 3, selected: false }
336349
],
337350
sourceProject: [
338351
{ number: 1, selected: true },
339352
{ number: 2, selected: true },
340-
{ number: 3, selected: false },
341-
{ number: 5, selected: false }
353+
{ number: 3, selected: false }
342354
]
343355
});
344356
}));
@@ -347,8 +359,7 @@ describe('DraftGenerationStepsComponent', () => {
347359
expect(component.selectableTrainingBooksByProj('project01')).toEqual([
348360
{ number: 1, selected: true },
349361
{ number: 2, selected: true },
350-
{ number: 3, selected: false },
351-
{ number: 5, selected: false }
362+
{ number: 3, selected: false }
352363
]);
353364
expect(component.selectableTrainingBooksByProj(sourceProjectId)).toEqual([
354365
{ number: 1, selected: true },
@@ -374,6 +385,10 @@ describe('DraftGenerationStepsComponent', () => {
374385
expect(component.emptyTranslateSourceBooks).toEqual([5]);
375386
}));
376387

388+
it('should set "emptyTrainingSourceBooks"', fakeAsync(() => {
389+
expect(component.emptyTrainingSourceBooks).toEqual([5]);
390+
}));
391+
377392
it('should set "unusableTranslateTargetBooks" and "unusableTrainingTargetBooks" correctly', fakeAsync(() => {
378393
expect(component.unusableTranslateTargetBooks).toEqual([7]);
379394
expect(component.unusableTrainingTargetBooks).toEqual([7]);
@@ -496,10 +511,7 @@ describe('DraftGenerationStepsComponent', () => {
496511
component.tryAdvanceStep();
497512
fixture.detectChanges();
498513
component.onTranslatedBookSelect([1]);
499-
expect(component.selectableTrainingBooksByProj('project01')).toEqual([
500-
{ number: 1, selected: true },
501-
{ number: 5, selected: false }
502-
]);
514+
expect(component.selectableTrainingBooksByProj('project01')).toEqual([{ number: 1, selected: true }]);
503515
expect(component.selectedTrainingBooksByProj('project01')).toEqual([{ number: 1, selected: true }]);
504516
expect(component.selectedTrainingBooksByProj('sourceProject')).toEqual([{ number: 1, selected: true }]);
505517
component.stepper.selectedIndex = 1;
@@ -511,8 +523,7 @@ describe('DraftGenerationStepsComponent', () => {
511523
// Exodus becomes a selectable training book
512524
expect(component.selectableTrainingBooksByProj('project01')).toEqual([
513525
{ number: 1, selected: true },
514-
{ number: 2, selected: false },
515-
{ number: 5, selected: false }
526+
{ number: 2, selected: false }
516527
]);
517528
expect(component.selectedTrainingBooksByProj('sourceProject')).toEqual([{ number: 1, selected: true }]);
518529
expect(component.selectedTrainingBooksByProj('project01')).toEqual([{ number: 1, selected: true }]);
@@ -618,9 +629,11 @@ describe('DraftGenerationStepsComponent', () => {
618629
});
619630

620631
describe('two training sources', () => {
621-
const availableBooks = [{ bookNum: 2 }, { bookNum: 3 }];
632+
const availableBooks = [{ bookNum: 2 }, { bookNum: 3 }, { bookNum: 5 }];
622633
const allBooks = [{ bookNum: 1 }, ...availableBooks, { bookNum: 6 }, { bookNum: 7 }, { bookNum: 8 }];
623634
const draftingSourceBooks = availableBooks.concat({ bookNum: 7 });
635+
const trainingSource1Books = availableBooks.concat({ bookNum: 1 });
636+
const trainingSource2Books = availableBooks.concat({ bookNum: 6 });
624637
const draftingSourceId = 'draftingSource';
625638
const config = {
626639
trainingSources: [
@@ -629,14 +642,14 @@ describe('DraftGenerationStepsComponent', () => {
629642
paratextId: 'PT_SP1',
630643
shortName: 'sP1',
631644
writingSystem: { tag: 'eng' },
632-
texts: availableBooks.concat({ bookNum: 1 })
645+
texts: trainingSource1Books
633646
},
634647
{
635648
projectRef: 'source2',
636649
paratextId: 'PT_SP2',
637650
shortName: 'sP2',
638651
writingSystem: { tag: 'eng' },
639-
texts: availableBooks.concat({ bookNum: 6 })
652+
texts: trainingSource2Books
640653
}
641654
] as [DraftSource, DraftSource],
642655
trainingTargets: [
@@ -657,14 +670,27 @@ describe('DraftGenerationStepsComponent', () => {
657670
] as [DraftSource]
658671
};
659672

673+
const emptyBooks = [5];
660674
beforeEach(fakeAsync(() => {
661675
when(mockDraftSourceService.getDraftProjectSources()).thenReturn(of(config));
662676
when(mockActivatedProjectService.projectDoc$).thenReturn(of({} as any));
663677
when(mockActivatedProjectService.changes$).thenReturn(of({} as any));
664678
when(mockActivatedProjectService.projectDoc).thenReturn({} as any);
679+
// setup mock sources with empty book
665680
setupProjectProfileMock(
666681
draftingSourceId,
667-
draftingSourceBooks.map(b => b.bookNum)
682+
draftingSourceBooks.map(b => b.bookNum),
683+
emptyBooks
684+
);
685+
setupProjectProfileMock(
686+
'source1',
687+
trainingSource1Books.map(b => b.bookNum),
688+
emptyBooks
689+
);
690+
setupProjectProfileMock(
691+
'source2',
692+
trainingSource2Books.map(b => b.bookNum),
693+
emptyBooks
668694
);
669695
when(mockFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(false));
670696
when(mockNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(true);
@@ -680,6 +706,7 @@ describe('DraftGenerationStepsComponent', () => {
680706
expect(component.allAvailableTranslateBooks).toEqual([
681707
{ number: 2, selected: false },
682708
{ number: 3, selected: false },
709+
{ number: 5, selected: false },
683710
{ number: 7, selected: false }
684711
]);
685712
}));
@@ -720,6 +747,8 @@ describe('DraftGenerationStepsComponent', () => {
720747
fixture.detectChanges();
721748
expect(component.unusableTranslateSourceBooks).toEqual([1, 6, 8]);
722749
expect(component.unusableTrainingSourceBooks).toEqual([6, 7, 8]);
750+
expect(component.emptyTranslateSourceBooks).toEqual([5]);
751+
expect(component.emptyTrainingSourceBooks).toEqual([5]);
723752

724753
// interact with unusable books notice
725754
const unusableTranslateBooks = fixture.nativeElement.querySelector('.unusable-translate-books');
@@ -1456,10 +1485,18 @@ describe('DraftGenerationStepsComponent', () => {
14561485
const profileDoc = {
14571486
id: projectId,
14581487
data: createTestProjectProfile({
1459-
texts: texts.map(b => ({ bookNum: b, chapters: [{ number: 1, lastVerse: emptyBooks.includes(b) ? 0 : 10 }] }))
1488+
texts: texts.map(b => ({ bookNum: b, chapters: [{ number: 1, lastVerse: 10 }] }))
14601489
})
14611490
} as SFProjectProfileDoc;
14621491

14631492
when(mockProjectService.getProfile(projectId)).thenResolve(profileDoc);
1493+
const textProgress = texts.map(bookNum => {
1494+
return {
1495+
text: { bookNum },
1496+
translated: emptyBooks.includes(bookNum) ? 0 : 100,
1497+
percentage: emptyBooks.includes(bookNum) ? 0 : 100
1498+
} as TextProgress;
1499+
});
1500+
when(mockProgressService.getTextProgressForProject(projectId)).thenResolve(textProgress);
14641501
}
14651502
});

0 commit comments

Comments
 (0)