From fb4220e0f57fd2b8775cdc416f4ef8fab05d7a7d Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Fri, 31 Oct 2025 16:18:25 -0600 Subject: [PATCH 1/2] SF-3617 Warn user when training source books are blank --- .../progress-service/progress.service.spec.ts | 62 +++++++++++++++-- .../progress-service/progress.service.ts | 39 +++++++++++ .../draft-generation-steps.component.html | 31 ++++++--- .../draft-generation-steps.component.spec.ts | 67 ++++++++++++++----- .../draft-generation-steps.component.ts | 54 ++++++++++----- .../src/assets/i18n/non_checking_en.json | 1 + 6 files changed, 206 insertions(+), 48 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts index d727be07e2a..1ac31af1805 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts @@ -20,6 +20,10 @@ const mockNoticeService = mock(NoticeService); const mockPermissionService = mock(PermissionsService); const mockProjectService = mock(ActivatedProjectService); +const defaultChaptersNum = 20; +const defaultTranslatedNum = 9; +const defaultBlankNum = 5; + describe('progress service', () => { configureTestingModule(() => ({ providers: [ @@ -161,17 +165,63 @@ describe('progress service', () => { expect(env.service.canTrainSuggestions).toBeFalsy(); discardPeriodicTasks(); })); + + it('returns text progress for texts on a project', fakeAsync(async () => { + const booksWithTexts = 5; + const totalTranslated = booksWithTexts * defaultChaptersNum * defaultTranslatedNum; + const totalBlank = booksWithTexts * defaultChaptersNum * defaultBlankNum; + const env = new TestEnvironment(totalTranslated, totalBlank); + tick(); + + const texts: TextInfo[] = env.createTexts(); + const projectDoc: SFProjectProfileDoc = { + id: 'sourceId', + data: createTestProjectProfile({ texts }) + } as SFProjectProfileDoc; + when(mockSFProjectService.getProfile(projectDoc.id)).thenResolve(projectDoc); + when(mockPermissionService.isUserOnProject(anything())).thenResolve(true); + + // SUT + const progressList = await env.service.getTextProgressForProject(projectDoc.id); + tick(); + expect(progressList.length).toEqual(texts.length); + for (let i = 0; i < progressList.length; i++) { + const progress = progressList[i]; + if (i < booksWithTexts) { + expect(progress.translated).toEqual(defaultTranslatedNum * defaultChaptersNum); + expect(progress.blank).toEqual(defaultBlankNum * defaultChaptersNum); + } else { + expect(progress.translated).toEqual(0); + expect(progress.blank).toEqual(0); + } + } + })); + + it('returns empty text progress if user does not have permission', fakeAsync(async () => { + const env = new TestEnvironment(1000, 500); + tick(); + const texts: TextInfo[] = env.createTexts(); + const projectDoc: SFProjectProfileDoc = { + id: 'sourceId', + data: createTestProjectProfile({ texts }) + } as SFProjectProfileDoc; + when(mockPermissionService.isUserOnProject(anything())).thenResolve(false); + + // SUT + const progressList = await env.service.getTextProgressForProject(projectDoc.id); + tick(); + expect(progressList.length).toEqual(0); + })); }); class TestEnvironment { readonly ngZone: NgZone = TestBed.inject(NgZone); readonly service: ProgressService; - private readonly numBooks = 20; - private readonly numChapters = 20; readonly mockProject = mock(SFProjectProfileDoc); readonly project$ = new BehaviorSubject(instance(this.mockProject)); + private readonly numBooks = 20; // Store all text data in a single map to avoid repeated deepEqual calls private readonly allTextData = new Map(); @@ -242,10 +292,10 @@ class TestEnvironment { private populateTextData(projectId: string, translatedSegments: number, blankSegments: number): void { for (let book = 1; book <= this.numBooks; book++) { - for (let chapter = 0; chapter < this.numChapters; chapter++) { - const translated = translatedSegments >= 9 ? 9 : translatedSegments; + for (let chapter = 0; chapter < defaultChaptersNum; chapter++) { + const translated = translatedSegments >= defaultTranslatedNum ? defaultTranslatedNum : translatedSegments; translatedSegments -= translated; - const blank = blankSegments >= 5 ? 5 : blankSegments; + const blank = blankSegments >= defaultBlankNum ? defaultBlankNum : blankSegments; blankSegments -= blank; const key = `${projectId}:${book}:${chapter}:target`; @@ -283,7 +333,7 @@ class TestEnvironment { const texts: TextInfo[] = []; for (let book = 1; book <= this.numBooks; book++) { const chapters: Chapter[] = []; - for (let chapter = 0; chapter < this.numChapters; chapter++) { + for (let chapter = 0; chapter < defaultChaptersNum; chapter++) { chapters.push({ isValid: true, lastVerse: 1, number: chapter, permissions: {}, hasAudio: false }); } texts.push({ bookNum: book, chapters: chapters, hasSource: true, permissions: {} }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts index ca4cf94a3e4..265aee84e34 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts @@ -230,6 +230,45 @@ export class ProgressService extends DataLoadingComponent implements OnDestroy { this._allChaptersChangeSub?.unsubscribe(); } + /** Calculate the text progress for a project by reading every text doc for each book. */ + async getTextProgressForProject(projectId: string): Promise { + if (!(await this.permissionsService.isUserOnProject(projectId))) return []; + + const projectDoc = await this.projectService.getProfile(projectId); + if (projectDoc.data == null) { + return []; + } + const chapterPromises: Promise[] = []; + const chaptersByBook: Map = new Map(); + + // for every book that exists in the project calculate the translated verses + for (const book of projectDoc.data.texts) { + const bookChapters: TextDocId[] = book.chapters.map( + c => new TextDocId(projectDoc.id, book.bookNum, c.number, 'target') + ); + const chapterTextDocPromises = Promise.all(bookChapters.map(c => this.projectService.getText(c))); + // set the map of books to text docs + void chapterTextDocPromises.then(textDocs => { + chaptersByBook.set(book.bookNum, textDocs); + }); + chapterPromises.push(chapterTextDocPromises); + } + + await Promise.all(chapterPromises); + const textProgressList = projectDoc.data.texts.map(t => new TextProgress(t)); + for (const textProgress of textProgressList) { + const chapterTextDocs: TextDoc[] = chaptersByBook.get(textProgress.text.bookNum) ?? []; + for (const chapterTextDoc of chapterTextDocs) { + // get the translated and blank segments from the chapter docs + const { translated, blank } = chapterTextDoc.getSegmentCount(); + textProgress.translated += translated; + textProgress.blank += blank; + } + } + + return textProgressList; + } + private async initialize(projectId: string): Promise { this._canTrainSuggestions = false; this._projectDoc = await this.projectService.getProfile(projectId); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html index 0d3b4f0a4d0..9398f805dd7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html @@ -133,7 +133,7 @@

{{ t("translated_books") }}

(bookSelect)="onTranslatedBookSelect($event)" data-test-id="draft-stepper-training-books" > - @if (unusableTrainingSourceBooks.length) { + @if (unusableTrainingSourceBooks.length > 0 || emptyTrainingSourceBooks.length > 0) {
{{ t("translated_books") }} [innerHtml]=" !expandUnusableTrainingBooks ? i18n.translateAndInsertTags('draft_generation_steps.books_are_hidden_show_why', { - numBooks: unusableTrainingSourceBooks.length + numBooks: unusableTrainingSourceBooks.length + emptyTrainingSourceBooks.length }) : i18n.translateAndInsertTags('draft_generation_steps.books_are_hidden_hide_explanation', { - numBooks: unusableTrainingSourceBooks.length + numBooks: unusableTrainingSourceBooks.length + emptyTrainingSourceBooks.length }) " > @if (expandUnusableTrainingBooks) { -

- -

- {{ bookNames(unusableTrainingSourceBooks) }} + @if (emptyTrainingSourceBooks.length > 0) { +

+ +

+ {{ bookNames(emptyTrainingSourceBooks) }} + } + @if (unusableTrainingSourceBooks.length > 0) { +

+ +

+ {{ bookNames(unusableTrainingSourceBooks) }} + } }
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index 3e22dc7fd27..d30f52e1b11 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts @@ -79,6 +79,19 @@ describe('DraftGenerationStepsComponent', () => { { text: { bookNum: 6 }, translated: 20, blank: 2, percentage: 90 } as TextProgress, { text: { bookNum: 7 }, translated: 0 } as TextProgress ]); + const textProgress = [ + { text: { bookNum: 1 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 2 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 3 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 4 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 5 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 6 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 7 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 8 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 9 }, translated: 100, percentage: 100 } as TextProgress, + { text: { bookNum: 10 }, translated: 100, percentage: 100 } as TextProgress + ]; + when(mockProgressService.getTextProgressForProject(anything())).thenResolve(textProgress); when(mockOnlineStatusService.isOnline).thenReturn(true); })); @@ -331,14 +344,12 @@ describe('DraftGenerationStepsComponent', () => { project01: [ { number: 1, selected: true }, { number: 2, selected: true }, - { number: 3, selected: false }, - { number: 5, selected: false } + { number: 3, selected: false } ], sourceProject: [ { number: 1, selected: true }, { number: 2, selected: true }, - { number: 3, selected: false }, - { number: 5, selected: false } + { number: 3, selected: false } ] }); })); @@ -347,8 +358,7 @@ describe('DraftGenerationStepsComponent', () => { expect(component.selectableTrainingBooksByProj('project01')).toEqual([ { number: 1, selected: true }, { number: 2, selected: true }, - { number: 3, selected: false }, - { number: 5, selected: false } + { number: 3, selected: false } ]); expect(component.selectableTrainingBooksByProj(sourceProjectId)).toEqual([ { number: 1, selected: true }, @@ -374,6 +384,10 @@ describe('DraftGenerationStepsComponent', () => { expect(component.emptyTranslateSourceBooks).toEqual([5]); })); + it('should set "emptyTrainingSourceBooks"', fakeAsync(() => { + expect(component.emptyTrainingSourceBooks).toEqual([5]); + })); + it('should set "unusableTranslateTargetBooks" and "unusableTrainingTargetBooks" correctly', fakeAsync(() => { expect(component.unusableTranslateTargetBooks).toEqual([7]); expect(component.unusableTrainingTargetBooks).toEqual([7]); @@ -496,10 +510,7 @@ describe('DraftGenerationStepsComponent', () => { component.tryAdvanceStep(); fixture.detectChanges(); component.onTranslatedBookSelect([1]); - expect(component.selectableTrainingBooksByProj('project01')).toEqual([ - { number: 1, selected: true }, - { number: 5, selected: false } - ]); + expect(component.selectableTrainingBooksByProj('project01')).toEqual([{ number: 1, selected: true }]); expect(component.selectedTrainingBooksByProj('project01')).toEqual([{ number: 1, selected: true }]); expect(component.selectedTrainingBooksByProj('sourceProject')).toEqual([{ number: 1, selected: true }]); component.stepper.selectedIndex = 1; @@ -511,8 +522,7 @@ describe('DraftGenerationStepsComponent', () => { // Exodus becomes a selectable training book expect(component.selectableTrainingBooksByProj('project01')).toEqual([ { number: 1, selected: true }, - { number: 2, selected: false }, - { number: 5, selected: false } + { number: 2, selected: false } ]); expect(component.selectedTrainingBooksByProj('sourceProject')).toEqual([{ number: 1, selected: true }]); expect(component.selectedTrainingBooksByProj('project01')).toEqual([{ number: 1, selected: true }]); @@ -618,9 +628,11 @@ describe('DraftGenerationStepsComponent', () => { }); describe('two training sources', () => { - const availableBooks = [{ bookNum: 2 }, { bookNum: 3 }]; + const availableBooks = [{ bookNum: 2 }, { bookNum: 3 }, { bookNum: 5 }]; const allBooks = [{ bookNum: 1 }, ...availableBooks, { bookNum: 6 }, { bookNum: 7 }, { bookNum: 8 }]; const draftingSourceBooks = availableBooks.concat({ bookNum: 7 }); + const trainingSource1Books = availableBooks.concat({ bookNum: 1 }); + const trainingSource2Books = availableBooks.concat({ bookNum: 6 }); const draftingSourceId = 'draftingSource'; const config = { trainingSources: [ @@ -629,14 +641,14 @@ describe('DraftGenerationStepsComponent', () => { paratextId: 'PT_SP1', shortName: 'sP1', writingSystem: { tag: 'eng' }, - texts: availableBooks.concat({ bookNum: 1 }) + texts: trainingSource1Books }, { projectRef: 'source2', paratextId: 'PT_SP2', shortName: 'sP2', writingSystem: { tag: 'eng' }, - texts: availableBooks.concat({ bookNum: 6 }) + texts: trainingSource2Books } ] as [DraftSource, DraftSource], trainingTargets: [ @@ -657,6 +669,7 @@ describe('DraftGenerationStepsComponent', () => { ] as [DraftSource] }; + const emptyBooks = [5]; beforeEach(fakeAsync(() => { when(mockDraftSourceService.getDraftProjectSources()).thenReturn(of(config)); when(mockActivatedProjectService.projectDoc$).thenReturn(of({} as any)); @@ -664,7 +677,18 @@ describe('DraftGenerationStepsComponent', () => { when(mockActivatedProjectService.projectDoc).thenReturn({} as any); setupProjectProfileMock( draftingSourceId, - draftingSourceBooks.map(b => b.bookNum) + draftingSourceBooks.map(b => b.bookNum), + emptyBooks + ); + setupProjectProfileMock( + 'source1', + trainingSource1Books.map(b => b.bookNum), + emptyBooks + ); + setupProjectProfileMock( + 'source2', + trainingSource2Books.map(b => b.bookNum), + emptyBooks ); when(mockFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(false)); when(mockNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(true); @@ -680,6 +704,7 @@ describe('DraftGenerationStepsComponent', () => { expect(component.allAvailableTranslateBooks).toEqual([ { number: 2, selected: false }, { number: 3, selected: false }, + { number: 5, selected: false }, { number: 7, selected: false } ]); })); @@ -720,6 +745,8 @@ describe('DraftGenerationStepsComponent', () => { fixture.detectChanges(); expect(component.unusableTranslateSourceBooks).toEqual([1, 6, 8]); expect(component.unusableTrainingSourceBooks).toEqual([6, 7, 8]); + expect(component.emptyTranslateSourceBooks).toEqual([5]); + expect(component.emptyTrainingSourceBooks).toEqual([5]); // interact with unusable books notice const unusableTranslateBooks = fixture.nativeElement.querySelector('.unusable-translate-books'); @@ -1461,5 +1488,13 @@ describe('DraftGenerationStepsComponent', () => { } as SFProjectProfileDoc; when(mockProjectService.getProfile(projectId)).thenResolve(profileDoc); + const textProgress = texts.map(bookNum => { + return { + text: { bookNum }, + translated: emptyBooks.includes(bookNum) ? 0 : 100, + percentage: emptyBooks.includes(bookNum) ? 0 : 100 + } as TextProgress; + }); + when(mockProgressService.getTextProgressForProject(projectId)).thenResolve(textProgress); } }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts index 968c07939a6..23aeee39e8f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts @@ -22,14 +22,12 @@ import { TranslocoModule } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; import { isEqual } from 'lodash-es'; import { TranslocoMarkupModule } from 'ngx-transloco-markup'; -import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; import { DraftConfig, ProjectScriptureRange, TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; -import { TextInfo } from 'realtime-server/scriptureforge/models/text-info'; import { combineLatest, merge, Subscription } from 'rxjs'; import { distinctUntilChanged, filter } from 'rxjs/operators'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; @@ -43,7 +41,6 @@ import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { ParatextProject } from '../../../core/models/paratext-project'; -import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { TrainingDataDoc } from '../../../core/models/training-data-doc'; import { ParatextService } from '../../../core/paratext.service'; import { SFProjectService } from '../../../core/sf-project.service'; @@ -137,6 +134,7 @@ export class DraftGenerationStepsComponent implements OnInit { unusableTranslateSourceBooks: number[] = []; unusableTranslateTargetBooks: number[] = []; emptyTranslateSourceBooks: number[] = []; + emptyTrainingSourceBooks: number[] = []; unusableTrainingSourceBooks: number[] = []; unusableTrainingTargetBooks: number[] = []; @@ -163,6 +161,8 @@ export class DraftGenerationStepsComponent implements OnInit { protected trainingTargets: DraftSource[] = []; protected trainingDataFiles: Readonly[] = []; + private sourceProgress: Map = new Map(); + private trainingDataQuery?: RealtimeQuery; private trainingDataQuerySubscription?: Subscription; private currentUserDoc?: UserDoc; @@ -170,7 +170,7 @@ export class DraftGenerationStepsComponent implements OnInit { constructor( private readonly destroyRef: DestroyRef, protected readonly activatedProject: ActivatedProjectService, - private readonly projectService: SFProjectService, + readonly projectService: SFProjectService, private readonly draftSourcesService: DraftSourcesService, protected readonly featureFlags: FeatureFlagService, private readonly nllbLanguageService: NllbLanguageService, @@ -212,7 +212,7 @@ export class DraftGenerationStepsComponent implements OnInit { // The null values will have been filtered above const target = trainingTargets[0]!; - const draftingSource = draftingSources[0]!; + const draftingSource: DraftSource = draftingSources[0]!; // If both source and target project languages are in the NLLB, // training book selection is optional (and discouraged). this.isTrainingOptional = @@ -225,9 +225,6 @@ export class DraftGenerationStepsComponent implements OnInit { // TODO: When implementing multiple drafting sources, this will need to be updated to handle multiple sources const draftingSourceBooks = new Set(); - const draftingSourceProfileDoc: SFProjectProfileDoc = await this.projectService.getProfile( - draftingSource.projectRef - ); for (const text of draftingSource.texts) { draftingSourceBooks.add(text.bookNum); } @@ -237,11 +234,17 @@ export class DraftGenerationStepsComponent implements OnInit { for (const source of this.draftingSources) { this.availableTranslateBooks[source?.projectRef] = []; + if (source.noAccess) continue; + const draftSourceProgress = await this.progressService.getTextProgressForProject(source.projectRef); + this.sourceProgress.set(source.projectRef, draftSourceProgress); } this.availableTrainingBooks[projectId!] = []; for (const source of this.trainingSources) { this.availableTrainingBooks[source?.projectRef] = []; + if (source.noAccess || this.sourceProgress.has(source.projectRef)) continue; + const trainingSourceProgress = await this.progressService.getTextProgressForProject(source.projectRef); + this.sourceProgress.set(source.projectRef, trainingSourceProgress); } this.trainingDataQuery?.dispose(); @@ -280,8 +283,8 @@ export class DraftGenerationStepsComponent implements OnInit { if (draftingSourceBooks.has(bookNum)) { const book: Book = { number: bookNum, selected: false }; this.allAvailableTranslateBooks.push(book); - if (this.sourceBookHasContent(draftingSourceProfileDoc.data, bookNum)) { - this.availableTranslateBooks[draftingSources[0]!.projectRef].push(book); + if (await this.bookHasVerseContent(draftingSource.projectRef, bookNum)) { + this.availableTranslateBooks[draftingSource.projectRef].push(book); } else { this.emptyTranslateSourceBooks.push(bookNum); } @@ -313,18 +316,36 @@ export class DraftGenerationStepsComponent implements OnInit { // Training books let isPresentInASource = false; + let isBookEmptyInAllSources = true; if (trainingSourceBooks.has(bookNum)) { - this.availableTrainingBooks[trainingSources[0]!.projectRef].push({ number: bookNum, selected: selected }); isPresentInASource = true; + if (await this.bookHasVerseContent(trainingSources[0]!.projectRef, bookNum)) { + this.availableTrainingBooks[trainingSources[0]!.projectRef].push({ + number: bookNum, + selected: selected + }); + isBookEmptyInAllSources = false; + } } else { this.unusableTrainingSourceBooks.push(bookNum); } if (trainingSources[1] != null && secondTrainingSourceBooks.has(bookNum)) { - this.availableTrainingBooks[trainingSources[1].projectRef].push({ number: bookNum, selected: selected }); isPresentInASource = true; + if (await this.bookHasVerseContent(trainingSources[1]!.projectRef, bookNum)) { + this.availableTrainingBooks[trainingSources[1].projectRef].push({ + number: bookNum, + selected: selected + }); + isBookEmptyInAllSources = false; + } } if (isPresentInASource) { - this.availableTrainingBooks[projectId!].push({ number: bookNum, selected: selected }); + if (isBookEmptyInAllSources) { + // the books is present but is empty + this.emptyTrainingSourceBooks.push(bookNum); + } else { + this.availableTrainingBooks[projectId!].push({ number: bookNum, selected: selected }); + } } } @@ -695,9 +716,10 @@ export class DraftGenerationStepsComponent implements OnInit { } } - private sourceBookHasContent(project: SFProjectProfile | undefined, bookNum: number): boolean { - const sourceProjectText: TextInfo | undefined = project?.texts.find(t => t.bookNum === bookNum); - return (sourceProjectText?.chapters ?? []).some(c => c.lastVerse > 0); + /** Check whether a project book has any translated verses. */ + private async bookHasVerseContent(projectId: string, bookNum: number): Promise { + if (!this.sourceProgress.has(projectId)) return false; + return (this.sourceProgress.get(projectId)!.find(p => p.text.bookNum === bookNum)?.translated ?? 0) > 0; } private setProjectDisplayNames(target: DraftSource | undefined, draftingSource: DraftSource | undefined): void { 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 aeb777f7e8e..83a7e0b2c6d 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 @@ -264,6 +264,7 @@ "summary_translate_title": "Drafting from [b]{{ draftingSourceProjectName }}[/b]", "summary_translate": "The language model will then translate these books:", "these_source_books_are_blank": "The following books cannot be translated as they are blank in the drafting source text ({{ draftingSourceProjectName }}).", + "these_training_source_books_are_blank": "The following books cannot be used for training as they are blank in the source text ({{ firstTrainingSource }}).", "these_source_books_cannot_be_used_for_training": "The following books cannot be used for training as they are not in the training source text ({{ firstTrainingSource }}).", "these_source_books_cannot_be_used_for_translating": "The following books cannot be translated as they are not in the drafting source text ({{ draftingSourceProjectName }}).", "training_books_will_appear": "Training books will appear as you select books under translated books", From ae89d6518b3341884e25de4b72785e42079316ae Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Thu, 13 Nov 2025 10:20:07 -0700 Subject: [PATCH 2/2] Remove non null assertions --- .../draft-generation-steps.component.spec.ts | 8 ++++--- .../draft-generation-steps.component.ts | 24 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index d30f52e1b11..ff26417853d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts @@ -79,7 +79,7 @@ describe('DraftGenerationStepsComponent', () => { { text: { bookNum: 6 }, translated: 20, blank: 2, percentage: 90 } as TextProgress, { text: { bookNum: 7 }, translated: 0 } as TextProgress ]); - const textProgress = [ + const defaultTextProgress = [ { text: { bookNum: 1 }, translated: 100, percentage: 100 } as TextProgress, { text: { bookNum: 2 }, translated: 100, percentage: 100 } as TextProgress, { text: { bookNum: 3 }, translated: 100, percentage: 100 } as TextProgress, @@ -91,7 +91,7 @@ describe('DraftGenerationStepsComponent', () => { { text: { bookNum: 9 }, translated: 100, percentage: 100 } as TextProgress, { text: { bookNum: 10 }, translated: 100, percentage: 100 } as TextProgress ]; - when(mockProgressService.getTextProgressForProject(anything())).thenResolve(textProgress); + when(mockProgressService.getTextProgressForProject(anything())).thenResolve(defaultTextProgress); when(mockOnlineStatusService.isOnline).thenReturn(true); })); @@ -304,6 +304,7 @@ describe('DraftGenerationStepsComponent', () => { when(mockActivatedProjectService.projectDoc).thenReturn(mockTargetProjectDoc); when(mockActivatedProjectService.projectDoc$).thenReturn(targetProjectDoc$); when(mockActivatedProjectService.changes$).thenReturn(targetProjectDoc$); + // setup mock source with empty book setupProjectProfileMock( sourceProjectId, sourceBooks.map(b => b.bookNum), @@ -675,6 +676,7 @@ describe('DraftGenerationStepsComponent', () => { when(mockActivatedProjectService.projectDoc$).thenReturn(of({} as any)); when(mockActivatedProjectService.changes$).thenReturn(of({} as any)); when(mockActivatedProjectService.projectDoc).thenReturn({} as any); + // setup mock sources with empty book setupProjectProfileMock( draftingSourceId, draftingSourceBooks.map(b => b.bookNum), @@ -1483,7 +1485,7 @@ describe('DraftGenerationStepsComponent', () => { const profileDoc = { id: projectId, data: createTestProjectProfile({ - texts: texts.map(b => ({ bookNum: b, chapters: [{ number: 1, lastVerse: emptyBooks.includes(b) ? 0 : 10 }] })) + texts: texts.map(b => ({ bookNum: b, chapters: [{ number: 1, lastVerse: 10 }] })) }) } as SFProjectProfileDoc; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts index 23aeee39e8f..65ba8a4bc0c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts @@ -54,7 +54,8 @@ import { DraftSource, DraftSourcesService } from '../draft-sources.service'; import { TrainingDataService } from '../training-data/training-data.service'; // We consider books with more than 10 translated segments as translated -const minimumTranslatedSegments: number = 10; +const autoSelectMinTranslatedSegments: number = 10; +const nonEmptyBookMinTranslatedSegments: number = 2; export interface DraftGenerationStepsResult { trainingDataFiles: string[]; @@ -308,7 +309,7 @@ export class DraftGenerationStepsComponent implements OnInit { const selected: boolean = !hasPreviousTrainingRange && textProgress != null && - textProgress.translated > minimumTranslatedSegments && + textProgress.translated > autoSelectMinTranslatedSegments && (textProgress.percentage >= 99 || textProgress.notTranslated <= 3); // If books were automatically selected, reflect this in the UI via a notice @@ -317,10 +318,11 @@ export class DraftGenerationStepsComponent implements OnInit { // Training books let isPresentInASource = false; let isBookEmptyInAllSources = true; - if (trainingSourceBooks.has(bookNum)) { + const firstTrainingSourceRef = trainingSources[0]?.projectRef; + if (firstTrainingSourceRef != null && trainingSourceBooks.has(bookNum)) { isPresentInASource = true; - if (await this.bookHasVerseContent(trainingSources[0]!.projectRef, bookNum)) { - this.availableTrainingBooks[trainingSources[0]!.projectRef].push({ + if (await this.bookHasVerseContent(firstTrainingSourceRef, bookNum)) { + this.availableTrainingBooks[firstTrainingSourceRef].push({ number: bookNum, selected: selected }); @@ -329,10 +331,11 @@ export class DraftGenerationStepsComponent implements OnInit { } else { this.unusableTrainingSourceBooks.push(bookNum); } - if (trainingSources[1] != null && secondTrainingSourceBooks.has(bookNum)) { + const secondTrainingSourceRef = trainingSources[1]?.projectRef; + if (secondTrainingSourceRef != null && secondTrainingSourceBooks.has(bookNum)) { isPresentInASource = true; - if (await this.bookHasVerseContent(trainingSources[1]!.projectRef, bookNum)) { - this.availableTrainingBooks[trainingSources[1].projectRef].push({ + if (await this.bookHasVerseContent(secondTrainingSourceRef, bookNum)) { + this.availableTrainingBooks[secondTrainingSourceRef].push({ number: bookNum, selected: selected }); @@ -718,8 +721,9 @@ export class DraftGenerationStepsComponent implements OnInit { /** Check whether a project book has any translated verses. */ private async bookHasVerseContent(projectId: string, bookNum: number): Promise { - if (!this.sourceProgress.has(projectId)) return false; - return (this.sourceProgress.get(projectId)!.find(p => p.text.bookNum === bookNum)?.translated ?? 0) > 0; + const textProgress = this.sourceProgress.get(projectId); + if (textProgress == null) return false; + return (textProgress.find(p => p.text.bookNum === bookNum)?.translated ?? 0) > nonEmptyBookMinTranslatedSegments; } private setProjectDisplayNames(target: DraftSource | undefined, draftingSource: DraftSource | undefined): void {